0.3.0 refactor

ui: adds overlay layer to patch-db-client

ui: getting towards mocks

ui: cleans up factory init

ui: nice type hack

ui: live api for patch

ui: api service source + http

starts up

ui: api source + http

ui: rework patchdb config, pass stashTimeout into patchDbModel

wires in temp patching into api service

ui: example of wiring patchdbmodel into page

begin integration

remove unnecessary method

linting

first data rendering

rework app initialization

http source working for ssh delete call

temp patches working

entire Embassy tab complete

not in kansas anymore

ripping, saving progress

progress for API request response types and endoint defs

Update data-model.ts

shambles, but in a good way

progress

big progress

progress

installed list working

big progress

progress

progress

begin marketplace redesign

Update api-types.ts

Update api-types.ts

marketplace improvements

cosmetic

dependencies and recommendations

begin nym auth approach

install wizard

restore flow and donations
This commit is contained in:
Aaron Greenspan
2021-02-16 13:45:09 -07:00
committed by Aiden McClelland
parent 46f32cb90b
commit 8d01ebe8b2
238 changed files with 25509 additions and 14852 deletions

View File

@@ -8,34 +8,25 @@
</ion-header>
<ion-content class="ion-padding-top">
<ion-spinner *ngIf="$loading$ | async" class="center" name="lines" color="warning"></ion-spinner>
<ng-container *ngIf="patch.watch$('package-data', pkgId, 'installed') | ngrxPush as installed">
<ng-container *ngIf="installed.manifest as manifest">
<ng-container *ngIf="!($loading$ | async) && {
title: app.title | async,
versionInstalled: app.versionInstalled | async,
status: app.status | async,
actions: app.actions | async
} as vars">
<ion-item *ngIf="error" style="margin-bottom: 16px;">
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
</ion-item>
<!-- no metrics -->
<ion-item *ngIf="!vars.actions.length">
<ion-label class="ion-text-wrap">
<p>No Actions for {{ vars.title }} {{ vars.versionInstalled }}.</p>
</ion-label>
</ion-item>
<!-- actions -->
<ion-item-group>
<ion-item button *ngFor="let action of vars.actions" (click)="handleAction(action)" >
<ion-item *ngIf="manifest.actions | empty; else actions">
<ion-label class="ion-text-wrap">
<h2><ion-text color="primary">{{ action.name }}</ion-text><ion-icon *ngIf="!(action.allowedStatuses | includes: vars.status)" color="danger" name="close-outline"></ion-icon></h2>
<p><ion-text color="dark">{{ action.description }}</ion-text></p>
<p>No Actions for {{ manifest.title }} {{ manifest.versionInstalled }}.</p>
</ion-label>
</ion-item>
</ion-item-group>
<ng-template #actions>
<ion-item-group>
<ion-item button *ngFor="let action of manifest.actions | keyvalue: asIsOrder" (click)="handleAction(installed, action)" >
<ion-label class="ion-text-wrap">
<h2><ion-text color="primary">{{ action.value.name }}</ion-text><ion-icon *ngIf="!(action.value['allowed-statuses'] | includes: installed.status.main.status)" color="danger" name="close-outline"></ion-icon></h2>
<p><ion-text color="dark">{{ action.value.description }}</ion-text></p>
</ion-label>
</ion-item>
</ion-item-group>
</ng-template>
</ng-container>
</ng-container>
</ion-content>

View File

