Refactor AppListPage

This commit is contained in:
waterplea
2021-11-22 16:33:41 +03:00
committed by Aiden McClelland
parent b546eb2504
commit ee81ca111b
29 changed files with 719 additions and 17510 deletions

17124
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -49,6 +49,7 @@
"@types/node": "^16.7.13",
"@types/uuid": "^8.3.1",
"node-html-parser": "^4.1.4",
"prettier": "^2.4.1",
"raw-loader": "^4.0.2",
"ts-node": "^10.2.0",
"tslint": "^6.1.3",

View File

@@ -59,7 +59,7 @@ export class WizardBaker {
action,
verb: 'beginning update for',
title,
executeAction: () => this.embassyApi.installPackage({ id, 'version-spec': version ? `=${version}` : undefined }),
executeAction: () => this.embassyApi.installPackage({ id, version: version ? `=${version}` : undefined }),
},
},
bottomBar: {
@@ -158,7 +158,7 @@ export class WizardBaker {
action,
verb: 'beginning downgrade for',
title,
executeAction: () => this.embassyApi.installPackage({ id, 'version-spec': version ? `=${version}` : undefined }),
executeAction: () => this.embassyApi.installPackage({ id, version: version ? `=${version}` : undefined }),
},
},
bottomBar: {

View File

@@ -0,0 +1,16 @@
<div class="welcome">
<h2>
Welcome to
<ion-text color="danger" class="embassy">Embassy</ion-text>
</h2>
<p class="ion-text-wrap">Get started by installing your first service.</p>
</div>
<ion-button
color="dark"
routerLink="'/marketplace'"
routerDirection="root"
class="marketplace"
>
<ion-icon slot="start" name="storefront-outline"></ion-icon>
Marketplace
</ion-button>

View File

@@ -0,0 +1,18 @@
:host {
display: block;
}
.welcome {
display: flex;
flex-direction: column;
justify-content: center;
height: 40vh;
}
.embassy {
font-family: "Montserrat", sans-serif;
}
.marketplace {
width: 50%;
}

View File

@@ -0,0 +1,9 @@
import { ChangeDetectionStrategy, Component } from "@angular/core";
@Component({
selector: "app-list-empty",
templateUrl: "app-list-empty.component.html",
styleUrls: ["app-list-empty.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppListEmptyComponent {}

View File

@@ -0,0 +1,10 @@
<ion-icon
*ngIf="pkg.error; else bulb"
class="warning-icon"
name="warning-outline"
size="small"
color="warning"
></ion-icon>
<ng-template #bulb>
<div class="bulb" [style.background-color]="getColor(pkg)"></div>
</ng-template>

View File

@@ -0,0 +1,24 @@
:host {
display: block;
}
.bulb {
position: absolute !important;
left: 9px !important;
top: 8px !important;
height: 14px;
width: 14px;
border-radius: 100%;
box-shadow: 0 0 6px 6px rgba(255, 213, 52, 0.1);
}
.warning-icon {
position: absolute !important;
left: 6px !important;
top: 0 !important;
font-size: 12px;
border-radius: 100%;
padding: 1px;
background-color: rgba(255, 213, 52, 0.1);
box-shadow: 0 0 4px 4px rgba(255, 213, 52, 0.1);
}

View File

@@ -0,0 +1,22 @@
import { ChangeDetectionStrategy, Component, Input } from "@angular/core";
import { PkgInfo } from "src/app/util/get-package-info";
@Component({
selector: "app-list-icon",
templateUrl: "app-list-icon.component.html",
styleUrls: ["app-list-icon.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppListIconComponent {
@Input()
pkg: PkgInfo;
@Input()
connectionFailure = false;
getColor(pkg: PkgInfo): string {
return this.connectionFailure
? "var(--ion-color-dark)"
: "var(--ion-color-" + pkg.primaryRendering.color + ")";
}
}

View File

@@ -0,0 +1,42 @@
<ion-item
button
detail="false"
[routerLink]="['/services', pkg.entry.manifest.id]"
>
<app-list-icon
slot="start"
[pkg]="pkg"
[connectionFailure]="connectionFailure"
></app-list-icon>
<ion-thumbnail slot="start" class="ion-margin-start">
<img alt="" [src]="pkg.entry['static-files'].icon" />
</ion-thumbnail>
<ion-label>
<h2>{{ pkg.entry.manifest.title }}</h2>
<p>{{ pkg.entry.manifest.version | displayEmver }}</p>
<status
[disconnected]="connectionFailure"
[rendering]="pkg.primaryRendering"
[installProgress]="pkg.installProgress?.totalProgress"
weight="bold"
size="small"
></status>
</ion-label>
<ion-button
*ngIf="pkg.entry.manifest.interfaces | hasUi"
slot="end"
fill="clear"
color="primary"
(click)="launchUi(pkg.entry)"
[disabled]="
!(
pkg.entry.state
| isLaunchable
: pkg.entry.installed?.status.main.status
: pkg.entry.manifest.interfaces
)
"
>
<ion-icon slot="icon-only" name="open-outline"></ion-icon>
</ion-button>
</ion-item>

View File

@@ -0,0 +1,42 @@
import { DOCUMENT } from "@angular/common";
import {
ChangeDetectionStrategy,
Component,
Inject,
Input,
} from "@angular/core";
import { PackageDataEntry } from "src/app/services/patch-db/data-model";
import { ConfigService } from "src/app/services/config.service";
import { PkgInfo } from "src/app/util/get-package-info";
@Component({
selector: "app-list-pkg",
templateUrl: "app-list-pkg.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppListPkgComponent {
@Input()
pkg: PkgInfo;
@Input()
connectionFailure = false;
constructor(
@Inject(DOCUMENT) private readonly document: Document,
private readonly config: ConfigService
) {}
getColor(pkg: PkgInfo): string {
return this.connectionFailure
? "var(--ion-color-dark)"
: "var(--ion-color-" + pkg.primaryRendering.color + ")";
}
launchUi(pkg: PackageDataEntry): void {
this.document.defaultView.open(
this.config.launchableURL(pkg),
"_blank",
"noreferrer"
);
}
}

View File

@@ -0,0 +1,22 @@
<ion-item>
<ion-thumbnail slot="start">
<img alt="" [src]="rec.icon" />
</ion-thumbnail>
<ion-label>
<h2>{{ rec.title }}</h2>
<p>{{ rec.version | displayEmver }}</p>
</ion-label>
<ion-spinner *ngIf="loading$ | async; else actions"></ion-spinner>
<ng-template #actions>
<div slot="end">
<ion-button fill="clear" color="danger" (click)="deleteRecovered(rec)">
<ion-icon slot="icon-only" name="trash-outline"></ion-icon>
<!-- Remove -->
</ion-button>
<ion-button fill="clear" color="success" (click)="install$.next(rec)">
<ion-icon slot="icon-only" name="download-outline"></ion-icon>
<!-- Install -->
</ion-button>
</div>
</ng-template>
</ion-item>

View File

@@ -0,0 +1,83 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
Output,
} from "@angular/core";
import { AlertController } from "@ionic/angular";
import { ApiService } from "src/app/services/api/embassy-api.service";
import { ErrorToastService } from "src/app/services/error-toast.service";
import { from, merge, OperatorFunction, pipe, Subject } from "rxjs";
import { catchError, mapTo, startWith, switchMap, tap } from "rxjs/operators";
import { RecoveredInfo } from "src/app/util/parse-data-model";
@Component({
selector: "app-list-rec",
templateUrl: "app-list-rec.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppListRecComponent {
readonly install$ = new Subject<RecoveredInfo>();
readonly delete$ = new Subject<RecoveredInfo>();
@Input()
rec: RecoveredInfo;
@Output()
readonly deleted = new EventEmitter<void>();
readonly installing$ = this.install$.pipe(
switchMap(({ id }) =>
from(this.api.installPackage({ id })).pipe(
tap(() => this.deleted.emit()),
loading(this.errToast)
)
)
);
readonly deleting$ = this.delete$.pipe(
switchMap(({ id }) =>
from(this.api.deleteRecoveredPackage({ id })).pipe(loading(this.errToast))
)
);
readonly loading$ = merge(this.installing$, this.deleting$);
constructor(
private readonly api: ApiService,
private readonly errToast: ErrorToastService,
private readonly alertCtrl: AlertController
) {}
async deleteRecovered(pkg: RecoveredInfo): Promise<void> {
const alert = await this.alertCtrl.create({
header: "Delete Data",
message: `This action will permanently delete all data associated with ${pkg.title}.`,
buttons: [
{
text: "Cancel",
role: "cancel",
},
{
text: "Delete",
handler: () => {
this.delete$.next(pkg);
},
cssClass: "enter-click",
},
],
});
await alert.present();
}
}
function loading(
errToast: ErrorToastService
): OperatorFunction<unknown, boolean> {
return pipe(
startWith(true),
catchError((e) => from(errToast.present(e))),
mapTo(false)
);
}

View File

@@ -0,0 +1,55 @@
<!-- header -->
<ion-item-divider>
{{ reordering ? "Reorder" : "Installed Services" }}
<ion-button slot="end" fill="clear" (click)="toggle()">
<ion-icon
slot="start"
[name]="reordering ? 'checkmark' : 'swap-vertical'"
></ion-icon>
{{ reordering ? "Done" : "Reorder" }}
</ion-button>
</ion-item-divider>
<!-- reordering -->
<ion-list *ngIf="reordering; else grid">
<ion-reorder-group disabled="false" (ionItemReorder)="reorder($any($event))">
<ion-reorder *ngFor="let item of pkgs">
<ion-item *ngIf="item | packageInfo | async as pkg" class="item">
<app-list-icon
slot="start"
[pkg]="pkg"
[connectionFailure]="connectionFailure$ | async"
></app-list-icon>
<ion-thumbnail slot="start" class="ion-margin-start">
<img alt="" [src]="pkg.entry['static-files'].icon" />
</ion-thumbnail>
<ion-label>
<h2>{{ pkg.entry.manifest.title }}</h2>
<p>{{ pkg.entry.manifest.version | displayEmver }}</p>
<status
[disconnected]="connectionFailure$ | async"
[rendering]="pkg.primaryRendering"
[installProgress]="pkg.installProgress?.totalProgress"
weight="bold"
size="small"
></status>
</ion-label>
<ion-icon slot="end" name="reorder-three" color="dark"></ion-icon>
</ion-item>
</ion-reorder>
</ion-reorder-group>
</ion-list>
<!-- not reordering -->
<ng-template #grid>
<ion-grid>
<ion-row>
<ion-col *ngFor="let pkg of pkgs" sizeXs="12" sizeXl="6">
<app-list-pkg
[pkg]="pkg | packageInfo | async"
[connectionFailure]="connectionFailure$ | async"
></app-list-pkg>
</ion-col>
</ion-row>
</ion-grid>
</ng-template>

View File

@@ -0,0 +1,49 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
Output,
} from "@angular/core";
import { ItemReorderEventDetail } from "@ionic/core";
import { PackageDataEntry } from "src/app/services/patch-db/data-model";
import { map } from "rxjs/operators";
import {
ConnectionFailure,
ConnectionService,
} from "src/app/services/connection.service";
@Component({
selector: "app-list-reorder",
templateUrl: "app-list-reorder.component.html",
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppListReorderComponent {
@Input()
reordering = false;
@Input()
pkgs: readonly PackageDataEntry[] = [];
@Output()
readonly reorderingChange = new EventEmitter<boolean>();
@Output()
readonly pkgsChange = new EventEmitter<readonly PackageDataEntry[]>();
readonly connectionFailure$ = this.connectionService
.watchFailure$()
.pipe(map((failure) => failure !== ConnectionFailure.None));
constructor(private readonly connectionService: ConnectionService) {}
toggle() {
this.reordering = !this.reordering;
this.reorderingChange.emit(this.reordering);
}
reorder({ detail }: CustomEvent<ItemReorderEventDetail>): void {
this.pkgs = detail.complete([...this.pkgs]);
this.pkgsChange.emit(this.pkgs);
}
}

View File

@@ -1,18 +1,24 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppListPage } from './app-list.page'
import { StatusComponentModule } from 'src/app/components/status/status.component.module'
import { SharingModule } from 'src/app/modules/sharing.module'
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
import { NgModule } from "@angular/core";
import { CommonModule } from "@angular/common";
import { Routes, RouterModule } from "@angular/router";
import { IonicModule } from "@ionic/angular";
import { AppListPage } from "./app-list.page";
import { StatusComponentModule } from "src/app/components/status/status.component.module";
import { SharingModule } from "src/app/modules/sharing.module";
import { BadgeMenuComponentModule } from "src/app/components/badge-menu-button/badge-menu.component.module";
import { AppListIconComponent } from "./app-list-icon/app-list-icon.component";
import { AppListEmptyComponent } from "./app-list-empty/app-list-empty.component";
import { AppListPkgComponent } from "./app-list-pkg/app-list-pkg.component";
import { AppListRecComponent } from "./app-list-rec/app-list-rec.component";
import { AppListReorderComponent } from "./app-list-reorder/app-list-reorder.component";
import { PackageInfoPipe } from "./package-info.pipe";
const routes: Routes = [
{
path: '',
path: "",
component: AppListPage,
},
]
];
@NgModule({
imports: [
@@ -25,6 +31,12 @@ const routes: Routes = [
],
declarations: [
AppListPage,
AppListIconComponent,
AppListEmptyComponent,
AppListPkgComponent,
AppListRecComponent,
AppListReorderComponent,
PackageInfoPipe,
],
})
export class AppListPageModule { }
export class AppListPageModule {}

View File

@@ -8,152 +8,37 @@
</ion-header>
<ion-content class="ion-padding">
<!-- loading -->
<text-spinner *ngIf="loading" text="Connecting to Embassy"></text-spinner>
<text-spinner
*ngIf="loading else data"
text="Connecting to Embassy"
></text-spinner>
<!-- not loading -->
<div *ngIf="!loading">
<div *ngIf="empty; else list" class="ion-text-center ion-padding">
<div style="display: flex; flex-direction: column; justify-content: center; height: 40vh">
<h2>Welcome to <ion-text color="danger" style="font-family: 'Montserrat';">Embassy</ion-text></h2>
<p class="ion-text-wrap">Get started by installing your first service.</p>
</div>
<ion-button color="dark" [routerLink]="['/marketplace']" routerDirection="root" style="width: 50%;">
<ion-icon slot="start" name="storefront-outline"></ion-icon>
Marketplace
</ion-button>
</div>
<ng-template #data>
<app-list-empty
*ngIf="empty; else list"
class="ion-text-center ion-padding"
></app-list-empty>
<ng-template #list>
<ng-container *ngIf="pkgs.length">
<!-- header -->
<ion-item-divider>
{{ reordering ? 'Reorder' : 'Installed Services' }}
<ion-button slot="end" fill="clear" (click)="toggleReorder()">
<ng-container *ngIf="!reordering">
<ion-icon slot="start" name="swap-vertical"></ion-icon>
Reorder
</ng-container>
<ng-container *ngIf="reordering">
<ion-icon slot="start" name="checkmark"></ion-icon>
Done
</ng-container>
</ion-button>
</ion-item-divider>
<!-- not reordering -->
<ion-grid *ngIf="!reordering">
<ion-row>
<ion-col *ngFor="let pkg of pkgs" sizeXs="12" sizeXl="6">
<ion-item button detail="false" [routerLink]="['/services', pkg.entry.manifest.id]">
<div
*ngIf="!pkg.error"
slot="start"
class="bulb"
[style.background-color]="connectionFailure ? 'var(--ion-color-dark)' : 'var(--ion-color-' + pkg.primaryRendering.color + ')'"
></div>
<ion-icon
*ngIf="pkg.error"
slot="start"
class="warning-icon"
name="warning-outline"
size="small"
color="warning"
></ion-icon>
<ion-thumbnail slot="start" class="ion-margin-start">
<img [src]="pkg.entry['static-files'].icon" />
</ion-thumbnail>
<ion-label>
<h2>{{ pkg.entry.manifest.title }}</h2>
<p>{{ pkg.entry.manifest.version | displayEmver }}</p>
<status
[disconnected]="connectionFailure"
[rendering]="pkg.primaryRendering"
[installProgress]="pkg.installProgress?.totalProgress"
weight="bold"
size="small"
></status>
</ion-label>
<ion-button
*ngIf="pkg.entry.manifest.interfaces | hasUi"
slot="end"
fill="clear"
color="primary"
(click)="launchUi(pkg.entry, $event)"
[disabled]="!(pkg.entry.state | isLaunchable : pkg.entry.installed?.status.main.status : pkg.entry.manifest.interfaces)"
>
<ion-icon slot="icon-only" name="open-outline"></ion-icon>
</ion-button>
</ion-item>
</ion-col>
</ion-row>
</ion-grid>
<!-- reordering -->
<ion-list>
<ion-reorder-group *ngIf="reordering" disabled="false" (ionItemReorder)="reorder($any($event))">
<ion-reorder *ngFor="let pkg of pkgs">
<ion-item style="--background: var(--ion-color-medium-shade);">
<div
*ngIf="!pkg.error"
slot="start"
class="bulb"
[style.background-color]="connectionFailure ? 'var(--ion-color-dark)' : 'var(--ion-color-' + pkg.primaryRendering.color + ')'"
></div>
<ion-icon
*ngIf="pkg.error"
slot="start"
class="warning-icon"
name="warning-outline"
size="small"
color="warning"
></ion-icon>
<ion-thumbnail slot="start" class="ion-margin-start">
<img [src]="pkg.entry['static-files'].icon" />
</ion-thumbnail>
<ion-label>
<h2>{{ pkg.entry.manifest.title }}</h2>
<p>{{ pkg.entry.manifest.version | displayEmver }}</p>
<status
[disconnected]="connectionFailure"
[rendering]="pkg.primaryRendering"
[installProgress]="pkg.installProgress?.totalProgress"
weight="bold"
size="small"
></status>
</ion-label>
<ion-icon slot="end" name="reorder-three" color="dark"></ion-icon>
</ion-item>
</ion-reorder>
</ion-reorder-group>
</ion-list>
</ng-container>
<app-list-reorder
*ngIf="pkgs.length"
[(pkgs)]="pkgs"
[reordering]="reordering"
(reorderingChange)="onReordering($event)"
></app-list-reorder>
<ng-container *ngIf="recoveredPkgs.length && !reordering">
<ion-item-group>
<ion-item-divider>Recovered Services</ion-item-divider>
<ion-item *ngFor="let rec of recoveredPkgs; let i = index;">
<ion-thumbnail slot="start">
<img [src]="rec.icon" />
</ion-thumbnail>
<ion-label>
<h2>{{ rec.title }}</h2>
<p>{{ rec.version | displayEmver }}</p>
</ion-label>
<div *ngIf="!rec.installing" slot="end">
<ion-button fill="clear" color="danger" (click)="deleteRecovered(rec, i)">
<ion-icon slot="icon-only" name="trash-outline"></ion-icon>
<!-- Remove -->
</ion-button>
<ion-button fill="clear" color="success" (click)="install(rec)">
<ion-icon slot="icon-only" name="download-outline"></ion-icon>
<!-- Install -->
</ion-button>
</div>
<ion-spinner *ngIf="rec.installing"></ion-spinner>
</ion-item>
<app-list-rec
*ngFor="let rec of recoveredPkgs"
[rec]="rec"
(deleted)="deleteRecovered(rec)"
></app-list-rec>
</ion-item-group>
</ng-container>
</ng-template>
</div>
</ion-content>
</ng-template>
</ion-content>

View File

@@ -2,23 +2,6 @@ ion-item-divider {
margin-bottom: 16px;
}
.bulb {
position: absolute !important;
left: 9px !important;
top: 8px !important;
height: 14px;
width: 14px;
border-radius: 100%;
box-shadow: 0 0 6px 6px rgba(255,213,52, 0.1);
}
.warning-icon {
position: absolute !important;
left: 6px !important;
top: 0 !important;
font-size: 12px;
border-radius: 100%;
padding: 1px;
background-color: rgba(255,213,52, 0.1);
box-shadow: 0 0 4px 4px rgba(255,213,52, 0.1);
.item {
--background: var(--ion-color-medium-shade);
}

View File

@@ -1,246 +1,97 @@
import { Component } from '@angular/core'
import { ItemReorderEventDetail } from '@ionic/core'
import { ConfigService } from 'src/app/services/config.service'
import { ConnectionFailure, ConnectionService } from 'src/app/services/connection.service'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { DataModel, PackageDataEntry, PackageState, RecoveredPackageDataEntry } from 'src/app/services/patch-db/data-model'
import { combineLatest, Observable, Subscription } from 'rxjs'
import { DependencyStatus, HealthStatus, PrimaryRendering, renderPkgStatus, StatusRendering } from 'src/app/services/pkg-status-rendering.service'
import { filter, take, tap } from 'rxjs/operators'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { Observable } from 'rxjs'
import { filter, map, switchMapTo, take, takeUntil, tap } from 'rxjs/operators'
import { isEmptyObject, exists } from 'src/app/util/misc.util'
import { PackageLoadingService, ProgressData } from 'src/app/services/package-loading.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ErrorToastService } from 'src/app/services/error-toast.service'
import { AlertController } from '@ionic/angular'
import { parseDataModel, RecoveredInfo } from 'src/app/util/parse-data-model'
import { DestroyService } from 'src/app/services/destroy.service'
@Component({
selector: 'app-list',
templateUrl: './app-list.page.html',
styleUrls: ['./app-list.page.scss'],
providers: [DestroyService],
})
export class AppListPage {
PackageState = PackageState
subs: Subscription[] = []
connectionFailure: boolean
pkgs: PkgInfo[] = []
recoveredPkgs: RecoveredInfo[] = []
order: string[] = []
pkgs: readonly PackageDataEntry[] = []
recoveredPkgs: readonly RecoveredInfo[] = []
order: readonly string[] = []
loading = true
empty = false
reordering = false
constructor (
private readonly config: ConfigService,
private readonly connectionService: ConnectionService,
private readonly pkgLoading: PackageLoadingService,
private readonly api: ApiService,
private readonly patch: PatchDbService,
private readonly errToast: ErrorToastService,
private readonly alertCtrl: AlertController,
private readonly destroy$: DestroyService,
) { }
get empty (): boolean {
return !this.pkgs.length && isEmptyObject(this.recoveredPkgs)
}
ngOnInit () {
this.patch.watch$()
.pipe(
filter(data => exists(data) && !isEmptyObject(data)),
take(1),
)
.subscribe(data => {
this.loading = false
const pkgs = JSON.parse(JSON.stringify(data['package-data'])) as { [id: string]: PackageDataEntry }
this.recoveredPkgs = Object.entries(data['recovered-packages'])
.filter(([id, _]) => !pkgs[id])
.map(([id, val]) => {
return {
...val,
id,
installing: false,
}
})
this.order = [...data.ui['pkg-order'] || []]
this.patch
.watch$()
.pipe(
filter((data) => exists(data) && !isEmptyObject(data)),
take(1),
map(parseDataModel),
tap(({ order, pkgs, recoveredPkgs }) => {
this.pkgs = pkgs
this.recoveredPkgs = recoveredPkgs
this.order = order
this.loading = false
// add known pkgs in preferential order
this.order.forEach(id => {
if (pkgs[id]) {
this.pkgs.push(this.subscribeToPkg(pkgs[id]))
delete pkgs[id]
}
})
// unshift unknown packages and set order in UI DB
if (!isEmptyObject(pkgs)) {
Object.values(pkgs).forEach(pkg => {
this.pkgs.unshift(this.subscribeToPkg(pkg))
this.order.unshift(pkg.manifest.id)
})
this.setOrder()
}
if (!this.pkgs.length && isEmptyObject(this.recoveredPkgs)) {
this.empty = true
}
this.subs.push(this.watchNewlyRecovered())
})
this.subs.push(
this.connectionService.watchFailure$()
.subscribe(connectionFailure => {
this.connectionFailure = connectionFailure !== ConnectionFailure.None
}),
)
// set order in UI DB if there were unknown packages
if (order.length < pkgs.length) {
this.setOrder()
}
}),
switchMapTo(this.watchNewlyRecovered()),
takeUntil(this.destroy$),
)
.subscribe()
}
ngOnDestroy () {
this.pkgs.forEach(pkg => pkg.sub.unsubscribe())
this.subs.forEach(sub => sub.unsubscribe())
}
launchUi (pkg: PackageDataEntry, event: Event): void {
event.preventDefault()
event.stopPropagation()
window.open(this.config.launchableURL(pkg), '_blank', 'noreferrer')
}
toggleReorder (): void {
if (this.reordering) {
this.order = this.pkgs.map(pkg => pkg.entry.manifest.id)
onReordering (reordering: boolean): void {
if (!reordering) {
this.setOrder()
}
this.reordering = !this.reordering
this.reordering = reordering
}
async reorder (ev: CustomEvent<ItemReorderEventDetail>): Promise<void> {
this.pkgs = ev.detail.complete(this.pkgs)
deleteRecovered (rec: RecoveredInfo): void {
this.recoveredPkgs = this.recoveredPkgs.filter((item) => item !== rec)
}
async install (pkg: RecoveredInfo): Promise<void> {
pkg.installing = true
try {
await this.api.installPackage({ id: pkg.id, 'version-spec': undefined })
} catch (e) {
this.errToast.present(e)
pkg.installing = false
}
}
async deleteRecovered (pkg: RecoveredInfo, index: number): Promise<void> {
const execute = async () => {
pkg.installing = true
try {
await this.api.deleteRecoveredPackage({ id: pkg.id })
this.recoveredPkgs.splice(index, 1)
} catch (e) {
this.errToast.present(e)
pkg.installing = false
}
}
const alert = await this.alertCtrl.create({
header: 'Delete Data',
message: `This action will permanently delete all data associated with ${pkg.title}.`,
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Delete',
handler: () => {
execute()
},
cssClass: 'enter-click',
},
],
})
await alert.present()
}
private watchNewlyRecovered (): Subscription {
return combineLatest([this.watchPkgs(), this.patch.watch$('recovered-packages')])
.subscribe(([pkgs, recoveredPkgs]) => {
Object.keys(recoveredPkgs).forEach(id => {
const inPkgs = !!pkgs[id]
const recoveredIndex = this.recoveredPkgs.findIndex(rec => rec.id === id)
if (inPkgs && recoveredIndex > -1) {
this.recoveredPkgs.splice(recoveredIndex, 1)
}
})
})
}
private watchPkgs (): Observable<DataModel['package-data']> {
return this.patch.watch$('package-data')
.pipe(
filter(pkgs => {
return Object.keys(pkgs).length !== this.pkgs.length
}),
tap(pkgs => {
private watchNewlyRecovered (): Observable<unknown> {
return this.patch.watch$('package-data').pipe(
filter((pkgs) => Object.keys(pkgs).length !== this.pkgs.length),
tap((pkgs) => {
const ids = Object.keys(pkgs)
const newIds = ids.filter(
(id) => !this.pkgs.find((pkg) => pkg.manifest.id === id),
)
// remove uninstalled
this.pkgs.forEach((pkg, i) => {
const id = pkg.entry.manifest.id
if (!ids.includes(id)) {
pkg.sub.unsubscribe()
this.pkgs.splice(i, 1)
}
})
const filtered = this.pkgs.filter((pkg) =>
ids.includes(pkg.manifest.id),
)
this.empty = !this.pkgs.length && isEmptyObject(this.recoveredPkgs)
// add new entry to beginning of array
const added = newIds.map((id) => pkgs[id])
ids.forEach(id => {
// if already exists, return
const pkg = this.pkgs.find(p => p.entry.manifest.id === id)
if (pkg) return
// otherwise add new entry to beginning of array
this.pkgs.unshift(this.subscribeToPkg(pkgs[id]))
})
this.pkgs = [...added, ...filtered]
this.recoveredPkgs.filter((rec) => !pkgs[rec.id])
}),
)
}
private setOrder (): void {
this.order = this.pkgs.map((pkg) => pkg.manifest.id)
this.api.setDbValue({ pointer: '/pkg-order', value: this.order })
}
private subscribeToPkg (pkg: PackageDataEntry): PkgInfo {
const pkgInfo: PkgInfo = {
entry: pkg,
primaryRendering: PrimaryRendering[renderPkgStatus(pkg).primary],
installProgress: !isEmptyObject(pkg['install-progress']) ? this.pkgLoading.transform(pkg['install-progress']) : undefined,
error: false,
sub: null,
}
// subscribe to pkg
pkgInfo.sub = this.patch.watch$('package-data', pkg.manifest.id).subscribe(update => {
if (!update) return
const statuses = renderPkgStatus(update)
const primaryRendering = PrimaryRendering[statuses.primary]
pkgInfo.entry = update
pkgInfo.installProgress = !isEmptyObject(update['install-progress']) ? this.pkgLoading.transform(update['install-progress']) : undefined
pkgInfo.primaryRendering = primaryRendering
pkgInfo.error = statuses.health === HealthStatus.Failure || [DependencyStatus.Warning, DependencyStatus.Critical].includes(statuses.dependency)
})
return pkgInfo
}
asIsOrder () {
return 0
}
}
interface RecoveredInfo extends RecoveredPackageDataEntry {
id: string
installing: boolean
}
interface PkgInfo {
entry: PackageDataEntry
primaryRendering: StatusRendering
installProgress: ProgressData
error: boolean
sub: Subscription | null
}

View File

@@ -0,0 +1,21 @@
import { Pipe, PipeTransform } from "@angular/core";
import { Observable } from "rxjs";
import { filter, map, startWith } from "rxjs/operators";
import { PackageDataEntry } from "../../../services/patch-db/data-model";
import { getPackageInfo, PkgInfo } from "../../../util/get-package-info";
import { PatchDbService } from "../../../services/patch-db/patch-db.service";
@Pipe({
name: "packageInfo",
})
export class PackageInfoPipe implements PipeTransform {
constructor(private readonly patch: PatchDbService) {}
transform(pkg: PackageDataEntry): Observable<PkgInfo> {
return this.patch.watch$("package-data", pkg.manifest.id).pipe(
filter((v) => !!v),
map(getPackageInfo),
startWith(getPackageInfo(pkg))
);
}
}

View File

@@ -13,10 +13,10 @@ import { DependencyStatus, HealthStatus, PrimaryRendering, PrimaryStatus, render
import { ConnectionFailure, ConnectionService } from 'src/app/services/connection.service'
import { ErrorToastService } from 'src/app/services/error-toast.service'
import { AppConfigPage } from 'src/app/modals/app-config/app-config.page'
import { PackageLoadingService, ProgressData } from 'src/app/services/package-loading.service'
import { filter } from 'rxjs/operators'
import { MarkdownPage } from 'src/app/modals/markdown/markdown.page'
import { Pipe, PipeTransform } from '@angular/core'
import { packageLoadingProgress, ProgressData } from 'src/app/util/package-loading-progress'
@Component({
selector: 'app-show',
@@ -60,7 +60,6 @@ export class AppShowPage {
private readonly embassyApi: ApiService,
private readonly wizardBaker: WizardBaker,
private readonly config: ConfigService,
private readonly packageLoadingService: PackageLoadingService,
private readonly patch: PatchDbService,
private readonly connectionService: ConnectionService,
) { }
@@ -86,7 +85,7 @@ export class AppShowPage {
this.pkg = pkg
this.statuses = renderPkgStatus(pkg)
this.installProgress = !isEmptyObject(pkg['install-progress']) ? this.packageLoadingService.transform(pkg['install-progress']) : undefined
this.installProgress = !isEmptyObject(pkg['install-progress']) ? packageLoadingProgress(pkg['install-progress']) : undefined
}),
// 2

View File

@@ -191,7 +191,7 @@ export class MarketplaceShowPage {
loader.present()
try {
await this.embassyApi.installPackage({ id, 'version-spec': version ? `=${version}` : undefined })
await this.embassyApi.installPackage({ id, version: version ? `=${version}` : undefined })
} catch (e) {
this.errToast.present(e)
} finally {

View File

@@ -1,17 +1,12 @@
import { Pipe, PipeTransform } from '@angular/core'
import { PackageLoadingService, ProgressData } from '../services/package-loading.service'
import { InstallProgress } from '../services/patch-db/data-model'
import { packageLoadingProgress, ProgressData } from '../util/package-loading-progress';
@Pipe({
name: 'installState',
})
export class InstallState implements PipeTransform {
constructor (
private readonly pkgLoading: PackageLoadingService,
) { }
transform (loadData: InstallProgress): ProgressData {
return this.pkgLoading.transform(loadData)
return packageLoadingProgress(loadData)
}
}

View File

@@ -142,7 +142,7 @@ export module RR {
export type GetPackageMetricsReq = { id: string } // package.metrics
export type GetPackageMetricsRes = Metric
export type InstallPackageReq = WithExpire<{ id: string, 'version-spec'?: string }> // package.install
export type InstallPackageReq = WithExpire<{ id: string, version?: string }> // package.install
export type InstallPackageRes = WithRevision<null>
export type DryUpdatePackageReq = { id: string, version: string } // package.update.dry

View File

@@ -0,0 +1,13 @@
import { Injectable, OnDestroy } from "@angular/core";
import { ReplaySubject } from "rxjs";
/**
* Observable abstraction over ngOnDestroy to use with takeUntil
*/
@Injectable()
export class DestroyService extends ReplaySubject<void> implements OnDestroy {
ngOnDestroy() {
this.next();
this.complete();
}
}

View File

@@ -1,45 +0,0 @@
import { Injectable } from '@angular/core'
import { InstallProgress } from './patch-db/data-model'
@Injectable({
providedIn: 'root',
})
export class PackageLoadingService {
constructor () { }
transform (loadData: InstallProgress): ProgressData {
let { downloaded, validated, unpacked, size, 'download-complete': downloadComplete, 'validation-complete': validationComplete, 'unpack-complete': unpackComplete } = loadData
// only permit 100% when "complete" == true
downloaded = downloadComplete ? size : Math.max(downloaded - 1, 0)
validated = validationComplete ? size : Math.max(validated - 1, 0)
unpacked = unpackComplete ? size : Math.max(unpacked - 1, 0)
const downloadWeight = 1
const validateWeight = .2
const unpackWeight = .7
const numerator = Math.floor(
downloadWeight * downloaded +
validateWeight * validated +
unpackWeight * unpacked)
const denominator = Math.floor(size * (downloadWeight + validateWeight + unpackWeight))
return {
totalProgress: Math.floor(100 * numerator / denominator),
downloadProgress: Math.floor(100 * downloaded / size),
validateProgress: Math.floor(100 * validated / size),
unpackProgress: Math.floor(100 * unpacked / size),
isComplete: downloadComplete && validationComplete && unpackComplete,
}
}
}
export interface ProgressData {
totalProgress: number
downloadProgress: number
validateProgress: number
unpackProgress: number
isComplete: boolean
}

View File

@@ -0,0 +1,38 @@
import { PackageDataEntry } from '../services/patch-db/data-model'
import {
DependencyStatus,
HealthStatus,
PrimaryRendering,
renderPkgStatus,
StatusRendering,
} from '../services/pkg-status-rendering.service'
import { isEmptyObject } from './misc.util'
import {
packageLoadingProgress,
ProgressData,
} from './package-loading-progress'
import { Subscription } from 'rxjs'
export function getPackageInfo (entry: PackageDataEntry): PkgInfo {
const statuses = renderPkgStatus(entry)
return {
entry,
primaryRendering: PrimaryRendering[statuses.primary],
installProgress: !isEmptyObject(entry['install-progress'])
? packageLoadingProgress(entry['install-progress'])
: undefined,
error:
statuses.health === HealthStatus.Failure ||
statuses.dependency === DependencyStatus.Warning ||
statuses.dependency === DependencyStatus.Critical,
}
}
export interface PkgInfo {
entry: PackageDataEntry
primaryRendering: StatusRendering
installProgress: ProgressData
error: boolean
sub?: Subscription | null
}

View File

@@ -0,0 +1,50 @@
import { InstallProgress } from "src/app/services/patch-db/data-model";
export function packageLoadingProgress(
loadData: InstallProgress
): ProgressData {
let {
downloaded,
validated,
unpacked,
size,
"download-complete": downloadComplete,
"validation-complete": validationComplete,
"unpack-complete": unpackComplete,
} = loadData;
// only permit 100% when "complete" == true
downloaded = downloadComplete ? size : Math.max(downloaded - 1, 0);
validated = validationComplete ? size : Math.max(validated - 1, 0);
unpacked = unpackComplete ? size : Math.max(unpacked - 1, 0);
const downloadWeight = 1;
const validateWeight = 0.2;
const unpackWeight = 0.7;
const numerator = Math.floor(
downloadWeight * downloaded +
validateWeight * validated +
unpackWeight * unpacked
);
const denominator = Math.floor(
size * (downloadWeight + validateWeight + unpackWeight)
);
return {
totalProgress: Math.floor((100 * numerator) / denominator),
downloadProgress: Math.floor((100 * downloaded) / size),
validateProgress: Math.floor((100 * validated) / size),
unpackProgress: Math.floor((100 * unpacked) / size),
isComplete: downloadComplete && validationComplete && unpackComplete,
};
}
export interface ProgressData {
totalProgress: number;
downloadProgress: number;
validateProgress: number;
unpackProgress: number;
isComplete: boolean;
}

View File

@@ -0,0 +1,50 @@
import {
DataModel,
PackageDataEntry,
RecoveredPackageDataEntry,
} from "../services/patch-db/data-model";
export function parseDataModel(data: DataModel): ParsedData {
const all = JSON.parse(JSON.stringify(data["package-data"])) as {
[id: string]: PackageDataEntry;
};
const order = [...(data.ui["pkg-order"] || [])];
const pkgs = [];
const recoveredPkgs = Object.entries(data["recovered-packages"])
.filter(([id, _]) => !all[id])
.map(([id, val]) => ({
...val,
id,
}));
// add known packages in preferential order
order.forEach((id) => {
if (all[id]) {
pkgs.push(all[id]);
delete all[id];
}
});
// unshift unknown packages
Object.values(all).forEach((pkg) => {
pkgs.unshift(pkg);
});
return {
order,
pkgs,
recoveredPkgs,
};
}
export interface RecoveredInfo extends RecoveredPackageDataEntry {
id: string;
}
interface ParsedData {
order: string[];
pkgs: PackageDataEntry[];
recoveredPkgs: RecoveredInfo[];
}