mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-04-04 22:39:46 +00:00
Refactor AppListPage
This commit is contained in:
committed by
Aiden McClelland
parent
b546eb2504
commit
ee81ca111b
17124
ui/package-lock.json
generated
17124
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,7 @@
|
|||||||
"@types/node": "^16.7.13",
|
"@types/node": "^16.7.13",
|
||||||
"@types/uuid": "^8.3.1",
|
"@types/uuid": "^8.3.1",
|
||||||
"node-html-parser": "^4.1.4",
|
"node-html-parser": "^4.1.4",
|
||||||
|
"prettier": "^2.4.1",
|
||||||
"raw-loader": "^4.0.2",
|
"raw-loader": "^4.0.2",
|
||||||
"ts-node": "^10.2.0",
|
"ts-node": "^10.2.0",
|
||||||
"tslint": "^6.1.3",
|
"tslint": "^6.1.3",
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export class WizardBaker {
|
|||||||
action,
|
action,
|
||||||
verb: 'beginning update for',
|
verb: 'beginning update for',
|
||||||
title,
|
title,
|
||||||
executeAction: () => this.embassyApi.installPackage({ id, 'version-spec': version ? `=${version}` : undefined }),
|
executeAction: () => this.embassyApi.installPackage({ id, version: version ? `=${version}` : undefined }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
bottomBar: {
|
bottomBar: {
|
||||||
@@ -158,7 +158,7 @@ export class WizardBaker {
|
|||||||
action,
|
action,
|
||||||
verb: 'beginning downgrade for',
|
verb: 'beginning downgrade for',
|
||||||
title,
|
title,
|
||||||
executeAction: () => this.embassyApi.installPackage({ id, 'version-spec': version ? `=${version}` : undefined }),
|
executeAction: () => this.embassyApi.installPackage({ id, version: version ? `=${version}` : undefined }),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
bottomBar: {
|
bottomBar: {
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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 + ")";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,24 @@
|
|||||||
import { NgModule } from '@angular/core'
|
import { NgModule } from "@angular/core";
|
||||||
import { CommonModule } from '@angular/common'
|
import { CommonModule } from "@angular/common";
|
||||||
import { Routes, RouterModule } from '@angular/router'
|
import { Routes, RouterModule } from "@angular/router";
|
||||||
import { IonicModule } from '@ionic/angular'
|
import { IonicModule } from "@ionic/angular";
|
||||||
import { AppListPage } from './app-list.page'
|
import { AppListPage } from "./app-list.page";
|
||||||
import { StatusComponentModule } from 'src/app/components/status/status.component.module'
|
import { StatusComponentModule } from "src/app/components/status/status.component.module";
|
||||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
import { SharingModule } from "src/app/modules/sharing.module";
|
||||||
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.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 = [
|
const routes: Routes = [
|
||||||
{
|
{
|
||||||
path: '',
|
path: "",
|
||||||
component: AppListPage,
|
component: AppListPage,
|
||||||
},
|
},
|
||||||
]
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -25,6 +31,12 @@ const routes: Routes = [
|
|||||||
],
|
],
|
||||||
declarations: [
|
declarations: [
|
||||||
AppListPage,
|
AppListPage,
|
||||||
|
AppListIconComponent,
|
||||||
|
AppListEmptyComponent,
|
||||||
|
AppListPkgComponent,
|
||||||
|
AppListRecComponent,
|
||||||
|
AppListReorderComponent,
|
||||||
|
PackageInfoPipe,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppListPageModule { }
|
export class AppListPageModule {}
|
||||||
|
|||||||
@@ -8,152 +8,37 @@
|
|||||||
</ion-header>
|
</ion-header>
|
||||||
|
|
||||||
<ion-content class="ion-padding">
|
<ion-content class="ion-padding">
|
||||||
|
|
||||||
<!-- loading -->
|
<!-- 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 -->
|
<!-- not loading -->
|
||||||
<div *ngIf="!loading">
|
<ng-template #data>
|
||||||
<div *ngIf="empty; else list" class="ion-text-center ion-padding">
|
<app-list-empty
|
||||||
<div style="display: flex; flex-direction: column; justify-content: center; height: 40vh">
|
*ngIf="empty; else list"
|
||||||
<h2>Welcome to <ion-text color="danger" style="font-family: 'Montserrat';">Embassy</ion-text></h2>
|
class="ion-text-center ion-padding"
|
||||||
<p class="ion-text-wrap">Get started by installing your first service.</p>
|
></app-list-empty>
|
||||||
</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 #list>
|
<ng-template #list>
|
||||||
<ng-container *ngIf="pkgs.length">
|
<app-list-reorder
|
||||||
<!-- header -->
|
*ngIf="pkgs.length"
|
||||||
<ion-item-divider>
|
[(pkgs)]="pkgs"
|
||||||
{{ reordering ? 'Reorder' : 'Installed Services' }}
|
[reordering]="reordering"
|
||||||
<ion-button slot="end" fill="clear" (click)="toggleReorder()">
|
(reorderingChange)="onReordering($event)"
|
||||||
<ng-container *ngIf="!reordering">
|
></app-list-reorder>
|
||||||
<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>
|
|
||||||
|
|
||||||
<ng-container *ngIf="recoveredPkgs.length && !reordering">
|
<ng-container *ngIf="recoveredPkgs.length && !reordering">
|
||||||
<ion-item-group>
|
<ion-item-group>
|
||||||
<ion-item-divider>Recovered Services</ion-item-divider>
|
<ion-item-divider>Recovered Services</ion-item-divider>
|
||||||
<ion-item *ngFor="let rec of recoveredPkgs; let i = index;">
|
<app-list-rec
|
||||||
<ion-thumbnail slot="start">
|
*ngFor="let rec of recoveredPkgs"
|
||||||
<img [src]="rec.icon" />
|
[rec]="rec"
|
||||||
</ion-thumbnail>
|
(deleted)="deleteRecovered(rec)"
|
||||||
<ion-label>
|
></app-list-rec>
|
||||||
<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>
|
|
||||||
</ion-item-group>
|
</ion-item-group>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</div>
|
</ng-template>
|
||||||
</ion-content>
|
</ion-content>
|
||||||
|
|||||||
@@ -2,23 +2,6 @@ ion-item-divider {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bulb {
|
.item {
|
||||||
position: absolute !important;
|
--background: var(--ion-color-medium-shade);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,246 +1,97 @@
|
|||||||
import { Component } from '@angular/core'
|
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 { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
|
||||||
import { DataModel, PackageDataEntry, PackageState, RecoveredPackageDataEntry } from 'src/app/services/patch-db/data-model'
|
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||||
import { combineLatest, Observable, Subscription } from 'rxjs'
|
import { Observable } from 'rxjs'
|
||||||
import { DependencyStatus, HealthStatus, PrimaryRendering, renderPkgStatus, StatusRendering } from 'src/app/services/pkg-status-rendering.service'
|
import { filter, map, switchMapTo, take, takeUntil, tap } from 'rxjs/operators'
|
||||||
import { filter, take, tap } from 'rxjs/operators'
|
|
||||||
import { isEmptyObject, exists } from 'src/app/util/misc.util'
|
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 { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
import { ErrorToastService } from 'src/app/services/error-toast.service'
|
import { parseDataModel, RecoveredInfo } from 'src/app/util/parse-data-model'
|
||||||
import { AlertController } from '@ionic/angular'
|
import { DestroyService } from 'src/app/services/destroy.service'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-list',
|
selector: 'app-list',
|
||||||
templateUrl: './app-list.page.html',
|
templateUrl: './app-list.page.html',
|
||||||
styleUrls: ['./app-list.page.scss'],
|
styleUrls: ['./app-list.page.scss'],
|
||||||
|
providers: [DestroyService],
|
||||||
})
|
})
|
||||||
export class AppListPage {
|
export class AppListPage {
|
||||||
PackageState = PackageState
|
pkgs: readonly PackageDataEntry[] = []
|
||||||
|
recoveredPkgs: readonly RecoveredInfo[] = []
|
||||||
subs: Subscription[] = []
|
order: readonly string[] = []
|
||||||
connectionFailure: boolean
|
|
||||||
pkgs: PkgInfo[] = []
|
|
||||||
recoveredPkgs: RecoveredInfo[] = []
|
|
||||||
order: string[] = []
|
|
||||||
loading = true
|
loading = true
|
||||||
empty = false
|
|
||||||
reordering = false
|
reordering = false
|
||||||
|
|
||||||
constructor (
|
constructor (
|
||||||
private readonly config: ConfigService,
|
|
||||||
private readonly connectionService: ConnectionService,
|
|
||||||
private readonly pkgLoading: PackageLoadingService,
|
|
||||||
private readonly api: ApiService,
|
private readonly api: ApiService,
|
||||||
private readonly patch: PatchDbService,
|
private readonly patch: PatchDbService,
|
||||||
private readonly errToast: ErrorToastService,
|
private readonly destroy$: DestroyService,
|
||||||
private readonly alertCtrl: AlertController,
|
|
||||||
) { }
|
) { }
|
||||||
|
|
||||||
|
get empty (): boolean {
|
||||||
|
return !this.pkgs.length && isEmptyObject(this.recoveredPkgs)
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit () {
|
ngOnInit () {
|
||||||
this.patch.watch$()
|
this.patch
|
||||||
.pipe(
|
.watch$()
|
||||||
filter(data => exists(data) && !isEmptyObject(data)),
|
.pipe(
|
||||||
take(1),
|
filter((data) => exists(data) && !isEmptyObject(data)),
|
||||||
)
|
take(1),
|
||||||
.subscribe(data => {
|
map(parseDataModel),
|
||||||
this.loading = false
|
tap(({ order, pkgs, recoveredPkgs }) => {
|
||||||
const pkgs = JSON.parse(JSON.stringify(data['package-data'])) as { [id: string]: PackageDataEntry }
|
this.pkgs = pkgs
|
||||||
this.recoveredPkgs = Object.entries(data['recovered-packages'])
|
this.recoveredPkgs = recoveredPkgs
|
||||||
.filter(([id, _]) => !pkgs[id])
|
this.order = order
|
||||||
.map(([id, val]) => {
|
this.loading = false
|
||||||
return {
|
|
||||||
...val,
|
|
||||||
id,
|
|
||||||
installing: false,
|
|
||||||
}
|
|
||||||
})
|
|
||||||
this.order = [...data.ui['pkg-order'] || []]
|
|
||||||
|
|
||||||
// add known pkgs in preferential order
|
// set order in UI DB if there were unknown packages
|
||||||
this.order.forEach(id => {
|
if (order.length < pkgs.length) {
|
||||||
if (pkgs[id]) {
|
this.setOrder()
|
||||||
this.pkgs.push(this.subscribeToPkg(pkgs[id]))
|
}
|
||||||
delete pkgs[id]
|
}),
|
||||||
}
|
switchMapTo(this.watchNewlyRecovered()),
|
||||||
})
|
takeUntil(this.destroy$),
|
||||||
|
)
|
||||||
// unshift unknown packages and set order in UI DB
|
.subscribe()
|
||||||
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
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy () {
|
onReordering (reordering: boolean): void {
|
||||||
this.pkgs.forEach(pkg => pkg.sub.unsubscribe())
|
if (!reordering) {
|
||||||
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)
|
|
||||||
this.setOrder()
|
this.setOrder()
|
||||||
}
|
}
|
||||||
this.reordering = !this.reordering
|
|
||||||
|
this.reordering = reordering
|
||||||
}
|
}
|
||||||
|
|
||||||
async reorder (ev: CustomEvent<ItemReorderEventDetail>): Promise<void> {
|
deleteRecovered (rec: RecoveredInfo): void {
|
||||||
this.pkgs = ev.detail.complete(this.pkgs)
|
this.recoveredPkgs = this.recoveredPkgs.filter((item) => item !== rec)
|
||||||
}
|
}
|
||||||
|
|
||||||
async install (pkg: RecoveredInfo): Promise<void> {
|
private watchNewlyRecovered (): Observable<unknown> {
|
||||||
pkg.installing = true
|
return this.patch.watch$('package-data').pipe(
|
||||||
try {
|
filter((pkgs) => Object.keys(pkgs).length !== this.pkgs.length),
|
||||||
await this.api.installPackage({ id: pkg.id, 'version-spec': undefined })
|
tap((pkgs) => {
|
||||||
} 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 => {
|
|
||||||
const ids = Object.keys(pkgs)
|
const ids = Object.keys(pkgs)
|
||||||
|
const newIds = ids.filter(
|
||||||
|
(id) => !this.pkgs.find((pkg) => pkg.manifest.id === id),
|
||||||
|
)
|
||||||
|
|
||||||
// remove uninstalled
|
// remove uninstalled
|
||||||
this.pkgs.forEach((pkg, i) => {
|
const filtered = this.pkgs.filter((pkg) =>
|
||||||
const id = pkg.entry.manifest.id
|
ids.includes(pkg.manifest.id),
|
||||||
if (!ids.includes(id)) {
|
)
|
||||||
pkg.sub.unsubscribe()
|
|
||||||
this.pkgs.splice(i, 1)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
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 => {
|
this.pkgs = [...added, ...filtered]
|
||||||
// if already exists, return
|
this.recoveredPkgs.filter((rec) => !pkgs[rec.id])
|
||||||
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]))
|
|
||||||
})
|
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private setOrder (): void {
|
private setOrder (): void {
|
||||||
|
this.order = this.pkgs.map((pkg) => pkg.manifest.id)
|
||||||
this.api.setDbValue({ pointer: '/pkg-order', value: this.order })
|
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
|
|
||||||
}
|
|
||||||
21
ui/src/app/pages/apps-routes/app-list/package-info.pipe.ts
Normal file
21
ui/src/app/pages/apps-routes/app-list/package-info.pipe.ts
Normal 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))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,10 +13,10 @@ import { DependencyStatus, HealthStatus, PrimaryRendering, PrimaryStatus, render
|
|||||||
import { ConnectionFailure, ConnectionService } from 'src/app/services/connection.service'
|
import { ConnectionFailure, ConnectionService } from 'src/app/services/connection.service'
|
||||||
import { ErrorToastService } from 'src/app/services/error-toast.service'
|
import { ErrorToastService } from 'src/app/services/error-toast.service'
|
||||||
import { AppConfigPage } from 'src/app/modals/app-config/app-config.page'
|
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 { filter } from 'rxjs/operators'
|
||||||
import { MarkdownPage } from 'src/app/modals/markdown/markdown.page'
|
import { MarkdownPage } from 'src/app/modals/markdown/markdown.page'
|
||||||
import { Pipe, PipeTransform } from '@angular/core'
|
import { Pipe, PipeTransform } from '@angular/core'
|
||||||
|
import { packageLoadingProgress, ProgressData } from 'src/app/util/package-loading-progress'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-show',
|
selector: 'app-show',
|
||||||
@@ -60,7 +60,6 @@ export class AppShowPage {
|
|||||||
private readonly embassyApi: ApiService,
|
private readonly embassyApi: ApiService,
|
||||||
private readonly wizardBaker: WizardBaker,
|
private readonly wizardBaker: WizardBaker,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly packageLoadingService: PackageLoadingService,
|
|
||||||
private readonly patch: PatchDbService,
|
private readonly patch: PatchDbService,
|
||||||
private readonly connectionService: ConnectionService,
|
private readonly connectionService: ConnectionService,
|
||||||
) { }
|
) { }
|
||||||
@@ -86,7 +85,7 @@ export class AppShowPage {
|
|||||||
|
|
||||||
this.pkg = pkg
|
this.pkg = pkg
|
||||||
this.statuses = renderPkgStatus(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
|
// 2
|
||||||
|
|||||||
@@ -191,7 +191,7 @@ export class MarketplaceShowPage {
|
|||||||
loader.present()
|
loader.present()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.embassyApi.installPackage({ id, 'version-spec': version ? `=${version}` : undefined })
|
await this.embassyApi.installPackage({ id, version: version ? `=${version}` : undefined })
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.errToast.present(e)
|
this.errToast.present(e)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
import { Pipe, PipeTransform } from '@angular/core'
|
import { Pipe, PipeTransform } from '@angular/core'
|
||||||
import { PackageLoadingService, ProgressData } from '../services/package-loading.service'
|
|
||||||
import { InstallProgress } from '../services/patch-db/data-model'
|
import { InstallProgress } from '../services/patch-db/data-model'
|
||||||
|
import { packageLoadingProgress, ProgressData } from '../util/package-loading-progress';
|
||||||
|
|
||||||
@Pipe({
|
@Pipe({
|
||||||
name: 'installState',
|
name: 'installState',
|
||||||
})
|
})
|
||||||
export class InstallState implements PipeTransform {
|
export class InstallState implements PipeTransform {
|
||||||
|
|
||||||
constructor (
|
|
||||||
private readonly pkgLoading: PackageLoadingService,
|
|
||||||
) { }
|
|
||||||
|
|
||||||
transform (loadData: InstallProgress): ProgressData {
|
transform (loadData: InstallProgress): ProgressData {
|
||||||
return this.pkgLoading.transform(loadData)
|
return packageLoadingProgress(loadData)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export module RR {
|
|||||||
export type GetPackageMetricsReq = { id: string } // package.metrics
|
export type GetPackageMetricsReq = { id: string } // package.metrics
|
||||||
export type GetPackageMetricsRes = Metric
|
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 InstallPackageRes = WithRevision<null>
|
||||||
|
|
||||||
export type DryUpdatePackageReq = { id: string, version: string } // package.update.dry
|
export type DryUpdatePackageReq = { id: string, version: string } // package.update.dry
|
||||||
|
|||||||
13
ui/src/app/services/destroy.service.ts
Normal file
13
ui/src/app/services/destroy.service.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
38
ui/src/app/util/get-package-info.ts
Normal file
38
ui/src/app/util/get-package-info.ts
Normal 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
|
||||||
|
}
|
||||||
50
ui/src/app/util/package-loading-progress.ts
Normal file
50
ui/src/app/util/package-loading-progress.ts
Normal 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;
|
||||||
|
}
|
||||||
50
ui/src/app/util/parse-data-model.ts
Normal file
50
ui/src/app/util/parse-data-model.ts
Normal 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[];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user