@@ -1,49 +1,37 @@
import { Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ApiService, isRpcFailure, isRpcSuccess } from 'src/app/services/api/api.service'
import { BehaviorSubject } from 'rxjs'
import { ApiService } from 'src/app/services/api/api.service'
import { AlertController } from '@ionic/angular'
import { ModelPreload } from 'src/app/models/model-preload'
import { LoaderService, markAsLoadingDuring$ } from 'src/app/services/loader.service'
import { ServiceAction, AppInstalledFull } from 'src/app/models/app-types'
import { PropertySubject } from 'src/app/util/property-subject.util'
import { map } from 'rxjs/operators'
import { Cleanup } from 'src/app/util/cleanup'
import { AppStatus } from 'src/app/models/app-model'
import { LoaderService } from 'src/app/services/loader.service'
import { HttpErrorResponse } from '@angular/common/http'
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
import { Action, InstalledPackageDataEntry, PackageMainStatus } from 'src/app/models/patch-db/data-model'
@Component({
selector: 'app-actions',
templateUrl: './app-actions.page.html',
styleUrls: ['./app-actions.page.scss'],
})
export class AppActionsPage extends Cleanup {
error = ''
$loading$ = new BehaviorSubject(true)
appId: string
app: PropertySubject<AppInstalledFull>
export class AppActionsPage {
pkgId: string
constructor(
constructor (
private readonly route: ActivatedRoute,
private readonly apiService: ApiService,
private readonly alertCtrl: AlertController,
private readonly preload: ModelPreload,
private readonly loaderService: LoaderService,
) { super() }
public readonly patch: PatchDbModel,
) { }
ngOnInit() {
this.appId = this.route.snapshot.paramMap.get('appId')
markAsLoadingDuring$(this.$loading$, this.preload.appFull(this.appId)).pipe(
map(app => this.app = app),
).subscribe({ error: e => this.error = e.message })
ngOnInit () {
this.pkgId = this.route.snapshot.paramMap.get('pkgId')
}
async handleAction(action: ServiceAction) {
if (action.allowedStatuses.includes(this.app.status.getValue())) {
async handleAction (pkg: InstalledPackageDataEntry, action: { key: string, value: Action }) {
if ((action.value['allowed-statuses'] as PackageMainStatus[]).includes(pkg.status.main.status)) {
const alert = await this.alertCtrl.create({
header: 'Confirm',
message: `Are you sure you want to execute action "${action.name}"? ${action.warning ? action.warning : ""}`,
message: `Are you sure you want to execute action "${action.value.name}"? ${action.value.warning || ''}`,
buttons: [
{
text: 'Cancel',
@@ -52,7 +40,7 @@ export class AppActionsPage extends Cleanup {
{
text: 'Execute',
handler: () => {
this.executeAction(action)
this.executeAction(pkg.manifest.id, action.key)
},
},
],
@@ -83,25 +71,19 @@ export class AppActionsPage extends Cleanup {
}
}
private async executeAction(action: ServiceAction) {
private async executeAction (pkgId: string, actionId: string) {
try {
const res = await this.loaderService.displayDuringP(
this.apiService.serviceAction(this.appId, action),
this.apiService.executePackageAction({ id: pkgId, 'action-id': actionId }),
)
if (isRpcFailure(res)) {
this.presentAlertActionFail(res.error.code, res.error.message)
}
if (isRpcSuccess(res)) {
const successAlert = await this.alertCtrl.create({
header: 'Execution Complete',
message: res.result.split('\n').join('</br ></br />'),
buttons: ['OK'],
cssClass: 'alert-success-message',
})
return await successAlert.present()
}
const successAlert = await this.alertCtrl.create({
header: 'Execution Complete',
message: res.message.split('\n').join('</br ></br />'),
buttons: ['OK'],
cssClass: 'alert-success-message',
})
return await successAlert.present()
} catch (e) {
if (e instanceof HttpErrorResponse) {
this.presentAlertActionFail(e.status, e.message)
@@ -111,7 +93,7 @@ export class AppActionsPage extends Cleanup {
}
}
private async presentAlertActionFail(code: number, message: string): Promise<void> {
private async presentAlertActionFail (code: number, message: string): Promise<void> {
const failureAlert = await this.alertCtrl.create({
header: 'Execution Failed',
message: `Error code ${code}. ${message}`,

View File

@@ -4,10 +4,8 @@ import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppAvailableListPage } from './app-available-list.page'
import { SharingModule } from '../../../modules/sharing.module'
import { PwaBackComponentModule } from 'src/app/components/pwa-back-button/pwa-back.component.module'
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
import { StatusComponentModule } from 'src/app/components/status/status.component.module'
import { UpdateOsBannerComponentModule } from 'src/app/components/update-os-banner/update-os-banner.component.module'
const routes: Routes = [
@@ -24,9 +22,7 @@ const routes: Routes = [
RouterModule.forChild(routes),
StatusComponentModule,
SharingModule,
PwaBackComponentModule,
BadgeMenuComponentModule,
UpdateOsBannerComponentModule,
],
declarations: [AppAvailableListPage],
})

View File

@@ -1,74 +1,87 @@
<ion-header>
<ion-toolbar>
<ion-title>Service Marketplace</ion-title>
<ion-title>Embassy Marketplace</ion-title>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
</ion-toolbar>
<update-os-banner></update-os-banner>
<ion-toolbar *ngIf="!pageLoading">
<ion-searchbar (ionChange)="search($event)" debounce="400"></ion-searchbar>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-bottom">
<ion-refresher slot="fixed" (ionRefresh)="doRefresh($event)">
<ion-refresher-content pullingIcon="lines" refreshingSpinner="lines"></ion-refresher-content>
</ion-refresher>
<ion-content class="ion-padding-top" *ngrxLet="patch.watch$('package-data') as installedPkgs">
<ion-spinner *ngIf="pageLoading; else pageLoaded" class="center" name="lines" color="warning"></ion-spinner>
<ion-spinner *ngIf="$loading$ | async" class="center" name="lines" color="warning"></ion-spinner>
<ng-container *ngIf="!($loading$ | async)">
<ng-template #pageLoaded>
<ion-item *ngIf="error" style="margin-bottom: 16px;">
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
</ion-item>
<ion-card *ngIf="v1Status.status === 'instructions'" [href]="'https://start9.com/eos-' + v1Status.version" target="_blank" class="instructions-card">
<ion-card-header>
<ion-card-subtitle>Coming Soon...</ion-card-subtitle>
<ion-card-title>EmbassyOS Version {{ v1Status.version }}</ion-card-title>
</ion-card-header>
<ion-card-content>
<b>Get ready. View the update instructions.</b>
</ion-card-content>
</ion-card>
<div class="scrollable">
<ion-button
*ngFor="let cat of data.categories"
size="small"
fill="clear"
[color]="cat === category ? 'success' : 'dark'"
(click)="switchCategory(cat)"
>
{{ cat }}
</ion-button>
</div>
<ion-card *ngIf="v1Status.status === 'available'" [href]="'https://start9.com/eos-' + v1Status.version" target="_blank" class="available-card">
<ion-card *ngIf="eos && category === 'featured'" class="eos-card" (click)="updateEos()">
<ion-card-header>
<ion-card-subtitle>Now Available...</ion-card-subtitle>
<ion-card-title>EmbassyOS Version {{ v1Status.version }}</ion-card-title>
<ion-card-title>EmbassyOS Version {{ eos.version }}</ion-card-title>
</ion-card-header>
<ion-card-content>
<b>View the update instructions.</b>
{{ eos.headline }}
</ion-card-content>
</ion-card>
<ion-card *ngFor="let app of apps" style="margin: 10px 10px;" [routerLink]="[app.id]">
<ion-item style="--inner-border-width: 0 0 .4px 0; --border-color: #525252;" *ngIf="{ installing: (app.subject.status | async) === 'INSTALLING', installComparison: app.subject | compareInstalledAndLatest | async } as l">
<ion-avatar style="margin-top: 8px;" slot="start">
<img [src]="app.subject.iconURL | async | iconParse" />
</ion-avatar>
<ion-label style="margin-top: 6px; margin-bottom: 3px">
<h1 style="font-family: 'Montserrat'; font-size: 20px; margin-bottom: 0px;">
{{app.subject.title | async}}
</h1>
<div *ngIf="!l.installing && l.installComparison === 'installed-equal'" class="beneath-title">
<ion-text style="font-size: 12px;" color="success">Installed</ion-text>
</div>
<div *ngIf="!l.installing && l.installComparison === 'installed-below'" class="beneath-title">
<ion-text style="font-size: 12px;" color="warning">Update Available</ion-text>
</div>
<div *ngIf="l.installing" class="beneath-title" style="display: flex; flex-direction: row; align-items: center;">
<ion-text style="font-size: 12px;" color="primary">Installing</ion-text>
<ion-spinner name="crescent" style="height: 10px; width: 15px; margin-left: 3px; margin-right: -4px;" color="primary"></ion-spinner>
</div>
</ion-label>
</ion-item>
<ion-card-content style="
font-size: small !important;
padding-bottom: 10px;
padding-top: 6px;
">
{{ app.subject.descriptionShort | async }}
</ion-card-content>
</ion-card>
</ng-container>
<ion-spinner *ngIf="pkgsLoading; else pkgsLoaded" class="center" name="lines" color="warning"></ion-spinner>
<ng-template #pkgsLoaded>
<ion-card *ngFor="let pkg of pkgs" style="margin: 10px 10px;" [routerLink]="[pkg.id]">
<ion-item style="--inner-border-width: 0 0 .4px 0; --border-color: #525252;">
<ion-avatar style="margin-top: 8px;" slot="start">
<img [src]="pkg.icon" />
</ion-avatar>
<ion-label style="margin-top: 6px; margin-bottom: 3px">
<h1 style="font-family: 'Montserrat'; font-size: 20px; margin-bottom: 0px;">
{{ pkg.title }}
</h1>
<p>{{ pkg.version }}</p>
<div class="beneath-title" *ngIf="installedPkgs[pkg.id] as pkgI">
<ng-container *ngIf="pkgI.state === PackageState.Installed">
<ion-text *ngIf="(pkg.version | compareEmver : pkgI.installed.manifest.version) === 0" style="font-size: 12px;" color="success">Installed</ion-text>
<ion-text *ngIf="(pkg.version | compareEmver : pkgI.installed.manifest.version) === 1" style="font-size: 12px;" color="warning">Update Available</ion-text>
</ng-container>
<div class="beneath-title" *ngIf="pkgI.state === PackageState.Installing" style="display: flex; flex-direction: row; align-items: center;">
<ion-text style="font-size: 12px;" color="primary">Installing</ion-text>
<ion-spinner name="crescent" style="height: 10px; width: 15px; margin-left: 3px; margin-right: -4px;" color="primary"></ion-spinner>
</div>
<div class="beneath-title" *ngIf="pkgI.state === PackageState.Updating" style="display: flex; flex-direction: row; align-items: center;">
<ion-text style="font-size: 12px;" color="primary">Updating</ion-text>
<ion-spinner name="crescent" style="height: 10px; width: 15px; margin-left: 3px; margin-right: -4px;" color="primary"></ion-spinner>
</div>
<div class="beneath-title" *ngIf="pkgI.state === PackageState.Removing" style="display: flex; flex-direction: row; align-items: center;">
<ion-text style="font-size: 12px;" color="danger">Removing</ion-text>
<ion-spinner name="crescent" style="height: 10px; width: 15px; margin-left: 3px; margin-right: -4px;" color="danger"></ion-spinner>
</div>
</div>
</ion-label>
</ion-item>
<ion-card-content style="
font-size: small !important;
padding-bottom: 10px;
padding-top: 6px;
">
{{ pkg.descriptionShort }}
</ion-card-content>
</ion-card>
</ng-template>
</ng-template>
</ion-content>

View File

@@ -5,12 +5,24 @@
padding: 1px 0px 1.5px 0px;
}
.instructions-card {
--background: linear-gradient(45deg, #101010 16%, var(--ion-color-medium) 150%);
margin: 16px 10px;
.scrollable {
overflow: auto;
white-space: nowrap;
background-color: var(--ion-color-light);
margin-bottom: 16px;
/* Hide scrollbar for Chrome, Safari and Opera */
::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.available-card {
.eos-card {
--background: linear-gradient(45deg, #101010 16%, var(--ion-color-danger) 150%);
margin: 16px 10px;
}
cursor: pointer;
}

View File

@@ -1,14 +1,11 @@
import { Component, NgZone } from '@angular/core'
import { Component } from '@angular/core'
import { ApiService } from 'src/app/services/api/api.service'
import { AppModel } from 'src/app/models/app-model'
import { AppAvailablePreview, AppInstalledPreview } from 'src/app/models/app-types'
import { pauseFor } from 'src/app/util/misc.util'
import { PropertySubjectId, initPropertySubject } from 'src/app/util/property-subject.util'
import { Subscription, BehaviorSubject, combineLatest } from 'rxjs'
import { take } from 'rxjs/operators'
import { markAsLoadingDuringP } from 'src/app/services/loader.service'
import { OsUpdateService } from 'src/app/services/os-update.service'
import { V1Status } from 'src/app/services/api/api-types'
import { MarketplaceData, MarketplaceEOS, AvailablePreview } from 'src/app/services/api/api-types'
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
import { ModalController } from '@ionic/angular'
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
import { PackageState } from 'src/app/models/patch-db/data-model'
@Component({
selector: 'app-available-list',
@@ -16,83 +13,93 @@ import { V1Status } from 'src/app/services/api/api-types'
styleUrls: ['./app-available-list.page.scss'],
})
export class AppAvailableListPage {
$loading$ = new BehaviorSubject(true)
pageLoading = true
pkgsLoading = true
error = ''
installedAppDeltaSubscription: Subscription
apps: PropertySubjectId<AppAvailablePreview>[] = []
appsInstalled: PropertySubjectId<AppInstalledPreview>[] = []
v1Status: V1Status = { status: 'nothing', version: '' }
category = 'featured'
query: string
data: MarketplaceData
eos: MarketplaceEOS
pkgs: AvailablePreview[] = []
PackageState = PackageState
page = 1
needInfinite = false
readonly perPage = 20
constructor (
private readonly apiService: ApiService,
private readonly appModel: AppModel,
private readonly zone: NgZone,
private readonly osUpdateService: OsUpdateService,
private readonly modalCtrl: ModalController,
private readonly wizardBaker: WizardBaker,
public patch: PatchDbModel,
) { }
async ngOnInit () {
this.installedAppDeltaSubscription = this.appModel
.watchDelta('update')
.subscribe(({ id }) => this.mergeInstalledProps(id))
markAsLoadingDuringP(this.$loading$, Promise.all([
this.getApps(),
this.checkV1Status(),
this.osUpdateService.checkWhenNotAvailable$().toPromise(), // checks for an os update, banner component renders conditionally
pauseFor(600),
]))
}
ionViewDidEnter () {
this.appModel.getContents().forEach(appInstalled => this.mergeInstalledProps(appInstalled.id))
}
async checkV1Status () {
try {
this.v1Status = await this.apiService.checkV1Status()
} catch (e) {
console.error(e)
}
}
mergeInstalledProps (appInstalledId: string) {
const appAvailable = this.apps.find(app => app.id === appInstalledId)
if (!appAvailable) return
const app = this.appModel.watch(appInstalledId)
combineLatest([app.status, app.versionInstalled])
.pipe(take(1))
.subscribe(([status, versionInstalled]) => {
this.zone.run(() => {
appAvailable.subject.status.next(status)
appAvailable.subject.versionInstalled.next(versionInstalled)
})
})
}
ngOnDestroy () {
this.installedAppDeltaSubscription.unsubscribe()
}
async doRefresh (e: any) {
await Promise.all([
this.getApps(),
pauseFor(600),
])
e.target.complete()
}
async getApps (): Promise<void> {
try {
this.apps = await this.apiService.getAvailableApps().then(apps =>
apps
.sort( (a1, a2) => a2.latestVersionTimestamp.getTime() - a1.latestVersionTimestamp.getTime())
.map(a => ({ id: a.id, subject: initPropertySubject(a) })),
)
this.appModel.getContents().forEach(appInstalled => this.mergeInstalledProps(appInstalled.id))
const [data, eos, pkgs] = await Promise.all([
this.apiService.getMarketplaceData({ }),
this.apiService.getEos({ }),
this.getPkgs(),
])
this.data = data
this.eos = eos
this.pkgs = pkgs
} catch (e) {
console.error(e)
this.error = e.message
} finally {
this.pageLoading = false
this.pkgsLoading = false
}
}
async doInfinite (e: any): Promise<void> {
const pkgs = await this.getPkgs()
this.pkgs = this.pkgs.concat(pkgs)
e.target.complete()
}
async search (e?: any): Promise<void> {
this.query = e.target.value || undefined
this.page = 1
this.pkgs = await this.getPkgs()
}
async updateEos (): Promise<void> {
await wizardModal(
this.modalCtrl,
this.wizardBaker.updateOS({
version: this.eos.version,
releaseNotes: this.eos.notes,
}),
)
}
private async getPkgs (): Promise<AvailablePreview[]> {
this.pkgsLoading = true
try {
const pkgs = await this.apiService.getAvailableList({
category: this.category,
query: this.query,
page: this.page,
'per-page': this.perPage,
})
this.needInfinite = pkgs.length >= this.perPage
this.page++
return pkgs
} catch (e) {
console.error(e)
this.error = e.message
} finally {
this.pkgsLoading = false
}
}
async switchCategory (category: string): Promise<void> {
this.category = category
this.pkgs = await this.getPkgs()
}
}

View File

@@ -2,7 +2,6 @@ import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { DependencyListComponentModule } from '../../../components/dependency-list/dependency-list.component.module'
import { AppAvailableShowPage } from './app-available-show.page'
import { SharingModule } from 'src/app/modules/sharing.module'
import { PwaBackComponentModule } from 'src/app/components/pwa-back-button/pwa-back.component.module'
@@ -10,7 +9,6 @@ import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/b
import { StatusComponentModule } from 'src/app/components/status/status.component.module'
import { RecommendationButtonComponentModule } from 'src/app/components/recommendation-button/recommendation-button.component.module'
import { InstallWizardComponentModule } from 'src/app/components/install-wizard/install-wizard.component.module'
import { ErrorMessageComponentModule } from 'src/app/components/error-message/error-message.component.module'
import { InformationPopoverComponentModule } from 'src/app/components/information-popover/information-popover.component.module'
const routes: Routes = [
@@ -25,14 +23,12 @@ const routes: Routes = [
CommonModule,
IonicModule,
StatusComponentModule,
DependencyListComponentModule,
RouterModule.forChild(routes),
SharingModule,
PwaBackComponentModule,
RecommendationButtonComponentModule,
BadgeMenuComponentModule,
InstallWizardComponentModule,
ErrorMessageComponentModule,
InformationPopoverComponentModule,
],
declarations: [AppAvailableShowPage],

View File

@@ -10,109 +10,140 @@
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-bottom" *ngIf="{
id: $app$.id | async,
status: $app$.status | async,
title: $app$.title | async,
versionInstalled: $app$.versionInstalled | async,
versionViewing: $app$.versionViewing | async,
descriptionLong: $app$.descriptionLong | async,
licenseName: $app$.licenseName | async,
licenseLink: $app$.licenseLink | async,
serviceRequirements: $app$.serviceRequirements | async,
iconURL: $app$.iconURL | async,
releaseNotes: $app$.releaseNotes | async
} as vars"
>
<ion-spinner *ngIf="($loading$ | async)" class="center" name="lines" color="warning"></ion-spinner>
<ion-content class="ion-padding-bottom">
<error-message [$error$]="$error$" [dismissable]="vars.id"></error-message>
<ion-spinner *ngIf="loading; else loaded" class="center" name="lines" color="warning"></ion-spinner>
<ng-container *ngIf="!($loading$ | async) && vars.id && ($app$ | compareInstalledAndViewing | async) as installedStatus">
<ion-item-group>
<ion-item lines="none">
<ion-avatar slot="start">
<img [src]="vars.iconURL | iconParse" />
</ion-avatar>
<ion-label class="ion-text-wrap">
<h1 style="font-family: 'Montserrat'">{{ vars.title }}</h1>
<h3>{{ vars.versionViewing | displayEmver }}</h3>
<ng-container *ngIf="vars.status !== 'INSTALLING'">
<h3 *ngIf="installedStatus === 'installed-equal'"><ion-text color="medium">Installed</ion-text></h3>
<h3 *ngIf="installedStatus === 'installed-below' || installedStatus === 'installed-above'"><ion-text color="medium">Installed </ion-text><ion-text style="font-size: small" color="medium"> at {{vars.versionInstalled | displayEmver}}</ion-text></h3>
</ng-container>
<ng-container *ngIf="vars.status === 'INSTALLING'">
<h3>
<status appStatus="INSTALLING" [text]="' (' + (vars.versionInstalled | displayEmver) + ')'" size="medium"></status>
</h3>
</ng-container>
</ion-label>
<ng-template #loaded>
<ion-item *ngIf="error" style="margin-bottom: 16px;">
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
</ion-item>
</ion-item>
</ion-item-group>
<ion-button *ngIf="!vars.versionInstalled" class="main-action-button" expand="block" fill="outline" color="success" (click)="install()">
Install
</ion-button>
<div *ngIf="vars.versionInstalled">
<ion-button class="main-action-button" expand="block" fill="outline" [routerLink]="['/services', 'installed', vars.id]">
Go to Service
</ion-button>
<div *ngIf="vars.status !== 'INSTALLING' ">
<ion-button *ngIf="installedStatus === 'installed-below'" class="main-action-button" expand="block" fill="outline" color="success" (click)="update('update')">
Update to {{ vars.versionViewing | displayEmver }}
</ion-button>
<ion-button *ngIf="installedStatus === 'installed-above'" class="main-action-button" expand="block" fill="outline" color="warning" (click)="update('downgrade')">
Downgrade to {{ vars.versionViewing | displayEmver }}
</ion-button>
</div>
</div>
<ion-item-group>
<ng-container *ngIf="recommendation">
<ion-item class="recommendation-item">
<ng-container *ngrxLet="patch.watch$('package-data', pkgId) as localPkg">
<ion-item-group>
<ion-item lines="none">
<ion-avatar slot="start">
<img [src]="pkg.icon" />
</ion-avatar>
<ion-label class="ion-text-wrap">
<h2 style="display: flex; align-items: center;">
<ion-avatar style="height: 3vh; width: 3vh; margin: 5px" slot="start">
<img [src]="recommendation.iconURL | iconParse" [alt]="recommendation.title"/>
</ion-avatar>
<ion-text style="margin: 5px; font-family: 'Montserrat'; font-size: smaller;">{{recommendation.title}}</ion-text>
</h2>
<div style="margin: 2px 5px">
<p style="color: var(--ion-color-medium); font-size: small">{{recommendation.description}}</p>
<p *ngIf="vars.versionViewing | satisfiesEmver: recommendation.versionSpec" class="recommendation-text">{{vars.title}} version {{vars.versionViewing | displayEmver}} is compatible.</p>
<p *ngIf="!(vars.versionViewing | satisfiesEmver: recommendation.versionSpec)" class="recommendation-text recommendation-error">{{vars.title}} version {{vars.versionViewing | displayEmver}} is NOT compatible.</p>
</div>
<h1 style="font-family: 'Montserrat'">{{ pkg.manifest.title }}</h1>
<h3>{{ pkg.manifest.version | displayEmver }}</h3>
<!-- no localPkg -->
<h3 *ngIf="!localPkg; else local">
<ion-text color="medium">Not Installed</ion-text>
</h3>
<!-- localPkg -->
<ng-template #local>
<h3 *ngIf="localPkg.state !== PackageState.Installed; else installed">
<!-- installing, updating, removing -->
<ion-text [color]="localPkg.state === PackageState.Removing ? 'danger' : 'primary'">{{ localPkg.state }}</ion-text>
<ion-spinner class="dots dots-medium" name="dots" [color]="localPkg.state === PackageState.Removing ? 'danger' : 'primary'"></ion-spinner>
</h3>
<!-- installed -->
<ng-template #installed>
<h3>
<ion-text color="medium">Installed at {{ localPkg.installed.manifest.version | displayEmver }}</ion-text>
</h3>
</ng-template>
</ng-template>
</ion-label>
</ion-item>
</ng-container>
</ion-item-group>
<!-- no localPkg -->
<ion-button *ngIf="!localPkg; else localPkg2" class="main-action-button" expand="block" fill="outline" color="success" (click)="install()">
Install
</ion-button>
<!-- localPkg -->
<ng-template #localPkg2>
<!-- not removing -->
<ng-container *ngIf="localPkg.state !== PackageState.Removing">
<ion-button class="main-action-button" expand="block" fill="outline" [routerLink]="['/services', 'installed', pkgId]">
Go to Service
</ion-button>
<!-- not installing or updating -->
<ng-container *ngIf="localPkg.state === PackageState.Installed">
<ion-button *ngIf="(localPkg.installed.manifest.version | compareEmver : pkg.manifest.version) === -1" class="main-action-button" expand="block" fill="outline" color="success" (click)="update('update')">
Update to {{ pkg.manifest.version | displayEmver }}
</ion-button>
<ion-button *ngIf="(localPkg.installed.manifest.version | compareEmver : pkg.manifest.version) === 1" class="main-action-button" expand="block" fill="outline" color="warning" (click)="update('downgrade')">
Downgrade to {{ pkg.manifest.version | displayEmver }}
</ion-button>
</ng-container>
</ng-container>
</ng-template>
</ng-container>
<ion-item-divider style="color: var(--ion-color-dark); font-weight: bold;">New in {{ vars.versionViewing | displayEmver }}</ion-item-divider>
<!-- recommendation -->
<ion-item *ngIf="rec && showRec" class="rec-item">
<ion-label class="ion-text-wrap">
<h2 style="display: flex; align-items: center;">
<ion-avatar style="height: 3vh; width: 3vh; margin: 5px" slot="start">
<img [src]="rec.dependentIcon" [alt]="rec.dependentTitle"/>
</ion-avatar>
<ion-text style="margin: 5px; font-family: 'Montserrat'; font-size: smaller;">{{ rec.dependentTitle }}</ion-text>
</h2>
<div style="margin: 7px 5px;">
<p style="color: var(--ion-color-dark); font-size: small">{{ rec.description }}</p>
<p *ngIf="pkg.manifest.version | satisfiesEmver: rec.version" class="recommendation-text">{{ pkg.manifest.title }} version {{ pkg.manifest.version | displayEmver }} is compatible.</p>
<p *ngIf="!(pkg.manifest.version | satisfiesEmver: rec.version)" class="recommendation-text recommendation-error">{{ pkg.manifest.title }} version {{ pkg.manifest.version | displayEmver }} is NOT compatible.</p>
<ion-button style="position: absolute; right: 0; top: 0" color="primary" fill="clear" (click)="dismissRec()">
<ion-icon name="close-outline"></ion-icon>
</ion-button>
</div>
</ion-label>
</ion-item>
<ion-item-group>
<!-- release notes -->
<ion-item-divider style="color: var(--ion-color-dark); font-weight: bold;">New in {{ pkg.manifest.version | displayEmver }}</ion-item-divider>
<ion-item lines="none">
<ion-label *ngIf="!($newVersionLoading$ | async)" style="display: flex; align-items: center; justify-content: space-between;" class="ion-text-wrap" >
<div id='release-notes'color="dark" [innerHTML]="vars.releaseNotes | markdown"></div>
<ion-label style="display: flex; align-items: center; justify-content: space-between;" class="ion-text-wrap" >
<div id='release-notes' color="dark" [innerHTML]="pkg.manifest['release-notes'] | markdown"></div>
</ion-label>
<ion-spinner style="display: block; margin: auto;" name="lines" color="dark" *ngIf="$newVersionLoading$ | async"></ion-spinner>
</ion-item>
<ion-item-divider class="divider">Description</ion-item-divider>
<!-- description -->
<ion-item-divider class="divider">
<ion-text color="dark">Description</ion-text>
</ion-item-divider>
<ion-item lines="none">
<ion-label class="ion-text-wrap">
<ion-text color="medium">
<h5>{{ vars.descriptionLong }}</h5>
<ion-text color="dark">
<h5>{{ pkg.manifest.description.long }}</h5>
</ion-text>
</ion-label>
</ion-item>
<ng-container *ngIf="(vars.serviceRequirements)?.length">
<ion-item-divider class="divider">Service Dependencies
<ion-button style="position: relative; right: 10px;" size="small" fill="clear" color="medium" (click)="presentPopover(serviceDependencyDefintion, $event)">
<!-- dependencies -->
<ng-container *ngIf="!(pkg.manifest.dependencies | empty)">
<ion-item-divider class="divider">
<ion-text color="dark">Service Dependencies</ion-text>
<ion-button style="position: relative; right: 10px;" size="small" fill="clear" color="dark" (click)="presentPopover(depDefintion, $event)">
<ion-icon name="help-circle-outline"></ion-icon>
</ion-button>
</ion-item-divider>
<dependency-list [$loading$]="$dependenciesLoading$" [hostApp]="$app$ | peekProperties" [dependencies]="vars.serviceRequirements"></dependency-list>
<div *ngFor="let dep of pkg.manifest.dependencies | keyvalue">
<ion-item *ngIf="!dep.value.optional" class="dependency-item">
<ion-avatar slot="start">
<img [src]="pkg['dependency-metadata'][dep.key].icon" />
</ion-avatar>
<ion-label class="ion-text-wrap" style="padding: 1vh; padding-left: 2vh">
<h4 style="font-family: 'Montserrat'">
{{ pkg['dependency-metadata'][dep.key].title }}
<span *ngIf="dep.value.recommended" style="font-family: 'Open Sans'; font-size: small; color: var(--ion-color-dark)"> (recommended)</span>
</h4>
<p style="font-size: small">{{ dep.value.version | displayEmver }}</p>
</ion-label>
</ion-item>
<ion-item style="margin-bottom: 10px" *ngIf="dep.value.description" lines="none">
<div style="font-size: small; color: var(--ion-color-dark)" [innerHtml]="dep.value.description"></div>
</ion-item>
</div>
</ng-container>
<!-- versions -->
<ion-item-divider></ion-item-divider>
<ion-item *ngIf="vars.licenseLink" lines="none" button [href]="vars.licenseLink" target="_blank">
<ion-icon slot="start" name="newspaper-outline"></ion-icon>
@@ -120,9 +151,10 @@
<ion-note slot="end">{{ vars.licenseName }}</ion-note>
</ion-item>
<ion-item lines="none" button (click)="presentAlertVersions()">
<ion-icon slot="start" name="file-tray-stacked-outline"></ion-icon>
<ion-label>Other versions</ion-label>
<ion-icon color="dark" slot="start" name="file-tray-stacked-outline"></ion-icon>
<ion-label color="dark">Other versions</ion-label>
</ion-item>
</ion-item-group>
</ng-container>
</ng-template>
</ion-content>

View File

@@ -1,81 +1,64 @@
import { Component, NgZone } from '@angular/core'
import { Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { AppAvailableFull, AppAvailableVersionSpecificInfo } from 'src/app/models/app-types'
import { ApiService } from 'src/app/services/api/api.service'
import { AlertController, ModalController, NavController, PopoverController } from '@ionic/angular'
import { markAsLoadingDuring$ } from 'src/app/services/loader.service'
import { BehaviorSubject, from, Observable, of } from 'rxjs'
import { catchError, concatMap, filter, switchMap, tap } from 'rxjs/operators'
import { Recommendation } from 'src/app/components/recommendation-button/recommendation-button.component'
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
import { AppModel } from 'src/app/models/app-model'
import { initPropertySubject, peekProperties, PropertySubject } from 'src/app/util/property-subject.util'
import { Cleanup } from 'src/app/util/cleanup'
import { InformationPopoverComponent } from 'src/app/components/information-popover/information-popover.component'
import { Emver } from 'src/app/services/emver.service'
import { displayEmver } from 'src/app/pipes/emver.pipe'
import { pauseFor } from 'src/app/util/misc.util'
import { AvailableShow } from 'src/app/services/api/api-types'
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
import { PackageState } from 'src/app/models/patch-db/data-model'
@Component({
selector: 'app-available-show',
templateUrl: './app-available-show.page.html',
styleUrls: ['./app-available-show.page.scss'],
})
export class AppAvailableShowPage extends Cleanup {
$loading$ = new BehaviorSubject(true)
export class AppAvailableShowPage {
loading = true
error = ''
pkg: AvailableShow
pkgId: string
// When a new version is selected
$newVersionLoading$ = new BehaviorSubject(false)
// When dependencies are refreshing
$dependenciesLoading$ = new BehaviorSubject(false)
PackageState = PackageState
$error$ = new BehaviorSubject(undefined)
$app$: PropertySubject<AppAvailableFull> = { } as any
appId: string
rec: Recommendation | null = null
showRec = true
openRecommendation = false
recommendation: Recommendation | null = null
serviceDependencyDefintion = '<span style="font-style: italic">Service Dependencies</span> are other services that this service recommends or requires in order to run.'
depDefinition = '<span style="font-style: italic">Service Dependencies</span> are other services that this service recommends or requires in order to run.'
constructor (
private readonly route: ActivatedRoute,
private readonly apiService: ApiService,
private readonly alertCtrl: AlertController,
private readonly zone: NgZone,
private readonly modalCtrl: ModalController,
private readonly wizardBaker: WizardBaker,
private readonly navCtrl: NavController,
private readonly appModel: AppModel,
private readonly popoverController: PopoverController,
private readonly emver: Emver,
) {
super()
}
public readonly patch: PatchDbModel,
) { }
async ngOnInit () {
this.appId = this.route.snapshot.paramMap.get('appId') as string
this.cleanup(
// new version always includes dependencies, but not vice versa
this.$newVersionLoading$.subscribe(this.$dependenciesLoading$),
markAsLoadingDuring$(this.$loading$,
from(this.apiService.getAvailableApp(this.appId)).pipe(
tap(app => this.$app$ = initPropertySubject(app)),
concatMap(() => this.fetchRecommendation()),
),
).pipe(
concatMap(() => this.syncWhenDependencyInstalls()), //must be final in stack
catchError(e => of(this.setError(e))),
).subscribe(),
)
this.pkgId = this.route.snapshot.paramMap.get('pkgId') as string
this.rec = history.state && history.state.installRec as Recommendation
this.getPkg()
}
ionViewDidEnter () {
markAsLoadingDuring$(this.$dependenciesLoading$, this.syncVersionSpecificInfo()).subscribe({
error: e => this.setError(e),
})
async getPkg (version?: string): Promise<void> {
this.loading = true
try {
this.pkg = await this.apiService.getAvailableShow({ id: this.pkgId, version })
} catch (e) {
console.error(e)
this.error = e.message
} finally {
this.loading = false
}
}
async presentPopover (information: string, ev: any) {
@@ -92,34 +75,17 @@ export class AppAvailableShowPage extends Cleanup {
return await popover.present()
}
syncVersionSpecificInfo (versionSpec?: string): Observable<any> {
if (!this.$app$.versionViewing) return of({ })
const specToFetch = versionSpec || `=${this.$app$.versionViewing.getValue()}`
return from(this.apiService.getAvailableAppVersionSpecificInfo(this.appId, specToFetch)).pipe(
tap(versionInfo => this.mergeInfo(versionInfo)),
)
}
private mergeInfo (versionSpecificInfo: AppAvailableVersionSpecificInfo) {
this.zone.run(() => {
Object.entries(versionSpecificInfo).forEach( ([k, v]) => {
if (!this.$app$[k]) this.$app$[k] = new BehaviorSubject(undefined)
if (v !== this.$app$[k].getValue()) this.$app$[k].next(v)
})
})
}
async presentAlertVersions () {
const app = peekProperties(this.$app$)
const alert = await this.alertCtrl.create({
header: 'Versions',
backdropDismiss: false,
inputs: app.versions.sort((a, b) => -1 * this.emver.compare(a, b)).map(v => {
return { name: v, // for CSS
inputs: this.pkg.versions.sort((a, b) => -1 * this.emver.compare(a, b)).map(v => {
return {
name: v, // for CSS
type: 'radio',
label: displayEmver(v), // appearance on screen
value: v, // literal SEM version value
checked: app.versionViewing === v,
checked: this.pkg.manifest.version === v,
}
}),
buttons: [
@@ -129,17 +95,7 @@ export class AppAvailableShowPage extends Cleanup {
}, {
text: 'Ok',
handler: (version: string) => {
const previousVersion = this.$app$.versionViewing.getValue()
this.$app$.versionViewing.next(version)
markAsLoadingDuring$(
this.$newVersionLoading$, this.syncVersionSpecificInfo(`=${version}`),
)
.subscribe({
error: e => {
this.setError(e)
this.$app$.versionViewing.next(previousVersion)
},
})
this.getPkg(version)
},
},
],
@@ -149,15 +105,14 @@ export class AppAvailableShowPage extends Cleanup {
}
async install () {
const app = peekProperties(this.$app$)
const { id, title, version, dependencies, alerts } = this.pkg.manifest
const { cancelled } = await wizardModal(
this.modalCtrl,
this.wizardBaker.install({
id: app.id,
title: app.title,
version: app.versionViewing,
serviceRequirements: app.serviceRequirements,
installAlert: app.installAlert,
id,
title,
version,
installAlert: alerts.install,
}),
)
if (cancelled) return
@@ -166,14 +121,13 @@ export class AppAvailableShowPage extends Cleanup {
}
async update (action: 'update' | 'downgrade') {
const app = peekProperties(this.$app$)
const { id, title, version, dependencies, alerts } = this.pkg.manifest
const value = {
id: app.id,
title: app.title,
version: app.versionViewing,
serviceRequirements: app.serviceRequirements,
installAlert: app.installAlert,
id,
title,
version,
serviceRequirements: dependencies,
installAlert: alerts.install,
}
const { cancelled } = await wizardModal(
@@ -188,27 +142,7 @@ export class AppAvailableShowPage extends Cleanup {
this.navCtrl.back()
}
private fetchRecommendation (): Observable<any> {
this.recommendation = history.state && history.state.installationRecommendation
if (this.recommendation) {
return from(this.syncVersionSpecificInfo(this.recommendation.versionSpec))
} else {
return of({ })
}
}
private syncWhenDependencyInstalls (): Observable<void> {
return this.$app$.serviceRequirements.pipe(
filter(deps => !!deps),
switchMap(deps => this.appModel.watchForInstallations(deps)),
concatMap(() => markAsLoadingDuring$(this.$dependenciesLoading$, this.syncVersionSpecificInfo())),
catchError(e => of(console.error(e))),
)
}
private setError (e: Error) {
console.error(e)
this.$error$.next(e.message)
dismissRec () {
this.showRec = false
}
}

View File

@@ -10,8 +10,6 @@ import { AppConfigObjectPageModule } from 'src/app/modals/app-config-object/app-
import { AppConfigUnionPageModule } from 'src/app/modals/app-config-union/app-config-union.module'
import { AppConfigValuePageModule } from 'src/app/modals/app-config-value/app-config-value.module'
import { SharingModule } from 'src/app/modules/sharing.module'
import { PwaBackComponentModule } from 'src/app/components/pwa-back-button/pwa-back.component.module'
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
import { RecommendationButtonComponentModule } from 'src/app/components/recommendation-button/recommendation-button.component.module'
import { InformationPopoverComponentModule } from 'src/app/components/information-popover/information-popover.component.module'
@@ -19,7 +17,6 @@ const routes: Routes = [
{
path: '',
component: AppConfigPage,
// canDeactivate: [CanDeactivateGuard],
},
]
@@ -35,8 +32,6 @@ const routes: Routes = [
FormsModule,
IonicModule,
RouterModule.forChild(routes),
PwaBackComponentModule,
BadgeMenuComponentModule,
RecommendationButtonComponentModule,
InformationPopoverComponentModule,
],

View File

@@ -5,25 +5,27 @@
<ion-icon name="arrow-back"></ion-icon>
</ion-button>
</ion-buttons>
<ion-title>{{ app['title'] | async }}</ion-title>
<ion-title>{{ pkg.manifest.title }}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<!-- loading -->
<div *ngIf="$loading$ | async" class="full-page-spinner">
<ion-spinner style="justify-self: center; align-self: end;" name="lines" color="warning"></ion-spinner>
<ion-label style="justify-self: center;" *ngIf="($loadingText$ | async)" color="dark">
{{$loadingText$ | async}}
</ion-label>
</div>
<ion-grid *ngIf="loadingText$ | ngrxPush as loadingText; else loaded" style="height: 100%;">
<ion-row class="ion-align-items-center ion-text-center" style="height: 100%;">
<ion-col>
<ion-spinner name="lines" color="warning"></ion-spinner>
<p>{{ loadingText }}</p>
</ion-col>
</ion-row>
</ion-grid>
<!-- not loading -->
<ng-container *ngIf="!($loading$ | async)">
<ng-template #loaded>
<ion-item *ngIf="error" class="notifier-item">
<ion-label style="margin: 7px 5px;" class="ion-text-wrap">
<p style="color: var(--ion-color-danger)">{{error.text}}</p>
<p style="color: var(--ion-color-danger)">{{ error.text }}</p>
<p><a style="color: var(--ion-color-danger); text-decoration: underline; font-weight: bold;" *ngIf="error.moreInfo && !openErrorMoreInfo" (click)="openErrorMoreInfo = true">{{error.moreInfo.buttonText}}</a></p>
<ng-container *ngIf="openErrorMoreInfo">
@@ -33,44 +35,45 @@
</ng-container>
</ion-label>
<ion-button style="position: absolute; right: 0; top: 0" *ngIf="app && (app.id | async)" color="danger" fill="clear" (click)="dismissError()">
<ion-button style="position: absolute; right: 0; top: 0" *ngIf="pkg" color="danger" fill="clear" (click)="dismissError()">
<ion-icon name="close-outline"></ion-icon>
</ion-button>
</ion-item>
<ng-container *ngIf="app && (app.id | async)">
<ng-container *ngIf="([AppStatus.NEEDS_CONFIG] | includes: (app.status | async)) && !edited">
<ng-container *ngIf="pkg">
<!-- @TODO make sure this is how to determine if pkg is in needs_config -->
<ng-container *ngIf="pkg.manifest.config && !pkg.status.configured && !edited">
<ion-item class="notifier-item">
<ion-label class="ion-text-wrap">
<h2 style="display: flex; align-items: center; margin-bottom: 3px;">
<ion-icon size="small" style="margin-right: 5px" slot="start" color="dark" slot="start" name="alert-circle-outline"></ion-icon>
<ion-text style="font-size: smaller;">Initial Config</ion-text>
</h2>
<p style="font-size: small">To use the default config for {{ app.title | async }}, click "Save" below.</p>
<p style="font-size: small">To use the default config for {{ app.title | ngrxPush }}, click "Save" below.</p>
</ion-label>
</ion-item>
</ng-container>
<ng-container *ngIf="recommendation && showRecommendation">
<ion-item class="recommendation-item">
<ng-container *ngIf="rec && showRec">
<ion-item class="rec-item">
<ion-label class="ion-text-wrap">
<h2 style="display: flex; align-items: center;">
<ion-icon size="small" style="margin: 4px" slot="start" color="primary" slot="start" name="ellipse"></ion-icon>
<ion-avatar style="width: 3vh; height: 3vh; margin: 0px 2px 0px 5px;" slot="start">
<img [src]="recommendation.iconURL | iconParse" [alt]="recommendation.title"/>
<img [src]="rec.dependentIcon" [alt]="rec.dependentTitle"/>
</ion-avatar>
<ion-text style="margin: 5px; font-family: 'Montserrat'; font-size: smaller;">{{recommendation.title}}</ion-text>
<ion-text style="margin: 5px; font-family: 'Montserrat'; font-size: smaller;">{{ rec.dependentTitle }}</ion-text>
</h2>
<div style="margin: 7px 5px;">
<p style="font-size: small; color: var(--ion-color-medium)"> {{app.title | async}} config has been modified to satisfy {{recommendation.title}}.
<p style="font-size: small; color: var(--ion-color-medium)"> {{app.title | ngrxPush}} config has been modified to satisfy {{ rec.dependentTitle }}.
<ion-text color="dark">To accept the changes, click “Save” below.</ion-text>
</p>
<a style="font-size: small" *ngIf="!openRecommendation" (click)="openRecommendation = true">More Info</a>
<ng-container *ngIf="openRecommendation">
<p style="margin-top: 10px; color: var(--ion-color-medium); font-size: small" [innerHTML]="recommendation.description"></p>
<a style="font-size: x-small; font-style: italic;" (click)="openRecommendation = false">hide</a>
<a style="font-size: small" *ngIf="!openRec" (click)="openRec = true">More Info</a>
<ng-container *ngIf="openRec">
<p style="margin-top: 10px; color: var(--ion-color-medium); font-size: small" [innerHTML]="rec.description"></p>
<a style="font-size: x-small; font-style: italic;" (click)="openRec = false">hide</a>
</ng-container>
<ion-button style="position: absolute; right: 0; top: 0" color="primary" fill="clear" (click)="dismissRecommendation()">
<ion-button style="position: absolute; right: 0; top: 0" color="primary" fill="clear" (click)="dismissRec()">
<ion-icon name="close-outline"></ion-icon>
</ion-button>
</div>
@@ -89,18 +92,18 @@
<!-- no config -->
<ion-item *ngIf="!hasConfig">
<ion-label class="ion-text-wrap">
<p>No config options for {{ app.title | async }} {{ app.versionInstalled | async }}.</p>
<p>No config options for {{ app.title | ngrxPush }} {{ app.versionInstalled | ngrxPush }}.</p>
</ion-label>
</ion-item>
<!-- save button, always show -->
<ion-button
[disabled]="invalid || (!edited && !added && !(['NEEDS_CONFIG'] | includes: (app.status | async)))"
[disabled]="invalid || (!edited && !added && !(['NEEDS_CONFIG'] | includes: (app.status | ngrxPush)))"
fill="outline"
expand="block"
style="margin: 10px"
color="primary"
(click)="save()"
(click)="save(pkg)"
>
<ion-text color="primary" style="font-weight: bold">
Save
@@ -115,5 +118,5 @@
</ion-item-group>
</ng-container>
</ng-container>
</ng-container>
</ng-template>
</ion-content>

View File

@@ -1,80 +1,73 @@
import { Component } from '@angular/core'
import { NavController, AlertController, ModalController, PopoverController } from '@ionic/angular'
import { ActivatedRoute } from '@angular/router'
import { AppModel, AppStatus } from 'src/app/models/app-model'
import { AppInstalledFull } from 'src/app/models/app-types'
import { ApiService } from 'src/app/services/api/api.service'
import { pauseFor, isEmptyObject, modulateTime } from 'src/app/util/misc.util'
import { PropertySubject, peekProperties } from 'src/app/util/property-subject.util'
import { LoaderService, markAsLoadingDuring$ } from 'src/app/services/loader.service'
import { isEmptyObject } from 'src/app/util/misc.util'
import { LoaderService } from 'src/app/services/loader.service'
import { TrackingModalController } from 'src/app/services/tracking-modal-controller.service'
import { ModelPreload } from 'src/app/models/model-preload'
import { BehaviorSubject, forkJoin, from, fromEvent, of } from 'rxjs'
import { BehaviorSubject, from, fromEvent, of, Subscription } from 'rxjs'
import { catchError, concatMap, map, take, tap } from 'rxjs/operators'
import { Recommendation } from 'src/app/components/recommendation-button/recommendation-button.component'
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
import { Cleanup } from 'src/app/util/cleanup'
import { InformationPopoverComponent } from 'src/app/components/information-popover/information-popover.component'
import { ConfigSpec } from 'src/app/app-config/config-types'
import { ConfigCursor } from 'src/app/app-config/config-cursor'
import { ConfigSpec } from 'src/app/pkg-config/config-types'
import { ConfigCursor } from 'src/app/pkg-config/config-cursor'
import { InstalledPackageDataEntry } from 'src/app/models/patch-db/data-model'
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
@Component({
selector: 'app-config',
templateUrl: './app-config.page.html',
styleUrls: ['./app-config.page.scss'],
})
export class AppConfigPage extends Cleanup {
export class AppConfigPage {
error: { text: string, moreInfo?:
{ title: string, description: string, buttonText: string }
}
invalid: string
$loading$ = new BehaviorSubject(true)
$loadingText$ = new BehaviorSubject(undefined)
loadingText$ = new BehaviorSubject(undefined)
app: PropertySubject<AppInstalledFull> = { } as any
appId: string
pkg: InstalledPackageDataEntry
hasConfig = false
recommendation: Recommendation | null = null
showRecommendation = true
openRecommendation = false
backButtonDefense = false
rec: Recommendation | null = null
showRec = true
openRec = false
invalid: string
edited: boolean
added: boolean
rootCursor: ConfigCursor<'object'>
spec: ConfigSpec
config: object
AppStatus = AppStatus
subs: Subscription[]
constructor (
private readonly navCtrl: NavController,
private readonly route: ActivatedRoute,
private readonly wizardBaker: WizardBaker,
private readonly preload: ModelPreload,
private readonly apiService: ApiService,
private readonly loader: LoaderService,
private readonly alertCtrl: AlertController,
private readonly modalController: ModalController,
private readonly trackingModalCtrl: TrackingModalController,
private readonly popoverController: PopoverController,
private readonly appModel: AppModel,
) { super() }
backButtonDefense = false
private readonly patch: PatchDbModel,
) { }
async ngOnInit () {
this.appId = this.route.snapshot.paramMap.get('appId') as string
const pkgId = this.route.snapshot.paramMap.get('pkgId') as string
this.route.params.pipe(take(1)).subscribe(params => {
if (params.edit) {
window.history.back()
}
})
this.cleanup(
this.subs = [
this.route.params.pipe(take(1)).subscribe(params => {
if (params.edit) {
window.history.back()
}
}),
fromEvent(window, 'popstate').subscribe(() => {
this.backButtonDefense = false
this.trackingModalCtrl.dismissAll()
@@ -90,49 +83,51 @@ export class AppConfigPage extends Cleanup {
this.navCtrl.back()
}
}),
)
]
markAsLoadingDuring$(this.$loading$,
from(this.preload.appFull(this.appId))
.pipe(
tap(app => this.app = app),
tap(() => this.$loadingText$.next(`Fetching config spec...`)),
concatMap(() => forkJoin([this.apiService.getAppConfig(this.appId), pauseFor(600)])),
concatMap(([{ spec, config }]) => {
const rec = history.state && history.state.configRecommendation as Recommendation
if (rec) {
this.$loadingText$.next(`Setting properties to accomodate ${rec.title}...`)
return from(this.apiService.postConfigureDependency(this.appId, rec.appId, true))
.pipe(
map(res => ({
spec,
config,
dependencyConfig: res.config,
})),
tap(() => this.recommendation = rec),
catchError(e => {
this.error = { text: `Could not set properties to accomodate ${rec.title}: ${e.message}`, moreInfo: {
title: `${rec.title} requires the following:`,
description: rec.description,
buttonText: 'Configure Manually',
} }
return of({ spec, config, dependencyConfig: null })
}),
)
} else {
this.patch.watch$('package-data', pkgId, 'installed')
.pipe(
tap(pkg => this.pkg = pkg),
tap(() => this.loadingText$.next(`Fetching config spec...`)),
concatMap(() => this.apiService.getPackageConfig({ id: pkgId })),
concatMap(({ spec, config }) => {
const rec = history.state && history.state.configRecommendation as Recommendation
if (rec) {
this.loadingText$.next(`Setting properties to accommodate ${rec.dependentTitle}...`)
return from(this.apiService.dryConfigureDependency({ 'dependency-id': pkgId, 'dependent-id': rec.dependentId }))
.pipe(
map(res => ({
spec,
config,
dependencyConfig: res,
})),
tap(() => this.rec = rec),
catchError(e => {
this.error = { text: `Could not set properties to accommodate ${rec.dependentTitle}: ${e.message}`, moreInfo: {
title: `${rec.dependentTitle} requires the following:`,
description: rec.description,
buttonText: 'Configure Manually',
} }
return of({ spec, config, dependencyConfig: null })
}
}),
map(({ spec, config, dependencyConfig }) => this.setConfig(spec, config, dependencyConfig)),
tap(() => this.$loadingText$.next(undefined)),
),
}),
)
} else {
return of({ spec, config, dependencyConfig: null })
}
}),
map(({ spec, config, dependencyConfig }) => this.setConfig(spec, config, dependencyConfig)),
tap(() => this.loadingText$.next(undefined)),
take(1),
).subscribe({
error: e => {
console.error(e)
this.error = { text: e.message }
},
error: e => {
console.error(e.message)
this.error = { text: e.message }
},
)
})
}
ngOnDestroy () {
this.subs.forEach(sub => sub.unsubscribe())
}
async presentPopover (title: string, description: string, ev: any) {
@@ -165,8 +160,8 @@ export class AppConfigPage extends Cleanup {
this.hasConfig = !isEmptyObject(this.spec)
}
dismissRecommendation () {
this.showRecommendation = false
dismissRec () {
this.showRec = false
}
dismissError () {
@@ -181,38 +176,30 @@ export class AppConfigPage extends Cleanup {
}
}
async save () {
const app = peekProperties(this.app)
const ogAppStatus = app.status
async save (pkg: InstalledPackageDataEntry) {
return this.loader.of({
message: `Saving config...`,
spinner: 'lines',
cssClass: 'loader',
}).displayDuringAsync(async () => {
const config = this.config
const { breakages } = await this.apiService.patchAppConfig(app, config, true)
const { breakages } = await this.apiService.drySetPackageConfig({ id: pkg.manifest.id, config: this.config })
if (breakages.length) {
const { cancelled } = await wizardModal(
this.modalController,
this.wizardBaker.configure({
app,
pkg,
breakages,
}),
)
if (cancelled) return { skip: true }
}
return this.apiService.patchAppConfig(app, config).then(
() => this.preload.loadInstalledApp(this.appId).then(() => ({ skip: false })),
)
return this.apiService.setPackageConfig({ id: pkg.manifest.id, config: this.config })
.then(() => ({ skip: false }))
})
.then(({ skip }) => {
if (skip) return
if (ogAppStatus === AppStatus.RUNNING) {
this.appModel.update({ id: this.appId, status: AppStatus.RESTARTING }, modulateTime(new Date(), 3, 'seconds'))
}
this.navCtrl.back()
})
.catch(e => this.error = { text: e.message })

View File

@@ -1,15 +1,10 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { DependencyListComponentModule } from 'src/app/components/dependency-list/dependency-list.component.module'
import { AppInstalledListPage } from './app-installed-list.page'
import { StatusComponentModule } from 'src/app/components/status/status.component.module'
import { SharingModule } from 'src/app/modules/sharing.module'
import { AppBackupPageModule } from 'src/app/modals/app-backup/app-backup.module'
import { PwaBackComponentModule } from 'src/app/components/pwa-back-button/pwa-back.component.module'
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
const routes: Routes = [
@@ -23,14 +18,13 @@ const routes: Routes = [
imports: [
CommonModule,
StatusComponentModule,
DependencyListComponentModule,
AppBackupPageModule,
SharingModule,
IonicModule,
RouterModule.forChild(routes),
PwaBackComponentModule,
BadgeMenuComponentModule,
],
declarations: [AppInstalledListPage],
declarations: [
AppInstalledListPage,
],
})
export class AppInstalledListPageModule { }

View File

@@ -8,52 +8,9 @@
</ion-header>
<ion-content style="position: relative">
<ion-spinner *ngIf="$loading$ | async" class="center" name="lines" color="warning"></ion-spinner>
<ng-container *ngrxLet="patch.watch$('package-data') as pkgs">
<ng-container *ngIf="!($loading$ | async)">
<ion-refresher slot="fixed" (ionRefresh)="doRefresh($event)">
<ion-refresher-content pullingIcon="lines" refreshingSpinner="lines"></ion-refresher-content>
</ion-refresher>
<ion-item *ngIf="error">
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
</ion-item>
<ion-grid>
<ion-row>
<ion-col *ngFor="let app of apps" sizeXs="4" sizeSm="3" sizeMd="2" sizeLg="2">
<ng-container *ngIf="{
status: app.subject.status | async,
hasUI: app.subject.hasUI | async,
launchable: app.subject.launchable | async,
iconURL: app.subject.iconURL | async | iconParse,
title: app.subject.title | async
} as vars">
<ion-card class="installed-card" style="position:relative" [routerLink]="['/services', 'installed', app.id]">
<div class="launch-container" *ngIf="vars.hasUI">
<div class="launch-button-triangle" (click)="launchUiTab(app.id, $event)" [class.disabled]="!vars.launchable">
<ion-icon name="rocket-outline"></ion-icon>
</div>
</div>
<img style="position: absolute" class="main-img" [src]="vars.iconURL" [alt]="vars.title" />
<img class="main-img" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=">
<img class="bulb-on" *ngIf="vars.status | displayBulb: 'green'" src="assets/img/running-bulb.png"/>
<img class="bulb-on" *ngIf="vars.status | displayBulb: 'red'" src="assets/img/issue-bulb.png"/>
<img class="bulb-on" *ngIf="vars.status | displayBulb: 'yellow'" src="assets/img/warning-bulb.png"/>
<img class="bulb-off" *ngIf="vars.status | displayBulb: 'off'" src="assets/img/off-bulb.png"/>
<ion-card-header>
<status [appStatus]="vars.status" size="small"></status>
<p>{{ vars.title }}</p>
</ion-card-header>
</ion-card>
</ng-container>
</ion-col>
</ion-row>
</ion-grid>
<div *ngIf="!apps || !apps.length" class="ion-text-center ion-padding">
<div *ngIf="pkgs | empty; else list" class="ion-text-center ion-padding">
<div style="display: flex; flex-direction: column; justify-content: center; height: 40vh">
<h2>Welcome to your <span style="font-style: italic; color: var(--ion-color-start9)">Embassy</span></h2>
<p class="ion-text-wrap">Get started by installing your first service.</p>
@@ -63,5 +20,32 @@
Marketplace
</ion-button>
</div>
<ng-template #list>
<ion-grid>
<ion-row *ngrxLet="connectionService.monitor$() as connection">
<ion-col *ngFor="let pkg of pkgs | keyvalue : asIsOrder" sizeXs="4" sizeSm="3" sizeMd="2" sizeLg="2">
<ion-card class="installed-card" style="position:relative" [routerLink]="['/services', 'installed', (pkg.value | manifest).id]">
<div class="launch-container" *ngIf="pkg.value | hasUi">
<div class="launch-button-triangle" (click)="launchUi(pkg.value, $event)" [class.disabled]="!(pkg.value | isLaunchable)">
<ion-icon name="rocket-outline"></ion-icon>
</div>
</div>
<img style="position: absolute" class="main-img" [src]="pkg.value['static-files'].icon" [alt]="icon" />
<img class="main-img" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=">
<img class="bulb-on" *ngIf="pkg.value | displayBulb: 'green' : connection" src="assets/img/running-bulb.png"/>
<img class="bulb-on" *ngIf="pkg.value | displayBulb: 'red' : connection" src="assets/img/issue-bulb.png"/>
<img class="bulb-on" *ngIf="pkg.value | displayBulb: 'yellow' : connection" src="assets/img/warning-bulb.png"/>
<img class="bulb-off" *ngIf="pkg.value | displayBulb: 'off' : connection" src="assets/img/off-bulb.png"/>
<ion-card-header>
<status [pkg]="pkg.value" [connection]="connection" size="small"></status>
<p>{{ (pkg.value | manifest).title }}</p>
</ion-card-header>
</ion-card>
</ion-col>
</ion-row>
</ion-grid>
</ng-template>
</ng-container>
</ion-content>

View File

@@ -1,126 +1,29 @@
import { Component } from '@angular/core'
import { AppModel, AppStatus } from 'src/app/models/app-model'
import { AppInstalledPreview } from 'src/app/models/app-types'
import { ModelPreload } from 'src/app/models/model-preload'
import { doForAtLeast } from 'src/app/util/misc.util'
import { PropertySubject, PropertySubjectId, toObservable } from 'src/app/util/property-subject.util'
import { markAsLoadingDuring$ } from 'src/app/services/loader.service'
import { BehaviorSubject, Observable, Subscription } from 'rxjs'
import { S9Server, ServerModel, ServerStatus } from 'src/app/models/server-model'
import { SyncDaemon } from 'src/app/services/sync.service'
import { Cleanup } from 'src/app/util/cleanup'
import { ConfigService } from 'src/app/services/config.service'
import { ConnectionService } from 'src/app/services/connection.service'
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
import { PackageDataEntry } from 'src/app/models/patch-db/data-model'
@Component({
selector: 'app-installed-list',
templateUrl: './app-installed-list.page.html',
styleUrls: ['./app-installed-list.page.scss'],
})
export class AppInstalledListPage extends Cleanup {
error = ''
initError = ''
$loading$ = new BehaviorSubject(true)
s9Host$: Observable<string>
AppStatus = AppStatus
server: PropertySubject<S9Server>
currentServer: S9Server
apps: PropertySubjectId<AppInstalledPreview>[] = []
subsToTearDown: Subscription[] = []
updatingFreeze = false
updating = false
segmentValue: 'services' | 'embassy' = 'services'
showCertDownload : boolean
export class AppInstalledListPage {
constructor (
private readonly serverModel: ServerModel,
private readonly appModel: AppModel,
private readonly preload: ModelPreload,
private readonly syncDaemon: SyncDaemon,
private readonly config: ConfigService,
) {
super()
}
public readonly connectionService: ConnectionService,
public readonly patch: PatchDbModel,
) { }
ngOnDestroy () {
this.subsToTearDown.forEach(s => s.unsubscribe())
}
async ngOnInit () {
this.server = this.serverModel.watch()
this.apps = []
this.cleanup(
// serverUpdateSubscription
this.server.status.subscribe(status => {
if (status === ServerStatus.UPDATING) {
this.updating = true
} else {
if (!this.updatingFreeze) { this.updating = false }
}
}),
// newAppsSubscription
this.appModel.watchDelta('add').subscribe(({ id }) => {
if (this.apps.find(a => a.id === id)) return
this.apps.push({ id, subject: this.appModel.watch(id) })
},
),
// appsDeletedSubscription
this.appModel.watchDelta('delete').subscribe(({ id }) => {
const i = this.apps.findIndex(a => a.id === id)
this.apps.splice(i, 1)
}),
// currentServerSubscription
toObservable(this.server).subscribe(currentServerProperties => {
this.currentServer = currentServerProperties
}),
)
markAsLoadingDuring$(this.$loading$, this.preload.apps()).subscribe({
next: apps => {
this.apps = apps
},
error: e => {
console.error(e)
this.error = e.message
},
})
}
async launchUiTab (id: string, event: Event) {
launchUi (pkg: PackageDataEntry, event: Event): void {
event.preventDefault()
event.stopPropagation()
const app = this.apps.find(app => app.id === id).subject
let uiAddress: string
if (this.config.isTor()) {
uiAddress = `http://${app.torAddress.getValue()}`
} else {
uiAddress = `https://${app.lanAddress.getValue()}`
}
return window.open(uiAddress, '_blank')
window.open(this.config.launchableURL(pkg.installed), '_blank')
}
async doRefresh (event: any) {
await doForAtLeast([this.getServerAndApps()], 600)
event.target.complete()
}
async getServerAndApps (): Promise<void> {
try {
await this.syncDaemon.sync()
this.error = ''
} catch (e) {
console.error(e)
this.error = e.message
}
asIsOrder () {
return 0
}
}

View File

@@ -1,18 +1,13 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { DependencyListComponentModule } from 'src/app/components/dependency-list/dependency-list.component.module'
import { AppInstalledShowPage } from './app-installed-show.page'
import { StatusComponentModule } from 'src/app/components/status/status.component.module'
import { SharingModule } from 'src/app/modules/sharing.module'
import { AppBackupPageModule } from 'src/app/modals/app-backup/app-backup.module'
import { PwaBackComponentModule } from 'src/app/components/pwa-back-button/pwa-back.component.module'
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
import { InstallWizardComponentModule } from 'src/app/components/install-wizard/install-wizard.component.module'
import { ErrorMessageComponentModule } from 'src/app/components/error-message/error-message.component.module'
import { InformationPopoverComponentModule } from 'src/app/components/information-popover/information-popover.component.module'
const routes: Routes = [
@@ -26,15 +21,12 @@ const routes: Routes = [
imports: [
CommonModule,
StatusComponentModule,
DependencyListComponentModule,
AppBackupPageModule,
SharingModule,
IonicModule,
RouterModule.forChild(routes),
PwaBackComponentModule,
BadgeMenuComponentModule,
InstallWizardComponentModule,
ErrorMessageComponentModule,
InformationPopoverComponentModule,
],
declarations: [AppInstalledShowPage],

View File

@@ -10,182 +10,173 @@
</ion-toolbar>
</ion-header>
<ion-content *ngIf="{
id: app.id | async,
torAddress: app.torAddress | async,
status: app.status | async,
versionInstalled: app.versionInstalled | async,
licenseName: app.licenseName | async,
licenseLink: app.licenseLink | async,
configuredRequirements: app.configuredRequirements | async,
lastBackup: app.lastBackup | async,
hasFetchedFull: app.hasFetchedFull | async,
iconURL: app.iconURL | async,
title: app.title | async,
hasUI: app.hasUI | async,
launchable: app.launchable | async,
lanAddress: app.lanAddress | async
} as vars" class="ion-padding-bottom">
<ion-spinner *ngIf="$loading$ | async" class="center" name="lines" color="warning"></ion-spinner>
<ng-container *ngIf="!($loading$ | async)">
<ion-refresher *ngIf="app && app.id" slot="fixed" (ionRefresh)="doRefresh($event)">
<ion-refresher-content pullingIcon="lines" refreshingSpinner="lines"></ion-refresher-content>
</ion-refresher>
<ion-content class="ion-padding-bottom">
<error-message [$error$]="$error$" [dismissable]="!!(app && app.id)"></error-message>
<ion-item *ngIf="error" style="margin-bottom: 16px;">
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
</ion-item>
<div class="top-plate" *ngIf="app && app.id">
<ion-item class="no-cushion-item" lines="none">
<ion-label class="ion-text-wrap" style="
display: grid;
grid-template-columns: 80px auto;
margin: 0px;
margin-top: 15px;"
>
<ion-avatar style="justify-self: center; height: 55px; width: 55px" slot="start">
<img [src]="vars.iconURL | iconParse" />
</ion-avatar>
<div style="display: flex; flex-direction: column;">
<ion-text style="font-family: 'Montserrat'; font-size: x-large; line-height: normal;" [class.less-large]="vars.title.length > 20">
{{ vars.title }}
</ion-text>
<ion-text style="margin-top: -5px; margin-left: 2px;">
{{ vars.versionInstalled | displayEmver }}
</ion-text>
</div>
</ion-label>
</ion-item>
<ion-item class="no-cushion-item" lines=none style="margin-bottom: 10px;">
<ion-label class="status-readout">
<status size="bold-large" [appStatus]="vars.status"></status>
<ion-button *ngIf="vars.status === AppStatus.NEEDS_CONFIG" expand="block" fill="outline" [routerLink]="['config']">
Configure
</ion-button>
<ion-button *ngIf="[AppStatus.RUNNING, AppStatus.CRASHED, AppStatus.PAUSED, AppStatus.RESTARTING] | includes: vars.status" expand="block" fill="outline" color="danger" (click)="stop()">
Stop
</ion-button>
<ion-button *ngIf="vars.status === AppStatus.CREATING_BACKUP" expand="block" fill="outline" (click)="presentAlertStopBackup()">
Stop Backup
</ion-button>
<ion-button *ngIf="vars.status === AppStatus.DEAD" expand="block" fill="outline" (click)="uninstall()">
Force Uninstall
</ion-button>
<ion-button *ngIf="vars.status === AppStatus.BROKEN_DEPENDENCIES" expand="block" fill="outline" (click)="scrollToRequirements()">
Fix
</ion-button>
<ion-button *ngIf="vars.status === AppStatus.STOPPED" expand="block" fill="outline" color="success" (click)="tryStart()">
Start
</ion-button>
</ion-label>
</ion-item>
<ion-button size="small" *ngIf="vars.hasUI" [disabled]="!vars.launchable" class="launch-button" expand="block" (click)="launchUiTab()">
Launch Web Interface
<ion-icon slot="end" name="rocket-outline"></ion-icon>
</ion-button>
</div>
<ng-container *ngIf="app && app.id && vars.status !== 'INSTALLING'">
<ion-item-group class="ion-padding-bottom">
<!-- addresses -->
<ion-item>
<ion-label class="ion-text-wrap">
<h2>Tor Address</h2>
<p>{{ vars.torAddress }}</p>
</ion-label>
<ion-button slot="end" fill="clear" (click)="copyTor()">
<ion-icon slot="icon-only" name="copy-outline" color="primary"></ion-icon>
</ion-button>
</ion-item>
<ion-item lines="none">
<ion-label class="ion-text-wrap">
<h2>LAN Address</h2>
<p *ngIf="!hideLAN">{{ vars.lanAddress }}</p>
<p *ngIf="hideLAN"><ion-text color="warning">No LAN address for {{ vars.title }} {{ vars.versionInstalled }}</ion-text></p>
</ion-label>
<ion-button *ngIf="!hideLAN" slot="end" fill="clear" (click)="copyLAN()">
<ion-icon slot="icon-only" name="copy-outline" color="primary"></ion-icon>
</ion-button>
</ion-item>
<!-- backups -->
<ion-item-divider></ion-item-divider>
<!-- create backup -->
<ion-item button [disabled]="[AppStatus.RESTORING_BACKUP, AppStatus.CREATING_BACKUP, AppStatus.INSTALLING, AppStatus.RESTARTING, AppStatus.STOPPING] | includes: vars.status" (click)="presentModalBackup('create')">
<ion-icon slot="start" name="save-outline" color="primary"></ion-icon>
<ion-label style="display: flex; flex-direction: column;">
<ion-text color="primary">Create Backup</ion-text>
<ion-text color="medium" style="font-size: x-small">
Last Backup: {{vars.lastBackup ? (vars.lastBackup | date: 'short') : 'never'}}
</ion-text>
</ion-label>
</ion-item>
<!-- restore backup -->
<ion-item lines="none" button [disabled]="[AppStatus.RESTORING_BACKUP, AppStatus.CREATING_BACKUP, AppStatus.INSTALLING, AppStatus.RESTARTING, AppStatus.STOPPING] | includes: vars.status" (click)="presentModalBackup('restore')">
<ion-icon slot="start" name="color-wand-outline" color="primary"></ion-icon>
<ion-label><ion-text color="primary">Restore from Backup</ion-text></ion-label>
</ion-item>
<ion-item-divider></ion-item-divider>
<!-- instructions -->
<ion-item [routerLink]="['instructions']">
<ion-icon slot="start" name="list-outline" color="primary"></ion-icon>
<ion-label><ion-text color="primary">Instructions</ion-text></ion-label>
</ion-item>
<!-- config -->
<ion-item [disabled]="[AppStatus.CREATING_BACKUP, AppStatus.RESTORING_BACKUP, AppStatus.INSTALLING, AppStatus.DEAD] | includes: vars.status" [routerLink]="['config']">
<ion-icon slot="start" name="construct-outline" color="primary"></ion-icon>
<ion-label><ion-text color="primary">Config</ion-text></ion-label>
</ion-item>
<!-- metrics -->
<ion-item [routerLink]="['metrics']">
<ion-icon slot="start" name="information-circle-outline" color="primary"></ion-icon>
<ion-label><ion-text color="primary">Properties</ion-text></ion-label>
</ion-item>
<!-- actions -->
<ion-item [routerLink]="['actions']">
<ion-icon slot="start" name="flash-outline" color="primary"></ion-icon>
<ion-label><ion-text color="primary">Actions</ion-text></ion-label>
</ion-item>
<!-- logs -->
<ion-item [disabled]="vars.status === AppStatus.INSTALLING" [routerLink]="['logs']">
<ion-icon slot="start" name="newspaper-outline" color="primary"></ion-icon>
<ion-label><ion-text color="primary">Logs</ion-text></ion-label>
</ion-item>
<!-- marketplace -->
<ion-item lines="none" [routerLink]="['/services', 'marketplace', appId]">
<ion-icon slot="start" name="storefront-outline" color="primary"></ion-icon>
<ion-label><ion-text color="primary">Marketplace Listing</ion-text></ion-label>
</ion-item>
<!-- license -->
<ion-item *ngIf="vars.licenseLink" lines="none" button [href]="vars.licenseLink" target="_blank">
<ion-icon slot="start" name="newspaper-outline" color="primary"></ion-icon>
<ion-label><ion-text color="primary">License</ion-text></ion-label>
<ion-note slot="end">{{ vars.licenseName }}</ion-note>
</ion-item>
<!-- dependencies -->
<ng-container *ngIf="vars.configuredRequirements && vars.configuredRequirements.length">
<ion-item-divider [id]="'service-requirements-' + vars.id">Dependencies
<ion-button style="position: relative; right: 10px;" size="small" fill="clear" color="medium" (click)="presentPopover(dependencyDefintion(), $event)">
<ion-icon name="help-circle-outline"></ion-icon>
</ion-button>
</ion-item-divider>
<dependency-list [$loading$]="$loadingDependencies$" depType="installed" [hostApp]="app | peekProperties" [dependencies]="vars.configuredRequirements"></dependency-list>
</ng-container>
<ion-item-divider></ion-item-divider>
<ng-container *ngIf="vars.status !== AppStatus.INSTALLING && vars.status !== 'CREATING_BACKUP'">
<!-- uninstall -->
<ion-item style="--background: transparent" button (click)="uninstall()">
<ion-icon slot="start" name="trash-outline" color="medium"></ion-icon>
<ion-label><ion-text color="danger">Uninstall</ion-text></ion-label>
<ng-container *ngrxLet="connectionService.monitor$() as connection">
<ng-container *ngIf="pkg | manifest as manifest">
<ng-container *ngIf="pkg | status : connection as status">
<div class="top-plate">
<ion-item class="no-cushion-item" lines="none">
<ion-label class="ion-text-wrap" style="
display: grid;
grid-template-columns: 80px auto;
margin: 0px;
margin-top: 15px;"
>
<ion-avatar style="justify-self: center; height: 55px; width: 55px" slot="start">
<img [src]="pkg['static-files'].icon" />
</ion-avatar>
<div style="display: flex; flex-direction: column;">
<ion-text style="font-family: 'Montserrat'; font-size: x-large; line-height: normal;" [class.less-large]="manifest.title.length > 20">
{{ manifest.title }}
</ion-text>
<ion-text style="margin-top: -5px; margin-left: 2px;">
{{ manifest.version | displayEmver }}
</ion-text>
</div>
</ion-label>
</ion-item>
<ion-item class="no-cushion-item" lines=none style="margin-bottom: 10px;">
<ion-label class="status-readout">
<status size="bold-large" [pkg]="pkg" [connection]="connection"></status>
<ion-button *ngIf="status === FeStatus.NeedsConfig" expand="block" fill="outline" [routerLink]="['config']">
Configure
</ion-button>
<ion-button *ngIf="status === FeStatus.Running" expand="block" fill="outline" color="danger" (click)="stop()">
Stop
</ion-button>
<ion-button *ngIf="status === FeStatus.DependencyIssue" expand="block" fill="outline" (click)="scrollToRequirements()">
Fix
</ion-button>
<ion-button *ngIf="status === FeStatus.Stopped" expand="block" fill="outline" color="success" (click)="tryStart()">
Start
</ion-button>
</ion-label>
</ion-item>
<ion-button size="small" *ngIf="pkg | hasUi" [disabled]="!(pkg | isLaunchable)" class="launch-button" expand="block" (click)="launchUiTab()">
Launch Web Interface
<ion-icon slot="end" name="rocket-outline"></ion-icon>
</ion-button>
</div>
<ng-container *ngIf="!([FeStatus.Installing, FeStatus.Updating, FeStatus.Removing] | includes : status)">
<ion-item-group class="ion-padding-bottom">
<!-- interfaces -->
<ion-item [routerLink]="['interfaces']">
<ion-icon slot="start" name="aperture-outline" color="primary"></ion-icon>
<ion-label><ion-text color="primary">Interfaces</ion-text></ion-label>
</ion-item>
<!-- instructions -->
<ion-item [routerLink]="['instructions']">
<ion-icon slot="start" name="list-outline" color="primary"></ion-icon>
<ion-label><ion-text color="primary">Instructions</ion-text></ion-label>
</ion-item>
<!-- config -->
<ion-item [disabled]="[FeStatus.Installing, FeStatus.Updating, FeStatus.Removing, FeStatus.BackingUp, FeStatus.Restoring] | includes : status" [routerLink]="['config']">
<ion-icon slot="start" name="construct-outline" color="primary"></ion-icon>
<ion-label><ion-text color="primary">Config</ion-text></ion-label>
</ion-item>
<!-- properties -->
<ion-item [routerLink]="['properties']">
<ion-icon slot="start" name="information-circle-outline" color="primary"></ion-icon>
<ion-label><ion-text color="primary">Properties</ion-text></ion-label>
</ion-item>
<!-- actions -->
<ion-item [routerLink]="['actions']">
<ion-icon slot="start" name="flash-outline" color="primary"></ion-icon>
<ion-label><ion-text color="primary">Actions</ion-text></ion-label>
</ion-item>
<!-- logs -->
<ion-item [routerLink]="['logs']">
<ion-icon slot="start" name="newspaper-outline" color="primary"></ion-icon>
<ion-label><ion-text color="primary">Logs</ion-text></ion-label>
</ion-item>
<!-- restore -->
<ion-item button [disabled]="[FeStatus.Connecting, FeStatus.Installing, FeStatus.Updating, FeStatus.Stopping, FeStatus.Removing, FeStatus.BackingUp, FeStatus.Restoring] | includes : status" [routerLink]="['restore']">
<ion-icon slot="start" name="color-wand-outline" color="primary"></ion-icon>
<ion-label><ion-text color="primary">Restore from Backup</ion-text></ion-label>
</ion-item>
<!-- donate -->
<ion-item button [href]="manifest['donation-url']" target="_blank">
<ion-icon slot="start" name="shapes-outline" color="primary"></ion-icon>
<ion-label><ion-text color="primary">Donate</ion-text></ion-label>
</ion-item>
<!-- marketplace -->
<ion-item [routerLink]="['/services', 'marketplace', manifest.id]">
<ion-icon slot="start" name="storefront-outline" color="primary"></ion-icon>
<ion-label><ion-text color="primary">Marketplace Listing</ion-text></ion-label>
</ion-item>
<!-- dependencies -->
<ng-container *ngIf="!(manifest.dependencies | empty)">
<ion-item-divider id="dependencies">
Dependencies
<ion-button style="position: relative; right: 10px;" size="small" fill="clear" color="medium" (click)="presentPopover(depDefinition, $event)">
<ion-icon name="help-circle-outline"></ion-icon>
</ion-button>
</ion-item-divider>
<div *ngFor="let dep of pkg.installed['current-dependencies'] | keyvalue">
<ion-item *ngrxLet="patch.watch$('package-data', dep.key) as localDep" class="dependency-item" lines="none">
<ion-avatar slot="start" style="position: relative; height: 5vh; width: 5vh; margin: 0px;">
<div class="dep-badge" [class]="pkg.installed.status['dependency-errors'][dep.key] ? 'dep-issue' : 'dep-sat'"></div>
<img [src]="localDep ? localDep['static-files'].icon : pkg.installed.status['dependency-errors'][dep.key]?.icon" />
</ion-avatar>
<ion-label class="ion-text-wrap" style="padding: 1vh; padding-left: 2vh">
<h4 style="font-family: 'Montserrat'">{{ localDep ? (localDep | manifest).title : pkg.installed.status['dependency-errors'][dep.key]?.title }}</h4>
<p style="font-size: small">{{ manifest.dependencies[dep.key].version | displayEmver }}</p>
<p style="padding-top: 2px; position: relative; font-style: italic; font-size: smaller"><ion-text [color]="pkg.installed.status['dependency-errors'][dep.key] ? 'warning' : 'success'">{{ pkg.installed.status['dependency-errors'][dep.key] ? pkg.installed.status['dependency-errors'][dep.key].type : 'satisfied' }}</ion-text></p>
</ion-label>
<ion-button *ngIf="!pkg.installed.status['dependency-errors'][dep.key] || (pkg.installed.status['dependency-errors'][dep.key] && [DependencyErrorType.InterfaceHealthChecksFailed, DependencyErrorType.HealthChecksFailed] | includes : pkg.installed.status['dependency-errors'][dep.key].type)" slot="end" size="small" [routerLink]="['/services', 'installed', dep.key]" color="primary" fill="outline" style="font-size: x-small">
View
</ion-button>
<ng-container *ngIf="pkg.installed.status['dependency-errors'][dep.key]">
<ion-button *ngIf="!localDep" slot="end" size="small" (click)="fixDep('install', dep.key)" color="primary" fill="outline" style="font-size: x-small">
Install
</ion-button>
<ng-container *ngIf="localDep && localDep.state === PackageState.Installed">
<ion-button *ngIf="pkg.installed.status['dependency-errors'][dep.key].type === DependencyErrorType.NotRunning" slot="end" size="small" [routerLink]="['/services', 'installed', dep.key]" color="primary" fill="outline" style="font-size: x-small">
Start
</ion-button>
<ion-button *ngIf="pkg.installed.status['dependency-errors'][dep.key].type === DependencyErrorType.IncorrectVersion" slot="end" size="small" (click)="fixDep('update', dep.key)" color="primary" fill="outline" style="font-size: x-small">
Update
</ion-button>
<ion-button *ngIf="pkg.installed.status['dependency-errors'][dep.key].type === DependencyErrorType.ConfigUnsatisfied" slot="end" size="small" (click)="fixDep('configure', dep.key)" color="primary" fill="outline" style="font-size: x-small">
Configure
</ion-button>
</ng-container>
<div *ngIf="localDep && localDep.state !== PackageState.Installed" slot="end" class="spinner">
<ion-spinner [color]="localDep.state === PackageState.Removing ? 'danger' : 'primary'" style="height: 3vh; width: 3vh" name="dots"></ion-spinner>
</div>
</ng-container>
</ion-item>
</div>
</ng-container>
<ion-item-divider></ion-item-divider>
<ng-container *ngIf="!([FeStatus.Installing, FeStatus.Updating, FeStatus.BackingUp, FeStatus.Restoring] | includes : status)">
<!-- uninstall -->
<ion-item button (click)="uninstall()">
<ion-icon slot="start" name="trash-outline" color="danger"></ion-icon>
<ion-label><ion-text color="danger">Uninstall</ion-text></ion-label>
</ion-item>
</ng-container>
</ion-item-group>
</ng-container>
</ion-item-group>
</ng-container>
</ng-container>
</ng-container>
</ion-content>

View File

@@ -48,3 +48,19 @@
--border-radius: 10px;
margin: 12px 10px;
}
.dep-badge {
position: absolute; width: 2.5vh;
height: 2.5vh;
border-radius: 50px;
left: -1vh;
top: -1vh;
}
.dep-issue {
background: radial-gradient(var(--ion-color-warning) 40%, transparent)
}
.dep-sat {
background: radial-gradient(var(--ion-color-success) 40%, transparent)
}

View File

@@ -1,41 +1,37 @@
import { Component, ViewChild } from '@angular/core'
import { AlertController, NavController, ToastController, ModalController, IonContent, PopoverController } from '@ionic/angular'
import { AlertController, NavController, ModalController, IonContent, PopoverController } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/api.service'
import { ActivatedRoute } from '@angular/router'
import { copyToClipboard } from 'src/app/util/web.util'
import { AppModel, AppStatus } from 'src/app/models/app-model'
import { AppInstalledFull } from 'src/app/models/app-types'
import { ModelPreload } from 'src/app/models/model-preload'
import { chill, pauseFor } from 'src/app/util/misc.util'
import { PropertySubject, peekProperties } from 'src/app/util/property-subject.util'
import { AppBackupPage } from 'src/app/modals/app-backup/app-backup.page'
import { LoaderService, markAsLoadingDuring$, markAsLoadingDuringP } from 'src/app/services/loader.service'
import { BehaviorSubject, Observable, of } from 'rxjs'
import { ActivatedRoute, NavigationExtras } from '@angular/router'
import { chill } from 'src/app/util/misc.util'
import { LoaderService } from 'src/app/services/loader.service'
import { Observable, of, Subscription } from 'rxjs'
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
import { catchError, concatMap, filter, switchMap, tap } from 'rxjs/operators'
import { Cleanup } from 'src/app/util/cleanup'
import { InformationPopoverComponent } from 'src/app/components/information-popover/information-popover.component'
import { ConfigService } from 'src/app/services/config.service'
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
import { DependencyErrorConfigUnsatisfied, DependencyErrorNotInstalled, DependencyErrorType, PackageDataEntry, PackageState } from 'src/app/models/patch-db/data-model'
import { FEStatus } from 'src/app/services/pkg-status-rendering.service'
import { ConnectionService } from 'src/app/services/connection.service'
import { Recommendation } from 'src/app/components/recommendation-button/recommendation-button.component'
@Component({
selector: 'app-installed-show',
templateUrl: './app-installed-show.page.html',
styleUrls: ['./app-installed-show.page.scss'],
})
export class AppInstalledShowPage extends Cleanup {
$loading$ = new BehaviorSubject(true)
$loadingDependencies$ = new BehaviorSubject(false) // when true, dependencies will render with spinners.
$error$ = new BehaviorSubject<string>('')
app: PropertySubject<AppInstalledFull> = { } as any
appId: string
AppStatus = AppStatus
showInstructions = false
export class AppInstalledShowPage {
error: string
pkgId: string
pkg: PackageDataEntry
pkgSub: Subscription
hideLAN: boolean
dependencyDefintion = () => `<span style="font-style: italic">Dependencies</span> are other services which must be installed, configured appropriately, and started in order to start ${this.app.title.getValue()}`
FeStatus = FEStatus
PackageState = PackageState
DependencyErrorType = DependencyErrorType
depDefinition = '<span style="font-style: italic">Service Dependencies</span> are other services that this service recommends or requires in order to run.'
@ViewChild(IonContent) content: IonContent
@@ -44,115 +40,44 @@ export class AppInstalledShowPage extends Cleanup {
private readonly route: ActivatedRoute,
private readonly navCtrl: NavController,
private readonly loader: LoaderService,
private readonly toastCtrl: ToastController,
private readonly modalCtrl: ModalController,
private readonly apiService: ApiService,
private readonly preload: ModelPreload,
private readonly wizardBaker: WizardBaker,
private readonly appModel: AppModel,
private readonly popoverController: PopoverController,
private readonly config: ConfigService,
) {
super()
}
public readonly patch: PatchDbModel,
public readonly connectionService: ConnectionService,
) { }
async ngOnInit () {
this.appId = this.route.snapshot.paramMap.get('appId') as string
this.cleanup(
markAsLoadingDuring$(this.$loading$, this.preload.appFull(this.appId))
.pipe(
tap(app => {
this.app = app
const appP = peekProperties(this.app)
this.hideLAN = !appP.lanAddress || (appP.id === 'mastodon' && appP.versionInstalled === '3.3.0') // @TODO delete this hack in 0.3.0
}),
concatMap(() => this.syncWhenDependencyInstalls()), // must be final in stack
catchError(e => of(this.setError(e))),
).subscribe(),
)
this.pkgId = this.route.snapshot.paramMap.get('pkgId')
this.pkgSub = this.patch.watch$('package-data', this.pkgId).subscribe(pkg => this.pkg = pkg)
}
ionViewDidEnter () {
markAsLoadingDuringP(this.$loadingDependencies$, this.getApp())
async ngOnDestroy () {
this.pkgSub.unsubscribe()
}
async doRefresh (event: any) {
await Promise.all([
this.getApp(),
pauseFor(600),
])
event.target.complete()
}
async scrollToRequirements () {
return this.scrollToElement('service-requirements-' + this.appId)
}
async getApp (): Promise<void> {
try {
await this.preload.loadInstalledApp(this.appId)
this.clearError()
} catch (e) {
this.setError(e)
}
}
async launchUiTab () {
let uiAddress: string
if (this.config.isTor()) {
uiAddress = `http://${this.app.torAddress.getValue()}`
} else {
uiAddress = `https://${this.app.lanAddress.getValue()}`
}
return window.open(uiAddress, '_blank')
}
async copyTor () {
const app = peekProperties(this.app)
let message = ''
await copyToClipboard(app.torAddress || '').then(success => { message = success ? 'copied to clipboard!' : 'failed to copy' })
const toast = await this.toastCtrl.create({
header: message,
position: 'bottom',
duration: 1000,
cssClass: 'notification-toast',
})
await toast.present()
}
async copyLAN () {
const app = peekProperties(this.app)
let message = ''
await copyToClipboard(app.lanAddress).then(success => { message = success ? 'copied to clipboard!' : 'failed to copy' })
const toast = await this.toastCtrl.create({
header: message,
position: 'bottom',
duration: 1000,
cssClass: 'notification-toast',
})
await toast.present()
launchUiTab (): void {
window.open(this.config.launchableURL(this.pkg.installed), '_blank')
}
async stop (): Promise<void> {
const app = peekProperties(this.app)
const { id, title, version } = this.pkg.installed.manifest
await this.loader.of({
message: `Stopping ${app.title}...`,
message: `Stopping...`,
spinner: 'lines',
cssClass: 'loader',
}).displayDuringAsync(async () => {
const { breakages } = await this.apiService.stopApp(this.appId, true)
const { breakages } = await this.apiService.dryStopPackage({ id })
if (breakages.length) {
const { cancelled } = await wizardModal(
this.modalCtrl,
this.wizardBaker.stop({
id: app.id,
title: app.title,
version: app.versionInstalled,
id,
title,
version,
breakages,
}),
)
@@ -160,76 +85,28 @@ export class AppInstalledShowPage extends Cleanup {
if (cancelled) return { }
}
return this.apiService.stopApp(this.appId).then(chill)
return this.apiService.stopPackage({ id }).then(chill)
}).catch(e => this.setError(e))
}
async tryStart (): Promise<void> {
const app = peekProperties(this.app)
if (app.startAlert) {
this.presentAlertStart(app)
const message = this.pkg.installed.manifest.alerts.start
if (message) {
this.presentAlertStart(message)
} else {
this.start(app)
this.start()
}
}
async presentModalBackup (type: 'create' | 'restore') {
const modal = await this.modalCtrl.create({
backdropDismiss: false,
component: AppBackupPage,
presentingElement: await this.modalCtrl.getTop(),
componentProps: {
app: peekProperties(this.app),
type,
},
})
await modal.present()
}
async presentAlertStopBackup (): Promise<void> {
const app = peekProperties(this.app)
const alert = await this.alertCtrl.create({
backdropDismiss: false,
header: 'Warning',
message: `${app.title} is not finished backing up. Are you sure you want stop the process?`,
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Stop',
cssClass: 'alert-danger',
handler: () => {
this.stopBackup()
},
},
],
})
await alert.present()
}
async stopBackup (): Promise<void> {
await this.loader.of({
message: `Stopping backup...`,
spinner: 'lines',
cssClass: 'loader',
}).displayDuringP(this.apiService.stopAppBackup(this.appId))
.catch (e => this.setError(e))
}
async uninstall () {
const app = peekProperties(this.app)
const { id, title, version, alerts } = this.pkg.installed.manifest
const data = await wizardModal(
this.modalCtrl,
this.wizardBaker.uninstall({
id: app.id,
title: app.title,
version: app.versionInstalled,
uninstallAlert: app.uninstallAlert,
id,
title,
version,
uninstallAlert: alerts.uninstall,
}),
)
@@ -251,10 +128,64 @@ export class AppInstalledShowPage extends Cleanup {
return await popover.present()
}
private async presentAlertStart (app: AppInstalledFull): Promise<void> {
scrollToRequirements () {
const el = document.getElementById('dependencies')
if (!el) return
let y = el.offsetTop
return this.content.scrollToPoint(0, y, 1000)
}
async fixDep (action: 'install' | 'update' | 'configure', id: string): Promise<void> {
switch (action) {
case 'install':
case 'update':
return this.installDep(id)
case 'configure':
return this.configureDep(id)
}
}
private async installDep (depId: string): Promise<void> {
const version = this.pkg.installed.manifest.dependencies[depId].version
const dependentTitle = this.pkg.installed.manifest.title
const installRec: Recommendation = {
dependentId: this.pkgId,
dependentTitle,
dependentIcon: this.pkg['static-files'].icon,
version,
description: `${dependentTitle} requires an install of ${(this.pkg.installed.status['dependency-errors'][depId] as DependencyErrorNotInstalled)?.title} satisfying ${version}.`,
}
const navigationExtras: NavigationExtras = {
state: { installRec },
}
await this.navCtrl.navigateForward(`/services/marketplace/${depId}`, navigationExtras)
}
private async configureDep (depId: string): Promise<void> {
const configErrors = (this.pkg.installed.status['dependency-errors'][depId] as DependencyErrorConfigUnsatisfied).errors
const description = `<ul>${configErrors.map(d => `<li>${d}</li>`).join('\n')}</ul>`
const dependentTitle = this.pkg.installed.manifest.title
const configRecommendation: Recommendation = {
dependentId: this.pkgId,
dependentTitle,
dependentIcon: this.pkg['static-files'].icon,
description,
}
const navigationExtras: NavigationExtras = {
state: { configRecommendation },
}
await this.navCtrl.navigateForward(`/services/installed/${depId}/config`, navigationExtras)
}
private async presentAlertStart (message: string): Promise<void> {
const alert = await this.alertCtrl.create({
header: 'Warning',
message: app.startAlert,
message,
buttons: [
{
text: 'Cancel',
@@ -263,7 +194,7 @@ export class AppInstalledShowPage extends Cleanup {
{
text: 'Start',
handler: () => {
this.start(app)
this.start()
},
},
],
@@ -271,40 +202,18 @@ export class AppInstalledShowPage extends Cleanup {
await alert.present()
}
private async start (app: AppInstalledFull): Promise<void> {
private async start (): Promise<void> {
this.loader.of({
message: `Starting ${app.title}...`,
message: `Starting...`,
spinner: 'lines',
cssClass: 'loader',
}).displayDuringP(
this.apiService.startApp(this.appId),
this.apiService.startPackage({ id: this.pkgId }),
).catch(e => this.setError(e))
}
private setError (e: Error): Observable<void> {
this.$error$.next(e.message)
this.error = e.message
return of()
}
private clearError () {
this.$error$.next('')
}
private async scrollToElement (elementId: string) {
const el = document.getElementById(elementId)
if (!el) return
let y = el.offsetTop
return this.content.scrollToPoint(0, y, 1000)
}
private syncWhenDependencyInstalls (): Observable<void> {
return this.app.configuredRequirements.pipe(
filter(deps => !!deps),
switchMap(reqs => this.appModel.watchForInstallations(reqs)),
concatMap(() => markAsLoadingDuringP(this.$loadingDependencies$, this.getApp())),
catchError(e => of(console.error(e))),
)
}
}

View File

@@ -21,6 +21,8 @@ const routes: Routes = [
PwaBackComponentModule,
SharingModule,
],
declarations: [AppInstructionsPage],
declarations: [
AppInstructionsPage,
],
})
export class AppInstructionsPageModule { }

View File

@@ -7,22 +7,14 @@
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<ion-content class="ion-padding">
<ion-spinner *ngIf="loading; else loaded" class="center" name="lines" color="warning"></ion-spinner>
<ion-spinner *ngIf="$loading$ | async" class="center" name="lines" color="warning"></ion-spinner>
<ng-container *ngIf="!($loading$ | async)">
<ion-item *ngIf="!app.instructions">
<ion-label class="ion-text-wrap">
<p>No instructions for {{ app.title }} {{ app.versionInstalled }}.</p>
</ion-label>
<ng-template #loaded>
<ion-item *ngIf="error" style="margin-bottom: 16px;">
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
</ion-item>
<div style="
padding-left: var(--ion-padding,16px);
padding-right: var(--ion-padding,16px);
padding-bottom: var(--ion-padding,16px);
" *ngIf="app.instructions" [innerHTML]="app.instructions | markdown"></div>
</ng-container>
<div *ngIf="instructions" class="instuctions-padding" [innerHTML]="instructions | markdown"></div>
</ng-template>
</ion-content>

View File

@@ -0,0 +1,3 @@
.instructions-padding {
padding: 0 16px 16px 16px
}

View File

@@ -1,10 +1,9 @@
import { Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { BehaviorSubject } from 'rxjs'
import { AppInstalledFull } from 'src/app/models/app-types'
import { ModelPreload } from 'src/app/models/model-preload'
import { markAsLoadingDuring$ } from 'src/app/services/loader.service'
import { peekProperties } from 'src/app/util/property-subject.util'
import { concatMap, take, tap } from 'rxjs/operators'
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
import { ApiService } from 'src/app/services/api/api.service'
import { Method } from 'src/app/services/http.service'
@Component({
selector: 'app-instructions',
@@ -12,25 +11,34 @@ import { peekProperties } from 'src/app/util/property-subject.util'
styleUrls: ['./app-instructions.page.scss'],
})
export class AppInstructionsPage {
$loading$ = new BehaviorSubject(true)
instructions: string
loading = true
error = ''
app: AppInstalledFull = { } as any
appId: string
constructor (
private readonly route: ActivatedRoute,
private readonly preload: ModelPreload,
private readonly apiService: ApiService,
private readonly patch: PatchDbModel,
) { }
async ngOnInit () {
this.appId = this.route.snapshot.paramMap.get('appId') as string
markAsLoadingDuring$(this.$loading$, this.preload.appFull(this.appId)).subscribe({
next: app => this.app = peekProperties(app),
error: e => {
console.error(e)
const pkgId = this.route.snapshot.paramMap.get('pkgId')
this.patch.watch$('package-data', pkgId)
.pipe(
concatMap(pkg => this.apiService.getStatic(pkg['static-files'].instructions)),
tap(instructions => {
console.log(instructions)
this.instructions = instructions
}),
take(1),
)
.subscribe(
() => { this.loading = false },
e => {
this.error = e.message
this.loading = false
},
})
() => console.log('COMPLETE'),
)
}
}

View File

@@ -0,0 +1,26 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppInterfacesPage } from './app-interfaces.page'
import { PwaBackComponentModule } from 'src/app/components/pwa-back-button/pwa-back.component.module'
import { SharingModule } from 'src/app/modules/sharing.module'
const routes: Routes = [
{
path: '',
component: AppInterfacesPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
PwaBackComponentModule,
SharingModule,
],
declarations: [AppInterfacesPage],
})
export class AppInterfacesPageModule { }

View File

@@ -0,0 +1,52 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<pwa-back-button></pwa-back-button>
</ion-buttons>
<ion-title>Interfaces</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<ng-container *ngIf="patch.watch$('package-data', pkgId) | ngrxPush as pkg">
<ion-card style="margin-bottom: 16px;" *ngFor="let interface of pkg.installed.manifest.interfaces | keyvalue: asIsOrder">
<ion-card-header>
<ion-card-title>
{{ interface.value.name }}
<ion-button class="vertical-align" *ngIf="interface.value.ui" [disabled]="!(pkg | isLaunchable)" fill="clear" (click)="launch(pkg.installed)">
<ion-icon slot="icon-only" name="rocket-outline" size="small"></ion-icon>
</ion-button>
</ion-card-title>
<ion-card-subtitle>{{ interface.value.description }}</ion-card-subtitle>
</ion-card-header>
<ion-card-content>
<ng-container *ngIf="pkg.installed['interface-info'].addresses[interface.key] as int">
<ion-item>
<ion-label class="ion-text-wrap">
<h2>Tor Address</h2>
<p>{{ 'http://' + int['tor-address'] }}</p>
</ion-label>
<ion-button slot="end" fill="clear" (click)="copy('http://' + int['tor-address'])">
<ion-icon size="small" slot="icon-only" name="copy-outline"></ion-icon>
</ion-button>
</ion-item>
<ion-item>
<ion-label class="ion-text-wrap">
<h2>LAN Address</h2>
<p>{{ 'https://' + int['lan-address'] }}</p>
</ion-label>
<ion-button slot="end" fill="clear" (click)="copy('https://' + int['lan-address'])">
<ion-icon size="small" slot="icon-only" name="copy-outline"></ion-icon>
</ion-button>
</ion-item>
<ion-item button detail="true">
<ion-label class="ion-text-wrap">
Advanced
</ion-label>
</ion-item>
</ng-container>
</ion-card-content>
</ion-card>
</ng-container>
</ion-content>

View File

@@ -0,0 +1,4 @@
.vertical-align {
display: inline-block;
vertical-align: middle;
}

View File

@@ -0,0 +1,49 @@
import { Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ToastController } from '@ionic/angular'
import { InstalledPackageDataEntry } from 'src/app/models/patch-db/data-model'
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
import { ConfigService } from 'src/app/services/config.service'
import { copyToClipboard } from 'src/app/util/web.util'
@Component({
selector: 'app-Interfaces',
templateUrl: './app-Interfaces.page.html',
styleUrls: ['./app-Interfaces.page.scss'],
})
export class AppInterfacesPage {
pkgId: string
constructor (
private readonly route: ActivatedRoute,
private readonly toastCtrl: ToastController,
private readonly config: ConfigService,
public readonly patch: PatchDbModel,
) { }
ngOnInit () {
this.pkgId = this.route.snapshot.paramMap.get('pkgId')
}
async copy (address: string): Promise<void> {
let message = ''
await copyToClipboard(address || '')
.then(success => { message = success ? 'copied to clipboard!' : 'failed to copy'})
const toast = await this.toastCtrl.create({
header: message,
position: 'bottom',
duration: 1000,
cssClass: 'notification-toast',
})
await toast.present()
}
launch (installed: InstalledPackageDataEntry): void {
window.open(this.config.launchableURL(installed), '_blank')
}
asIsOrder () {
return 0
}
}

View File

@@ -13,9 +13,12 @@
</ion-header>
<ion-content class="ion-padding" color="light">
<ion-item *ngIf="error" style="margin-bottom: 16px;">
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
</ion-item>
<ion-spinner *ngIf="$loading$ | async" class="center" name="lines" color="warning"></ion-spinner>
<ion-spinner *ngIf="!logs" class="center" name="lines" color="warning"></ion-spinner>
<p style="white-space: pre-line;">{{ logs }}</p>
</ion-content>

View File

@@ -2,9 +2,6 @@ import { Component, ViewChild } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ApiService } from 'src/app/services/api/api.service'
import { IonContent } from '@ionic/angular'
import { pauseFor } from 'src/app/util/misc.util'
import { markAsLoadingDuringP } from 'src/app/services/loader.service'
import { BehaviorSubject } from 'rxjs'
@Component({
selector: 'app-logs',
@@ -13,38 +10,29 @@ import { BehaviorSubject } from 'rxjs'
})
export class AppLogsPage {
@ViewChild(IonContent, { static: false }) private content: IonContent
$loading$ = new BehaviorSubject(true)
pkgId: string
logs = ''
error = ''
appId: string
logs: string
constructor (
private readonly route: ActivatedRoute,
private readonly apiService: ApiService,
) { }
async ngOnInit () {
this.appId = this.route.snapshot.paramMap.get('appId') as string
markAsLoadingDuringP(this.$loading$, Promise.all([
this.getLogs(),
pauseFor(600),
]))
ngOnInit () {
this.pkgId = this.route.snapshot.paramMap.get('pkgId')
this.getLogs()
}
async getLogs () {
this.logs = ''
this.$loading$.next(true)
try {
const logs = await this.apiService.getAppLogs(this.appId)
this.logs = logs.join('\n\n')
this.error = ''
const logs = await this.apiService.getPackageLogs({ id: this.pkgId })
this.logs = logs.map(l => `${l.timestamp} ${l.log}`).join('\n\n')
setTimeout(async () => await this.content.scrollToBottom(100), 200)
} catch (e) {
console.error(e)
this.error = e.message
} finally {
this.$loading$.next(false)
}
}
}

View File

@@ -1,74 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<pwa-back-button></pwa-back-button>
</ion-buttons>
<ion-title>Properties</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<ion-spinner *ngIf="$loading$ | async" class="center" name="lines" color="warning"></ion-spinner>
<ng-container *ngIf="!($loading$ | async)">
<ion-refresher slot="fixed" (ionRefresh)="doRefresh($event)">
<ion-refresher-content pullingIcon="lines" refreshingSpinner="lines"></ion-refresher-content>
</ion-refresher>
<ion-item *ngIf="error" style="margin-bottom: 16px;">
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
</ion-item>
<!-- not running -->
<ion-item *ngIf="app.status !== 'RUNNING'" class="ion-margin-bottom">
<ion-label class="ion-text-wrap">
<p><ion-text color="warning">{{ app.title }} is not running. Information on this page could be innacurate.</ion-text></p>
</ion-label>
</ion-item>
<!-- no metrics -->
<ion-item *ngIf="($hasMetrics$ | async) === false">
<ion-label class="ion-text-wrap">
<p>No properties for {{ app.title }} {{ app.versionInstalled }}.</p>
</ion-label>
</ion-item>
<!-- metrics -->
<ion-item-group *ngIf="($hasMetrics$ | async) === true">
<div *ngFor="let keyval of $metrics$ | async | keyvalue: asIsOrder">
<!-- object -->
<ion-item button detail="false" *ngIf="keyval.value.type === 'object'" (click)="goToNested(keyval.key)">
<ion-button *ngIf="keyval.value.description" class="help-button" fill="clear" slot="start" (click)="presentDescription(keyval, $event)">
<ion-icon size="small" slot="icon-only" name="help-circle-outline"></ion-icon>
</ion-button>
<ion-label class="ion-text-wrap">
<h2>{{ keyval.key }}</h2>
</ion-label>
<ion-icon slot="end" name="arrow-forward"></ion-icon>
</ion-item>
<!-- not object -->
<ion-item *ngIf="keyval.value.type === 'string'">
<ion-button *ngIf="keyval.value.description" class="help-button" fill="clear" slot="start" (click)="presentDescription(keyval, $event)">
<ion-icon size="small" slot="icon-only" name="help-circle-outline"></ion-icon>
</ion-button>
<ion-label class="ion-text-wrap">
<h2>{{ keyval.key }}</h2>
<p>{{ keyval.value.masked && !unmasked[keyval.key] ? (keyval.value.value | mask ) : (keyval.value.value | truncateEnd : 100) }}</p>
</ion-label>
<div slot="end" *ngIf="keyval.value.copyable || keyval.value.qr">
<ion-button *ngIf="keyval.value.masked" fill="clear" (click)="toggleMask(keyval.key)">
<ion-icon slot="icon-only" [name]="unmasked[keyval.key] ? 'eye-off-outline' : 'eye-outline'" [color]="unmasked[keyval.key] ? 'danger' : 'primary'" size="small"></ion-icon>
</ion-button>
<ion-button *ngIf="keyval.value.qr" fill="clear" (click)="showQR(keyval.value.value)">
<ion-icon slot="icon-only" name="qr-code-outline" size="small" color="primary"></ion-icon>
</ion-button>
<ion-button *ngIf="keyval.value.copyable" fill="clear" (click)="copy(keyval.value.value)">
<ion-icon slot="icon-only" name="copy-outline" size="small" color="primary"></ion-icon>
</ion-button>
</div>
</ion-item>
</div>
</ion-item-group>
</ng-container>
</ion-content>

View File

@@ -1,138 +0,0 @@
import { Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ApiService } from 'src/app/services/api/api.service'
import { pauseFor } from 'src/app/util/misc.util'
import { BehaviorSubject } from 'rxjs'
import { copyToClipboard } from 'src/app/util/web.util'
import { AlertController, NavController, PopoverController, ToastController } from '@ionic/angular'
import { AppMetrics } from 'src/app/util/metrics.util'
import { QRComponent } from 'src/app/components/qr/qr.component'
import { AppMetricStore } from './metric-store'
import * as JSONpointer from 'json-pointer'
import { ModelPreload } from 'src/app/models/model-preload'
import { markAsLoadingDuringP } from 'src/app/services/loader.service'
import { AppInstalledFull } from 'src/app/models/app-types'
import { peekProperties } from 'src/app/util/property-subject.util'
@Component({
selector: 'app-metrics',
templateUrl: './app-metrics.page.html',
styleUrls: ['./app-metrics.page.scss'],
})
export class AppMetricsPage {
error = ''
$loading$ = new BehaviorSubject(true)
appId: string
pointer: string
qrCode: string
app: AppInstalledFull
$metrics$ = new BehaviorSubject<AppMetrics>({ })
$hasMetrics$ = new BehaviorSubject<boolean>(null)
unmasked: { [key: string]: boolean } = { }
constructor (
private readonly route: ActivatedRoute,
private readonly apiService: ApiService,
private readonly alertCtrl: AlertController,
private readonly toastCtrl: ToastController,
private readonly popoverCtrl: PopoverController,
private readonly metricStore: AppMetricStore,
private readonly navCtrl: NavController,
private readonly preload: ModelPreload,
) { }
ngOnInit () {
this.appId = this.route.snapshot.paramMap.get('appId')
this.pointer = this.route.queryParams['pointer']
markAsLoadingDuringP(this.$loading$, Promise.all([
this.preload.appFull(this.appId).toPromise(),
this.getMetrics(),
pauseFor(600),
])).then(([app]) => {
this.app = peekProperties(app)
this.metricStore.watch().subscribe(m => {
const metrics = JSONpointer.get(m, this.pointer || '')
this.$metrics$.next(metrics)
})
this.$metrics$.subscribe(m => {
this.$hasMetrics$.next(!!Object.keys(m || { }).length)
})
this.route.queryParams.subscribe(queryParams => {
if (queryParams['pointer'] === this.pointer) return
this.pointer = queryParams['pointer']
const metrics = JSONpointer.get(this.metricStore.$metrics$.getValue(), this.pointer || '')
this.$metrics$.next(metrics)
})
})
}
async doRefresh (event: any) {
await Promise.all([
this.getMetrics(),
pauseFor(600),
])
event.target.complete()
}
async getMetrics (): Promise<void> {
try {
const metrics = await this.apiService.getAppMetrics(this.appId)
this.metricStore.update(metrics)
} catch (e) {
console.error(e)
this.error = e.message
}
}
async presentDescription (metric: { key: string, value: AppMetrics[''] }, e: Event) {
e.stopPropagation()
const alert = await this.alertCtrl.create({
header: metric.key,
message: metric.value.description,
})
await alert.present()
}
async goToNested (key: string): Promise<any> {
this.navCtrl.navigateForward(`/services/installed/${this.appId}/metrics`, {
queryParams: {
pointer: `${this.pointer || ''}/${key}/value`,
},
})
}
async copy (text: string): Promise<void> {
let message = ''
await copyToClipboard(text).then(success => { message = success ? 'copied to clipboard!' : 'failed to copy'})
const toast = await this.toastCtrl.create({
header: message,
position: 'bottom',
duration: 1000,
cssClass: 'notification-toast',
})
await toast.present()
}
async showQR (text: string, ev: any): Promise<void> {
const popover = await this.popoverCtrl.create({
component: QRComponent,
cssClass: 'qr-popover',
event: ev,
componentProps: {
text,
},
})
return await popover.present()
}
toggleMask (key: string) {
this.unmasked[key] = !this.unmasked[key]
}
asIsOrder (a: any, b: any) {
return 0
}
}

View File

@@ -1,15 +0,0 @@
import { Injectable } from '@angular/core'
import { BehaviorSubject } from 'rxjs'
import { AppMetrics } from '../../../util/metrics.util'
@Injectable({
providedIn: 'root',
})
export class AppMetricStore {
$metrics$: BehaviorSubject<AppMetrics> = new BehaviorSubject({ })
watch () { return this.$metrics$.asObservable() }
update (metrics: AppMetrics): void {
this.$metrics$.next(metrics)
}
}

View File

@@ -2,7 +2,7 @@ import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppMetricsPage } from './app-metrics.page'
import { AppPropertiesPage } from './app-properties.page'
import { PwaBackComponentModule } from 'src/app/components/pwa-back-button/pwa-back.component.module'
import { QRComponentModule } from 'src/app/components/qr/qr.component.module'
import { SharingModule } from 'src/app/modules/sharing.module'
@@ -10,7 +10,7 @@ import { SharingModule } from 'src/app/modules/sharing.module'
const routes: Routes = [
{
path: '',
component: AppMetricsPage,
component: AppPropertiesPage,
},
]
@@ -23,6 +23,6 @@ const routes: Routes = [
QRComponentModule,
SharingModule,
],
declarations: [AppMetricsPage],
declarations: [AppPropertiesPage],
})
export class AppMetricsPageModule { }
export class AppPropertiesPageModule { }

View File

@@ -0,0 +1,74 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<pwa-back-button></pwa-back-button>
</ion-buttons>
<ion-title>Properties</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<ion-spinner *ngIf="loading; else loaded" class="center" name="lines" color="warning"></ion-spinner>
<ng-template #loaded>
<ng-container *ngIf="patch.watch$('package-data', pkgId) | ngrxPush as pkg">
<ion-refresher slot="fixed" (ionRefresh)="doRefresh($event)">
<ion-refresher-content pullingIcon="lines" refreshingSpinner="lines"></ion-refresher-content>
</ion-refresher>
<ion-item *ngIf="error" style="margin-bottom: 16px;">
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
</ion-item>
<!-- not running -->
<ion-item *ngIf="pkg.installed.status.main.status !== FeStatus.Running" class="ion-margin-bottom">
<ion-label class="ion-text-wrap">
<p><ion-text color="warning">Service not running. Information on this page could be inaccurate.</ion-text></p>
</ion-label>
</ion-item>
<!-- no properties -->
<ion-item *ngIf="(hasProperties$ | ngrxPush) === false">
<ion-label class="ion-text-wrap">
<p>No properties.</p>
</ion-label>
</ion-item>
<!-- properties -->
<ion-item-group *ngIf="(hasProperties$ | ngrxPush) === true">
<div *ngFor="let prop of properties$ | ngrxPush | keyvalue: asIsOrder">
<!-- object -->
<ion-item button detail="true" *ngIf="prop.value.type === 'object'" (click)="goToNested(prop.key)">
<ion-button *ngIf="prop.value.description" class="help-button" fill="clear" slot="start" (click)="presentDescription(prop, $event)">
<ion-icon size="small" slot="icon-only" name="help-circle-outline"></ion-icon>
</ion-button>
<ion-label class="ion-text-wrap">
<h2>{{ prop.key }}</h2>
</ion-label>
</ion-item>
<!-- not object -->
<ion-item *ngIf="prop.value.type === 'string'">
<ion-button *ngIf="prop.value.description" class="help-button" fill="clear" slot="start" (click)="presentDescription(prop, $event)">
<ion-icon size="small" slot="icon-only" name="help-circle-outline"></ion-icon>
</ion-button>
<ion-label class="ion-text-wrap">
<h2>{{ prop.key }}</h2>
<p>{{ prop.value.masked && !unmasked[prop.key] ? (prop.value.value | mask ) : (prop.value.value | truncateEnd : 100) }}</p>
</ion-label>
<div slot="end" *ngIf="prop.value.copyable || prop.value.qr">
<ion-button *ngIf="prop.value.masked" fill="clear" (click)="toggleMask(prop.key)">
<ion-icon slot="icon-only" [name]="unmasked[prop.key] ? 'eye-off-outline' : 'eye-outline'" [color]="unmasked[prop.key] ? 'danger' : 'primary'" size="small"></ion-icon>
</ion-button>
<ion-button *ngIf="prop.value.qr" fill="clear" (click)="showQR(prop.value.value)">
<ion-icon slot="icon-only" name="qr-code-outline" size="small" color="primary"></ion-icon>
</ion-button>
<ion-button *ngIf="prop.value.copyable" fill="clear" (click)="copy(prop.value.value)">
<ion-icon slot="icon-only" name="copy-outline" size="small" color="primary"></ion-icon>
</ion-button>
</div>
</ion-item>
</div>
</ion-item-group>
</ng-container>
</ng-template>
</ion-content>

View File

@@ -0,0 +1,128 @@
import { Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ApiService } from 'src/app/services/api/api.service'
import { isEmptyObject, pauseFor } from 'src/app/util/misc.util'
import { BehaviorSubject, Subject } from 'rxjs'
import { copyToClipboard } from 'src/app/util/web.util'
import { AlertController, NavController, PopoverController, ToastController } from '@ionic/angular'
import { PackageProperties } from 'src/app/util/properties.util'
import { QRComponent } from 'src/app/components/qr/qr.component'
import { PropertyStore } from './property-store'
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
import * as JSONpointer from 'json-pointer'
import { FEStatus } from 'src/app/services/pkg-status-rendering.service'
@Component({
selector: 'app-properties',
templateUrl: './app-properties.page.html',
styleUrls: ['./app-properties.page.scss'],
})
export class AppPropertiesPage {
error = ''
loading = true
pkgId: string
pointer: string
qrCode: string
properties$ = new BehaviorSubject<PackageProperties>({ })
hasProperties$ = new BehaviorSubject<boolean>(null)
unmasked: { [key: string]: boolean } = { }
FeStatus = FEStatus
constructor (
private readonly route: ActivatedRoute,
private readonly apiService: ApiService,
private readonly alertCtrl: AlertController,
private readonly toastCtrl: ToastController,
private readonly popoverCtrl: PopoverController,
private readonly propertyStore: PropertyStore,
private readonly navCtrl: NavController,
public patch: PatchDbModel,
) { }
ngOnInit () {
this.pkgId = this.route.snapshot.paramMap.get('pkgId')
this.pointer = this.route.queryParams['pointer']
this.getProperties().then(() => this.loading = false)
this.propertyStore.watch$().subscribe(m => {
const properties = JSONpointer.get(m, this.pointer || '')
this.properties$.next(properties)
})
this.properties$.subscribe(m => {
this.hasProperties$.next(!isEmptyObject(m))
})
this.route.queryParams.subscribe(queryParams => {
if (queryParams['pointer'] === this.pointer) return
this.pointer = queryParams['pointer']
const properties = JSONpointer.get(this.propertyStore.properties$.getValue(), this.pointer || '')
this.properties$.next(properties)
})
}
async doRefresh (event: any) {
await this.getProperties(),
event.target.complete()
}
async presentDescription (property: { key: string, value: PackageProperties[''] }, e: Event) {
e.stopPropagation()
const alert = await this.alertCtrl.create({
header: property.key,
message: property.value.description,
})
await alert.present()
}
async goToNested (key: string): Promise<any> {
this.navCtrl.navigateForward(`/services/installed/${this.pkgId}/properties`, {
queryParams: {
pointer: `${this.pointer || ''}/${key}/value`,
},
})
}
async copy (text: string): Promise<void> {
let message = ''
await copyToClipboard(text).then(success => { message = success ? 'copied to clipboard!' : 'failed to copy'})
const toast = await this.toastCtrl.create({
header: message,
position: 'bottom',
duration: 1000,
cssClass: 'notification-toast',
})
await toast.present()
}
async showQR (text: string, ev: any): Promise<void> {
const popover = await this.popoverCtrl.create({
component: QRComponent,
cssClass: 'qr-popover',
event: ev,
componentProps: {
text,
},
})
return await popover.present()
}
toggleMask (key: string) {
this.unmasked[key] = !this.unmasked[key]
}
asIsOrder (a: any, b: any) {
return 0
}
private async getProperties (): Promise<void> {
try {
const properties = await this.apiService.getPackageProperties({ id: this.pkgId })
this.propertyStore.update(properties)
} catch (e) {
console.error(e)
this.error = e.message
}
}
}

View File

@@ -0,0 +1,15 @@
import { Injectable } from '@angular/core'
import { BehaviorSubject } from 'rxjs'
import { PackageProperties } from '../../../util/properties.util'
@Injectable({
providedIn: 'root',
})
export class PropertyStore {
properties$: BehaviorSubject<PackageProperties> = new BehaviorSubject({ })
watch$ () { return this.properties$.asObservable() }
update (properties: PackageProperties): void {
this.properties$.next(properties)
}
}

View File

@@ -0,0 +1,30 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { AppRestorePage } from './app-restore.page'
import { RouterModule, Routes } from '@angular/router'
import { PwaBackComponentModule } from 'src/app/components/pwa-back-button/pwa-back.component.module'
import { BackupConfirmationComponentModule } from 'src/app/modals/backup-confirmation/backup-confirmation.component.module'
import { SharingModule } from 'src/app/modules/sharing.module'
const routes: Routes = [
{
path: '',
component: AppRestorePage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
SharingModule,
RouterModule.forChild(routes),
BackupConfirmationComponentModule,
PwaBackComponentModule,
],
declarations: [
AppRestorePage,
],
})
export class AppRestorePageModule { }

View File

@@ -0,0 +1,64 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<pwa-back-button></pwa-back-button>
</ion-buttons>
<ion-title>Restore From Backup</ion-title>
<ion-buttons slot="end">
<ion-button (click)="doRefresh()" color="primary">
<ion-icon slot="icon-only" name="reload-outline"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top" *ngIf="patch.watch$('package-data', pkgId) | ngrxPush as pkg">
<ion-item *ngIf="error" style="margin-bottom: 16px;">
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
</ion-item>
<ion-item class="ion-margin-bottom">
<ion-label class="ion-text-wrap">
<p><ion-text color="dark">About</ion-text></p>
<p>
Select a location from which to restore {{ pkg.installed.manifest.title }}. This will overwrite all current data.
</p>
</ion-label>
</ion-item>
<ion-spinner *ngIf="loading; else loaded" name="lines" color="warning" class="center"></ion-spinner>
<ng-template #loaded>
<ion-item *ngIf="allPartitionsMounted">
<ion-text *ngIf="type === 'restore'" class="ion-text-wrap" color="warning">No partitions available. Insert the storage device containing the backup you intend to restore.</ion-text>
</ion-item>
<ion-card *ngFor="let disk of disks | keyvalue">
<ion-card-header>
<ion-card-title>
{{ disk.value.size }}
</ion-card-title>
<ion-card-subtitle>
{{ disk.key }}
</ion-card-subtitle>
</ion-card-header>
<ion-card-content>
<ion-item-group>
<ion-item button *ngFor="let partition of disk.value.partitions | keyvalue" [disabled]="partition.value['is-mounted']" (click)="presentModal(partition.key, partition.value)">
<ion-icon slot="start" name="save-outline"></ion-icon>
<ion-label>
<h2>{{ partition.value.label || partition.key }} ({{ partition.value.size || 'unknown size' }})</h2>
<p *ngIf="!partition.value['is-mounted']; else unavailable"><ion-text color="success">Available</ion-text></p>
<ng-template #unavailable>
<p><ion-text color="danger">Unavailable</ion-text></p>
</ng-template>
</ion-label>
</ion-item>
</ion-item-group>
</ion-card-content>
</ion-card>
</ng-template>
</ion-content>

View File

@@ -0,0 +1,91 @@
import { Component } from '@angular/core'
import { LoadingController, ModalController } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/api.service'
import { BackupConfirmationComponent } from 'src/app/modals/backup-confirmation/backup-confirmation.component'
import { DiskInfo, PartitionInfoEntry } from 'src/app/services/api/api-types'
import { ActivatedRoute } from '@angular/router'
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
@Component({
selector: 'app-restore',
templateUrl: './app-restore.page.html',
styleUrls: ['./app-restore.page.scss'],
})
export class AppRestorePage {
disks: DiskInfo
pkgId: string
loading = true
error: string
allPartitionsMounted: boolean
constructor (
private readonly route: ActivatedRoute,
private readonly modalCtrl: ModalController,
private readonly apiService: ApiService,
private readonly loadingCtrl: LoadingController,
public readonly patch: PatchDbModel,
) { }
ngOnInit () {
this.pkgId = this.route.snapshot.paramMap.get('pkgId')
this.getExternalDisks()
}
async doRefresh () {
this.loading = true
await this.getExternalDisks()
}
async getExternalDisks (): Promise<void> {
try {
this.disks = await this.apiService.getDisks({ })
this.allPartitionsMounted = Object.values(this.disks).every(d => Object.values(d.partitions).every(p => p['is-mounted']))
} catch (e) {
console.error(e)
this.error = e.message
} finally {
this.loading = false
}
}
async presentModal (logicalname: string, partition: PartitionInfoEntry): Promise<void> {
const m = await this.modalCtrl.create({
componentProps: {
name: partition.label || logicalname,
},
cssClass: 'alertlike-modal',
component: BackupConfirmationComponent,
backdropDismiss: false,
})
m.onWillDismiss().then(res => {
const data = res.data
if (data.cancel) return
this.restore(logicalname, data.password)
})
return await m.present()
}
private async restore (logicalname: string, password: string): Promise<void> {
this.error = ''
const loader = await this.loadingCtrl.create({
spinner: 'lines',
})
await loader.present()
try {
await this.apiService.restorePackage({
id: this.pkgId,
logicalname,
password,
})
} catch (e) {
console.error(e)
this.error = e.message
} finally {
loader.dismiss()
}
}
}

View File

@@ -7,45 +7,53 @@ const routes: Routes = [
redirectTo: 'installed',
pathMatch: 'full',
},
{
path: 'marketplace',
loadChildren: () => import('./app-available-list/app-available-list.module').then(m => m.AppAvailableListPageModule),
},
{
path: 'installed',
loadChildren: () => import('./app-installed-list/app-installed-list.module').then(m => m.AppInstalledListPageModule),
},
{
path: 'marketplace/:appId',
loadChildren: () => import('./app-available-show/app-available-show.module').then(m => m.AppAvailableShowPageModule),
},
{
path: 'installed/:appId',
path: 'installed/:pkgId',
loadChildren: () => import('./app-installed-show/app-installed-show.module').then(m => m.AppInstalledShowPageModule),
},
{
path: 'installed/:appId/instructions',
path: 'installed/:pkgId/actions',
loadChildren: () => import('./app-actions/app-actions.module').then(m => m.AppActionsPageModule),
},
{
path: 'installed/:pkgId/config',
loadChildren: () => import('./app-config/app-config.module').then(m => m.AppConfigPageModule),
},
{
path: 'installed/:pkgId/config/:edit',
loadChildren: () => import('./app-config/app-config.module').then(m => m.AppConfigPageModule),
},
{
path: 'installed/:pkgId/instructions',
loadChildren: () => import('./app-instructions/app-instructions.module').then(m => m.AppInstructionsPageModule),
},
{
path: 'installed/:appId/config',
loadChildren: () => import('./app-config/app-config.module').then(m => m.AppConfigPageModule),
path: 'installed/:pkgId/interfaces',
loadChildren: () => import('./app-interfaces/app-interfaces.module').then(m => m.AppInterfacesPageModule),
},
{
path: 'installed/:appId/config/:edit',
loadChildren: () => import('./app-config/app-config.module').then(m => m.AppConfigPageModule),
},
{
path: 'installed/:appId/logs',
path: 'installed/:pkgId/logs',
loadChildren: () => import('./app-logs/app-logs.module').then(m => m.AppLogsPageModule),
},
{
path: 'installed/:appId/metrics',
loadChildren: () => import('./app-metrics/app-metrics.module').then(m => m.AppMetricsPageModule),
path: 'installed/:pkgId/properties',
loadChildren: () => import('./app-properties/app-properties.module').then(m => m.AppPropertiesPageModule),
},
{
path: 'installed/:appId/actions',
loadChildren: () => import('./app-actions/app-actions.module').then(m => m.AppActionsPageModule),
path: 'installed/:pkgId/restore',
loadChildren: () => import('./app-restore/app-restore.module').then(m => m.AppRestorePageModule),
},
{
path: 'marketplace',
loadChildren: () => import('./app-available-list/app-available-list.module').then(m => m.AppAvailableListPageModule),
},
{
path: 'marketplace/:pkgId',
loadChildren: () => import('./app-available-show/app-available-show.module').then(m => m.AppAvailableShowPageModule),
},
]