mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-31 04:23:40 +00:00
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:
committed by
Aiden McClelland
parent
fd685ae32c
commit
594d93eb3b
@@ -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>
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 { }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ const routes: Routes = [
|
||||
PwaBackComponentModule,
|
||||
SharingModule,
|
||||
],
|
||||
declarations: [AppInstructionsPage],
|
||||
declarations: [
|
||||
AppInstructionsPage,
|
||||
],
|
||||
})
|
||||
export class AppInstructionsPageModule { }
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
.instructions-padding {
|
||||
padding: 0 16px 16px 16px
|
||||
}
|
||||
@@ -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'),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { ServerConfigPage } from './server-config.page'
|
||||
import { Routes, RouterModule } from '@angular/router'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
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 { ObjectConfigComponentModule } from 'src/app/components/object-config/object-config.component.module'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: ServerConfigPage,
|
||||
component: AppInterfacesPage,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -18,11 +17,10 @@ const routes: Routes = [
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
SharingModule,
|
||||
ObjectConfigComponentModule,
|
||||
RouterModule.forChild(routes),
|
||||
PwaBackComponentModule,
|
||||
SharingModule,
|
||||
],
|
||||
declarations: [ServerConfigPage],
|
||||
declarations: [AppInterfacesPage],
|
||||
})
|
||||
export class ServerConfigPageModule { }
|
||||
export class AppInterfacesPageModule { }
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
.vertical-align {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
91
ui/src/app/pages/apps-routes/app-restore/app-restore.page.ts
Normal file
91
ui/src/app/pages/apps-routes/app-restore/app-restore.page.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { AuthPasswordPage } from './auth-password.page'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AuthPasswordPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
SharingModule,
|
||||
],
|
||||
declarations: [AuthPasswordPage],
|
||||
})
|
||||
export class AuthPasswordPageModule { }
|
||||
@@ -0,0 +1,35 @@
|
||||
<ion-content class="ion-padding">
|
||||
<ion-grid style="height: 100%; max-width: 500px;">
|
||||
<ion-row class="ion-align-items-center" style="height: 100%;">
|
||||
<ion-col>
|
||||
<ion-card>
|
||||
<div style="padding: 20px;">
|
||||
<ion-card-header class="ion-text-center">
|
||||
<ion-card-title style="padding-bottom: 36px;">Confirm Nym</ion-card-title>
|
||||
<img src="assets/img/service-icons/bitcoind.png" style="max-width: 120px;" />
|
||||
<ion-card-subtitle class="ion-text-center">Chuck Tender</ion-card-subtitle>
|
||||
</ion-card-header>
|
||||
<ion-card-content style="padding-top: 30px;">
|
||||
<form (submit)="submit()">
|
||||
<ion-item-group>
|
||||
<ion-item color="light">
|
||||
<ion-input [type]="unmasked ? 'text' : 'password'" name="password" placeholder="Enter Password" [(ngModel)]="password" (ionChange)="error = ''"></ion-input>
|
||||
<ion-button fill="clear" color="dark" (click)="toggleMask()">
|
||||
<ion-icon slot="icon-only" [name]="unmasked ? 'eye-off-outline' : 'eye-outline'" size="small"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
<ion-item *ngIf="error" lines="none">
|
||||
<ion-label class="ion-text-wrap" color="danger">{{ error }}</ion-label>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
<ion-button color="dark" class="sharp-button" type="submit" [disabled]="!password" style="margin-top: 60px" expand="block" fill="outline">
|
||||
Login
|
||||
</ion-button>
|
||||
</form>
|
||||
</ion-card-content>
|
||||
</div>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,3 @@
|
||||
.sharp-button {
|
||||
--border-radius: 1px;
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { AuthService } from 'src/app/services/auth.service'
|
||||
import { LoaderService } from 'src/app/services/loader.service'
|
||||
import { NavController } from '@ionic/angular'
|
||||
|
||||
@Component({
|
||||
selector: 'auth-password',
|
||||
templateUrl: './auth-password.page.html',
|
||||
styleUrls: ['./auth-password.page.scss'],
|
||||
})
|
||||
export class AuthPasswordPage {
|
||||
password: string = ''
|
||||
unmasked = false
|
||||
error = ''
|
||||
|
||||
constructor (
|
||||
private readonly authService: AuthService,
|
||||
private readonly loader: LoaderService,
|
||||
private readonly navCtrl: NavController,
|
||||
) { }
|
||||
|
||||
ionViewDidEnter () {
|
||||
this.error = ''
|
||||
}
|
||||
|
||||
toggleMask () {
|
||||
this.unmasked = !this.unmasked
|
||||
}
|
||||
|
||||
async submit () {
|
||||
try {
|
||||
await this.loader.displayDuringP(
|
||||
this.authService.submitPassword(this.password),
|
||||
)
|
||||
this.password = ''
|
||||
return this.navCtrl.navigateForward([''])
|
||||
} catch (e) {
|
||||
this.error = e.message
|
||||
}
|
||||
}
|
||||
}
|
||||
26
ui/src/app/pages/auth-routes/auth-pin/auth-pin.module.ts
Normal file
26
ui/src/app/pages/auth-routes/auth-pin/auth-pin.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { AuthPinPage } from './auth-pin.page'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AuthPinPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
SharingModule,
|
||||
],
|
||||
declarations: [AuthPinPage],
|
||||
})
|
||||
export class AuthPinPageModule { }
|
||||
34
ui/src/app/pages/auth-routes/auth-pin/auth-pin.page.html
Normal file
34
ui/src/app/pages/auth-routes/auth-pin/auth-pin.page.html
Normal file
@@ -0,0 +1,34 @@
|
||||
<ion-content class="ion-padding">
|
||||
<ion-grid style="height: 100%; max-width: 500px;">
|
||||
<ion-row class="ion-align-items-center" style="height: 100%;">
|
||||
<ion-col>
|
||||
<ion-card>
|
||||
<div style="padding: 20px;">
|
||||
<ion-card-header class="ion-text-center">
|
||||
<!-- <ion-card-title style="padding-bottom: 36px;">Enter Pin</ion-card-title> -->
|
||||
<img src="assets/img/logo.png" style="max-width: 120px;" />
|
||||
</ion-card-header>
|
||||
<ion-card-content style="padding-top: 30px;">
|
||||
<form (submit)="submit()">
|
||||
<ion-item-group>
|
||||
<ion-item color="light">
|
||||
<ion-input [type]="unmasked ? 'text' : 'password'" name="pin" placeholder="Enter Pin" [(ngModel)]="pin" (ionChange)="error = ''"></ion-input>
|
||||
<ion-button fill="clear" color="dark" (click)="toggleMask()">
|
||||
<ion-icon slot="icon-only" [name]="unmasked ? 'eye-off-outline' : 'eye-outline'" size="small"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
<ion-item *ngIf="error" lines="none">
|
||||
<ion-label class="ion-text-wrap" color="danger">{{ error }}</ion-label>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
<ion-button color="dark" class="sharp-button" type="submit" [disabled]="!pin" style="margin-top: 60px" expand="block" fill="outline">
|
||||
Next
|
||||
</ion-button>
|
||||
</form>
|
||||
</ion-card-content>
|
||||
</div>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
3
ui/src/app/pages/auth-routes/auth-pin/auth-pin.page.scss
Normal file
3
ui/src/app/pages/auth-routes/auth-pin/auth-pin.page.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.sharp-button {
|
||||
--border-radius: 1px;
|
||||
}
|
||||
41
ui/src/app/pages/auth-routes/auth-pin/auth-pin.page.ts
Normal file
41
ui/src/app/pages/auth-routes/auth-pin/auth-pin.page.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { AuthService } from 'src/app/services/auth.service'
|
||||
import { LoaderService } from 'src/app/services/loader.service'
|
||||
|
||||
@Component({
|
||||
selector: 'auth-pin',
|
||||
templateUrl: './auth-pin.page.html',
|
||||
styleUrls: ['./auth-pin.page.scss'],
|
||||
})
|
||||
export class AuthPinPage {
|
||||
pin = ''
|
||||
unmasked = false
|
||||
error = ''
|
||||
|
||||
constructor (
|
||||
private readonly authService: AuthService,
|
||||
private readonly loader: LoaderService,
|
||||
private readonly navCtrl: NavController,
|
||||
) { }
|
||||
|
||||
ionViewDidEnter () {
|
||||
this.error = ''
|
||||
}
|
||||
|
||||
toggleMask () {
|
||||
this.unmasked = !this.unmasked
|
||||
}
|
||||
|
||||
async submit () {
|
||||
try {
|
||||
await this.loader.displayDuringP(
|
||||
this.authService.submitPin(this.pin),
|
||||
)
|
||||
this.pin = ''
|
||||
await this.navCtrl.navigateForward(['/auth/password'])
|
||||
} catch (e) {
|
||||
this.error = e.message
|
||||
}
|
||||
}
|
||||
}
|
||||
24
ui/src/app/pages/auth-routes/auth-routing.module.ts
Normal file
24
ui/src/app/pages/auth-routes/auth-routing.module.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { Routes, RouterModule } from '@angular/router'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'pin',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'pin',
|
||||
loadChildren: () => import('./auth-pin/auth-pin.module').then(m => m.AuthPinPageModule),
|
||||
},
|
||||
{
|
||||
path: 'password',
|
||||
loadChildren: () => import('./auth-password/auth-password.module').then(m => m.AuthPasswordPageModule),
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class AuthRoutingModule { }
|
||||
@@ -1,17 +0,0 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { Routes, RouterModule } from '@angular/router';
|
||||
|
||||
import { AuthenticatePage } from './authenticate.page';
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AuthenticatePage
|
||||
}
|
||||
];
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class AuthenticatePageRoutingModule {}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { NgModule } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { IonicModule } from '@ionic/angular';
|
||||
import { AuthenticatePageRoutingModule } from './authenticate-routing.module';
|
||||
import { AuthenticatePage } from './authenticate.page';
|
||||
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';
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
AuthenticatePageRoutingModule,
|
||||
PwaBackComponentModule,
|
||||
BadgeMenuComponentModule,
|
||||
],
|
||||
declarations: [AuthenticatePage],
|
||||
})
|
||||
export class AuthenticatePageModule { }
|
||||
@@ -1,27 +0,0 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Login</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<badge-menu-button></badge-menu-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<form (submit)="submitPassword()">
|
||||
<ion-item-group>
|
||||
<ion-item>
|
||||
<ion-input [type]="unmasked ? 'text' : 'password'" name="password" placeholder="Enter password" [(ngModel)]="password" (ionChange)="$error$.next('')"></ion-input>
|
||||
<ion-button fill="clear" [color]="unmasked ? 'danger' : 'primary'" (click)="toggleMask()">
|
||||
<ion-icon slot="icon-only" [name]="unmasked ? 'eye-off-outline' : 'eye-outline'" size="small"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
<ion-item *ngIf="$error$ | async as e" lines="none">
|
||||
<ion-label class="ion-text-wrap" color="danger">{{ e }}</ion-label>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
<ion-button type="submit" [disabled]="!password" style="margin-top: 30px" expand="block" fill="outline">
|
||||
Login
|
||||
</ion-button>
|
||||
</form>
|
||||
</ion-content>
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Component, OnInit } from '@angular/core'
|
||||
import { AuthService } from '../../services/auth.service'
|
||||
import { LoaderService } from '../../services/loader.service'
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
import { Router } from '@angular/router'
|
||||
|
||||
@Component({
|
||||
selector: 'app-authenticate',
|
||||
templateUrl: './authenticate.page.html',
|
||||
styleUrls: ['./authenticate.page.scss'],
|
||||
})
|
||||
export class AuthenticatePage implements OnInit {
|
||||
password: string = ''
|
||||
unmasked = false
|
||||
$error$ = new BehaviorSubject(undefined)
|
||||
|
||||
constructor (
|
||||
private readonly authStore: AuthService,
|
||||
private readonly loader: LoaderService,
|
||||
private readonly router: Router,
|
||||
) { }
|
||||
|
||||
ngOnInit () { }
|
||||
|
||||
ionViewDidEnter () {
|
||||
this.$error$.next(undefined)
|
||||
}
|
||||
|
||||
toggleMask () {
|
||||
this.unmasked = !this.unmasked
|
||||
}
|
||||
|
||||
async submitPassword () {
|
||||
try {
|
||||
await this.loader.displayDuringP(
|
||||
this.authStore.login(this.password),
|
||||
)
|
||||
this.password = ''
|
||||
return this.router.navigate([''])
|
||||
} catch (e) {
|
||||
this.$error$.next(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
24
ui/src/app/pages/maintenance/maintenance.module.ts
Normal file
24
ui/src/app/pages/maintenance/maintenance.module.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { MaintenancePage } from './maintenance.page'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: MaintenancePage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
SharingModule,
|
||||
],
|
||||
declarations: [MaintenancePage],
|
||||
})
|
||||
export class MaintenancePageModule { }
|
||||
13
ui/src/app/pages/maintenance/maintenance.page.html
Normal file
13
ui/src/app/pages/maintenance/maintenance.page.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<ion-content *ngrxLet="patch.watch$('server-info', 'status') as status">
|
||||
|
||||
<ion-grid 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 *ngIf="status === ServerStatus.Updating">Updating Embassy</p>
|
||||
<p *ngIf="status === ServerStatus.BackingUp">Embassy is backing up</p>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
|
||||
</ion-content>
|
||||
17
ui/src/app/pages/maintenance/maintenance.page.ts
Normal file
17
ui/src/app/pages/maintenance/maintenance.page.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { ServerStatus } from 'src/app/models/patch-db/data-model'
|
||||
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
|
||||
|
||||
@Component({
|
||||
selector: 'Maintenance',
|
||||
templateUrl: 'Maintenance.page.html',
|
||||
styleUrls: ['Maintenance.page.scss'],
|
||||
})
|
||||
export class MaintenancePage {
|
||||
ServerStatus = ServerStatus
|
||||
|
||||
constructor (
|
||||
public readonly patch: PatchDbModel,
|
||||
) { }
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { RouterModule, Routes } from '@angular/router'
|
||||
import { NotificationsPage } from './notifications.page'
|
||||
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 { SharingModule } from 'src/app/modules/sharing.module'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
@@ -20,6 +21,7 @@ const routes: Routes = [
|
||||
RouterModule.forChild(routes),
|
||||
PwaBackComponentModule,
|
||||
BadgeMenuComponentModule,
|
||||
SharingModule,
|
||||
],
|
||||
declarations: [NotificationsPage],
|
||||
})
|
||||
|
||||
@@ -36,15 +36,19 @@
|
||||
<ion-item *ngFor="let not of notifications; let i = index">
|
||||
<ion-label class="ion-text-wrap">
|
||||
<h2>
|
||||
<ion-text [color]="getColor(not)"><b>{{ not.title }}</b></ion-text>
|
||||
<ion-text [color]="not | notificationColor"><b>{{ not.title }}</b></ion-text>
|
||||
</h2>
|
||||
<h2 class="notification-message">
|
||||
{{ not.message }}
|
||||
<a *ngIf="not.code === 1" style="text-decoration: none;" (click)="viewBackupReport(not)">
|
||||
View Report
|
||||
</a>
|
||||
</h2>
|
||||
<h2 class="notification-message">{{ not.message }}</h2>
|
||||
<p>{{ not.createdAt | date: 'short' }}</p>
|
||||
<p>
|
||||
<a style="text-decoration: none;"
|
||||
[routerLink]="['/services', 'installed', not.appId]">{{ not.appId }}</a>
|
||||
<span> - </span>
|
||||
Code: {{ not.code }}
|
||||
{{ not['created-at'] | date: 'short' }}
|
||||
<a *ngIf="not['package-id'] as pkgId" style="text-decoration: none;" [routerLink]="['/services', 'installed', not['package-id']]">
|
||||
- {{ not['package-id'] }}
|
||||
</a>
|
||||
</p>
|
||||
</ion-label>
|
||||
<ion-button slot="end" fill="clear" (click)="remove(not.id, i)">
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { ServerModel, S9Notification } from 'src/app/models/server-model'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { pauseFor } from 'src/app/util/misc.util'
|
||||
import { LoaderService } from 'src/app/services/loader.service'
|
||||
import { ServerNotification, ServerNotifications } from 'src/app/services/api/api-types'
|
||||
import { AlertController } from '@ionic/angular'
|
||||
|
||||
@Component({
|
||||
selector: 'notifications',
|
||||
templateUrl: 'notifications.page.html',
|
||||
@@ -11,33 +12,25 @@ import { LoaderService } from 'src/app/services/loader.service'
|
||||
export class NotificationsPage {
|
||||
error = ''
|
||||
loading = true
|
||||
notifications: S9Notification[] = []
|
||||
notifications: ServerNotifications = []
|
||||
page = 1
|
||||
needInfinite = false
|
||||
readonly perPage = 20
|
||||
|
||||
constructor (
|
||||
private readonly serverModel: ServerModel,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly loader: LoaderService,
|
||||
private readonly alertCtrl: AlertController,
|
||||
) { }
|
||||
|
||||
async ngOnInit () {
|
||||
const [notifications] = await Promise.all([
|
||||
this.getNotifications(),
|
||||
pauseFor(600),
|
||||
])
|
||||
this.notifications = notifications
|
||||
this.serverModel.update({ badge: 0 })
|
||||
this.notifications = await this.getNotifications()
|
||||
this.loading = false
|
||||
}
|
||||
|
||||
async doRefresh (e: any) {
|
||||
this.page = 1
|
||||
await Promise.all([
|
||||
this.getNotifications(),
|
||||
pauseFor(600),
|
||||
])
|
||||
this.notifications = await this.getNotifications(),
|
||||
e.target.complete()
|
||||
}
|
||||
|
||||
@@ -47,10 +40,10 @@ export class NotificationsPage {
|
||||
e.target.complete()
|
||||
}
|
||||
|
||||
async getNotifications (): Promise<S9Notification[]> {
|
||||
let notifications: S9Notification[] = []
|
||||
async getNotifications (): Promise<ServerNotifications> {
|
||||
let notifications: ServerNotifications = []
|
||||
try {
|
||||
notifications = await this.apiService.getNotifications(this.page, this.perPage)
|
||||
notifications = await this.apiService.getNotifications({ page: this.page, 'per-page': this.perPage })
|
||||
this.needInfinite = notifications.length >= this.perPage
|
||||
this.page++
|
||||
this.error = ''
|
||||
@@ -62,29 +55,13 @@ export class NotificationsPage {
|
||||
}
|
||||
}
|
||||
|
||||
getColor (notification: S9Notification): string {
|
||||
const char = notification.code.charAt(0)
|
||||
switch (char) {
|
||||
case '0':
|
||||
return 'primary'
|
||||
case '1':
|
||||
return 'success'
|
||||
case '2':
|
||||
return 'warning'
|
||||
case '3':
|
||||
return 'danger'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
async remove (notificationId: string, index: number): Promise<void> {
|
||||
async remove (id: string, index: number): Promise<void> {
|
||||
this.loader.of({
|
||||
message: 'Deleting...',
|
||||
spinner: 'lines',
|
||||
cssClass: 'loader',
|
||||
}).displayDuringP(
|
||||
this.apiService.deleteNotification(notificationId).then(() => {
|
||||
this.apiService.deleteNotification({ id }).then(() => {
|
||||
this.notifications.splice(index, 1)
|
||||
this.error = ''
|
||||
}),
|
||||
@@ -93,5 +70,45 @@ export class NotificationsPage {
|
||||
this.error = e.message
|
||||
})
|
||||
}
|
||||
|
||||
async viewBackupReport (notification: ServerNotification<1>) {
|
||||
const data = notification.data
|
||||
|
||||
const embassyFailed = !!data.server.error
|
||||
const packagesFailed = Object.entries(data.packages).some(([_, val]) => val.error)
|
||||
|
||||
let message: string
|
||||
|
||||
if (embassyFailed || packagesFailed) {
|
||||
message = 'There was an issue backing up one or more items. Click "Retry" to retry ONLY the items that failed.'
|
||||
} else {
|
||||
message = 'All items were successfully backed up'
|
||||
}
|
||||
|
||||
const buttons: any[] = [ // why can't I import AlertButton?
|
||||
{
|
||||
text: 'Dismiss',
|
||||
role: 'cancel',
|
||||
},
|
||||
]
|
||||
|
||||
if (embassyFailed || packagesFailed) {
|
||||
buttons.push({
|
||||
text: 'Retry',
|
||||
handler: () => {
|
||||
console.log('retry backup')
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Backup Report',
|
||||
message,
|
||||
buttons,
|
||||
})
|
||||
|
||||
await alert.present()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import { DevOptionsPage } from './dev-options.page'
|
||||
import { Routes, RouterModule } from '@angular/router'
|
||||
import { PwaBackComponentModule } from 'src/app/components/pwa-back-button/pwa-back.component.module'
|
||||
import { ObjectConfigComponentModule } from 'src/app/components/object-config/object-config.component.module'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
@@ -20,7 +21,10 @@ const routes: Routes = [
|
||||
ObjectConfigComponentModule,
|
||||
RouterModule.forChild(routes),
|
||||
PwaBackComponentModule,
|
||||
SharingModule,
|
||||
],
|
||||
declarations: [
|
||||
DevOptionsPage,
|
||||
],
|
||||
declarations: [DevOptionsPage],
|
||||
})
|
||||
export class DevOptionsPageModule { }
|
||||
|
||||
@@ -9,18 +9,14 @@
|
||||
|
||||
<ion-content class="ion-padding-top">
|
||||
|
||||
<ion-refresher slot="fixed" (ionRefresh)="doRefresh($event)">
|
||||
<ion-refresher-content pullingIcon="lines" refreshingSpinner="lines"></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
|
||||
<ion-item-group>
|
||||
<ion-item button [routerLink]="['ssh-keys']">
|
||||
<ion-item-group *ngrxLet="patch.watch$('server-info') as server">
|
||||
<ion-item detail="true" button [routerLink]="['ssh-keys']">
|
||||
<ion-label>SSH Keys</ion-label>
|
||||
</ion-item>
|
||||
<!-- <ion-item button (click)="presentModalValueEdit('alternativeRegistryUrl')">
|
||||
<ion-label>Alt Marketplace</ion-label>
|
||||
<ion-note slot="end">{{ server.alternativeRegistryUrl | async }}</ion-note>
|
||||
</ion-item> -->
|
||||
<ion-item button (click)="presentModalValueEdit('registry', server.registry)">
|
||||
<ion-label>Marketplace URL</ion-label>
|
||||
<ion-note slot="end">{{ server.registry }}</ion-note>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
|
||||
</ion-content>
|
||||
@@ -1,11 +1,6 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { PropertySubject } from 'src/app/util/property-subject.util'
|
||||
import { S9Server } from 'src/app/models/server-model'
|
||||
import { ServerConfigService } from 'src/app/services/server-config.service'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { pauseFor } from 'src/app/util/misc.util'
|
||||
import { LoaderService } from 'src/app/services/loader.service'
|
||||
import { ModelPreload } from 'src/app/models/model-preload'
|
||||
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
|
||||
|
||||
@Component({
|
||||
selector: 'dev-options',
|
||||
@@ -13,30 +8,13 @@ import { ModelPreload } from 'src/app/models/model-preload'
|
||||
styleUrls: ['./dev-options.page.scss'],
|
||||
})
|
||||
export class DevOptionsPage {
|
||||
server: PropertySubject<S9Server> = { } as any
|
||||
|
||||
constructor (
|
||||
private readonly serverConfigService: ServerConfigService,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly loader: LoaderService,
|
||||
private readonly preload: ModelPreload,
|
||||
public readonly patch: PatchDbModel,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
this.loader.displayDuring$(
|
||||
this.preload.server(),
|
||||
).subscribe(s => this.server = s)
|
||||
}
|
||||
|
||||
async doRefresh (event: any) {
|
||||
await Promise.all([
|
||||
this.apiService.getServer(),
|
||||
pauseFor(600),
|
||||
])
|
||||
event.target.complete()
|
||||
}
|
||||
|
||||
async presentModalValueEdit (key: string): Promise<void> {
|
||||
await this.serverConfigService.presentModalValueEdit(key)
|
||||
async presentModalValueEdit (key: string, current?: any): Promise<void> {
|
||||
await this.serverConfigService.presentModalValueEdit(key, current)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { DevSSHKeysPage } from './dev-ssh-keys.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 = [
|
||||
{
|
||||
@@ -18,6 +19,7 @@ const routes: Routes = [
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
PwaBackComponentModule,
|
||||
SharingModule,
|
||||
],
|
||||
declarations: [DevSSHKeysPage],
|
||||
})
|
||||
|
||||
@@ -8,22 +8,19 @@
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding-top">
|
||||
|
||||
<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-item *ngIf="error" class="ion-margin-bottom">
|
||||
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
|
||||
</ion-item>
|
||||
|
||||
<ion-spinner *ngIf="loading" class="center" name="lines" color="warning"></ion-spinner>
|
||||
|
||||
<ion-item-group>
|
||||
<ion-item-divider>Saved Keys</ion-item-divider>
|
||||
<ion-item *ngFor="let fingerprint of server.ssh | async">
|
||||
<ion-item *ngFor="let ssh of sshService.watch$() | ngrxPush | keyvalue : asIsOrder">
|
||||
<ion-label class="ion-text-wrap">
|
||||
{{ fingerprint.alg }} {{ fingerprint.hash }} {{ fingerprint.hostname }}
|
||||
{{ ssh.value.alg }} {{ ssh.key }} {{ ssh.value.hostname }}
|
||||
</ion-label>
|
||||
<ion-button slot="end" fill="clear" (click)="presentAlertDelete(fingerprint)">
|
||||
<ion-button slot="end" fill="clear" (click)="presentAlertDelete(ssh.key)">
|
||||
<ion-icon slot="icon-only" name="close-outline" color="medium"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { SSHFingerprint, S9Server } from 'src/app/models/server-model'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { pauseFor } from 'src/app/util/misc.util'
|
||||
import { PropertySubject } from 'src/app/util/property-subject.util'
|
||||
import { ServerConfigService } from 'src/app/services/server-config.service'
|
||||
import { LoaderService } from 'src/app/services/loader.service'
|
||||
import { ModelPreload } from 'src/app/models/model-preload'
|
||||
import { AlertController } from '@ionic/angular'
|
||||
import { LoaderService } from 'src/app/services/loader.service'
|
||||
import { SSHService } from './ssh.service'
|
||||
|
||||
@Component({
|
||||
selector: 'dev-ssh-keys',
|
||||
@@ -14,40 +10,31 @@ import { AlertController } from '@ionic/angular'
|
||||
styleUrls: ['dev-ssh-keys.page.scss'],
|
||||
})
|
||||
export class DevSSHKeysPage {
|
||||
server: PropertySubject<S9Server> = { } as any
|
||||
error = ''
|
||||
loading = true
|
||||
|
||||
constructor (
|
||||
private readonly apiService: ApiService,
|
||||
private readonly loader: LoaderService,
|
||||
private readonly preload: ModelPreload,
|
||||
private readonly serverConfigService: ServerConfigService,
|
||||
private readonly alertCtrl: AlertController,
|
||||
public readonly sshService: SSHService,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
this.loader.displayDuring$(
|
||||
this.preload.server(),
|
||||
).subscribe(s => this.server = s)
|
||||
}
|
||||
|
||||
async doRefresh (event: any) {
|
||||
await Promise.all([
|
||||
this.apiService.getServer(),
|
||||
pauseFor(600),
|
||||
])
|
||||
event.target.complete()
|
||||
this.sshService.getKeys().then(() => {
|
||||
this.loading = false
|
||||
})
|
||||
}
|
||||
|
||||
async presentModalAdd () {
|
||||
await this.serverConfigService.presentModalValueEdit('ssh', true)
|
||||
await this.serverConfigService.presentModalValueEdit('ssh')
|
||||
}
|
||||
|
||||
async presentAlertDelete (fingerprint: SSHFingerprint) {
|
||||
async presentAlertDelete (hash: string) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
backdropDismiss: false,
|
||||
header: 'Caution',
|
||||
message: `Are you sure you want to delete this SSH key?`,
|
||||
message: `Are you sure you want to delete this key?`,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
@@ -57,7 +44,7 @@ export class DevSSHKeysPage {
|
||||
text: 'Delete',
|
||||
cssClass: 'alert-danger',
|
||||
handler: () => {
|
||||
this.delete(fingerprint)
|
||||
this.delete(hash)
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -65,16 +52,21 @@ export class DevSSHKeysPage {
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
async delete (fingerprint: SSHFingerprint) {
|
||||
async delete (hash: string): Promise<void> {
|
||||
this.error = ''
|
||||
this.loader.of({
|
||||
message: 'Deleting...',
|
||||
spinner: 'lines',
|
||||
cssClass: 'loader',
|
||||
}).displayDuringP(
|
||||
this.apiService.deleteSSHKey(fingerprint).then(() => this.error = ''),
|
||||
).catch(e => {
|
||||
}).displayDuringAsync(async () => {
|
||||
await this.sshService.delete(hash)
|
||||
}).catch(e => {
|
||||
console.error(e)
|
||||
this.error = e.message
|
||||
this.error = ''
|
||||
})
|
||||
}
|
||||
|
||||
asIsOrder (a: any, b: any) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
import { SSHKeys } from 'src/app/services/api/api-types'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SSHService {
|
||||
private readonly keys$ = new BehaviorSubject<SSHKeys>({ })
|
||||
|
||||
constructor (
|
||||
private readonly apiService: ApiService,
|
||||
) { }
|
||||
|
||||
watch$ () {
|
||||
return this.keys$.asObservable()
|
||||
}
|
||||
|
||||
async getKeys (): Promise<void> {
|
||||
const keys = await this.apiService.getSshKeys({ })
|
||||
this.keys$.next(keys)
|
||||
}
|
||||
|
||||
async add (pubkey: string): Promise<void> {
|
||||
const key = await this.apiService.addSshKey({ pubkey })
|
||||
const keys = this.keys$.getValue()
|
||||
this.keys$.next({ ...keys, ...key })
|
||||
}
|
||||
|
||||
async delete (hash: string): Promise<void> {
|
||||
await this.apiService.deleteSshKey({ hash })
|
||||
const keys = this.keys$.getValue()
|
||||
|
||||
const filtered = Object.keys(keys)
|
||||
.filter(h => h !== hash)
|
||||
.reduce((res, h) => {
|
||||
res[h] = keys[h]
|
||||
return res
|
||||
}, { })
|
||||
this.keys$.next(filtered)
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<!-- TODO: EJECT-DISKS -->
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<pwa-back-button></pwa-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Backup drives</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content *ngIf="!($loading$ | async)" class="ion-padding-top">
|
||||
<ion-refresher slot="fixed" (ionRefresh)="doRefresh($event)">
|
||||
<ion-refresher-content pullingIcon="lines" refreshingSpinner="lines"></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
|
||||
<ion-item-group>
|
||||
<ion-item *ngFor="let d of disks; let i = index">
|
||||
<ion-icon slot="start" name="save-outline"></ion-icon>
|
||||
<ion-label>{{d.logicalname}} ({{ d.size }})</ion-label>
|
||||
<ion-button *ngIf="!(d.$ejecting$ | async)" slot="end" fill="clear" color="medium" (click)="ejectDisk(i)">
|
||||
<ion-icon color="primary" class="icon" src="/assets/icon/eject.svg"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-spinner *ngIf="d.$ejecting$ | async" name="lines" color="medium"></ion-spinner>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
|
||||
<ion-content *ngIf="$loading$ | async" class="ion-padding-top">
|
||||
<ion-spinner class="center" name="lines" color="warning"></ion-spinner>
|
||||
</ion-content>
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { pauseFor } from 'src/app/util/misc.util'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { DiskInfo } from 'src/app/models/server-model'
|
||||
import { markAsLoadingDuringP } from 'src/app/services/loader.service'
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
import { AlertController } from '@ionic/angular'
|
||||
// TODO: EJECT-DISKS
|
||||
|
||||
type Ejectable<T> = T & { $ejecting$: BehaviorSubject<boolean> }
|
||||
|
||||
@Component({
|
||||
selector: 'external-drives',
|
||||
templateUrl: './external-drives.page.html',
|
||||
styleUrls: ['./external-drives.page.scss'],
|
||||
})
|
||||
export class ExternalDrivesPage {
|
||||
disks: Ejectable<DiskInfo>[] = []
|
||||
$loading$ = new BehaviorSubject(false)
|
||||
|
||||
constructor (
|
||||
private readonly apiService: ApiService,
|
||||
private readonly alertCtrl: AlertController,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
markAsLoadingDuringP(this.$loading$, this.fetchDisks())
|
||||
}
|
||||
|
||||
async doRefresh (event: any) {
|
||||
await Promise.all([
|
||||
this.fetchDisks(),
|
||||
pauseFor(600),
|
||||
])
|
||||
event.target.complete()
|
||||
}
|
||||
|
||||
async fetchDisks () {
|
||||
return this.apiService.getExternalDisks().then(ds => {
|
||||
this.disks = ds
|
||||
.filter(d => !!d.partitions.find(p => !p.isMounted))
|
||||
.map(d => ({ ...d, $ejecting$: new BehaviorSubject(false)}))
|
||||
.sort( (a, b) => a.logicalname < b.logicalname ? -1 : 1 )
|
||||
})
|
||||
}
|
||||
|
||||
async ejectDisk (diskIndex: number) {
|
||||
const d = this.disks[diskIndex]
|
||||
markAsLoadingDuringP(d.$ejecting$, this.apiService.ejectExternalDisk(d.logicalname))
|
||||
.then(() => this.disks.splice(diskIndex, 1))
|
||||
.catch((e: Error) => {
|
||||
this.alertError(`Could not eject ${d.logicalname}: ${e.message}`)
|
||||
})
|
||||
}
|
||||
|
||||
async alertError (desc: string) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
backdropDismiss: true,
|
||||
message: desc,
|
||||
cssClass: 'alert-error-message',
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { ExternalDrivesPage } from './external-drives.page'
|
||||
import { GeneralSettingsPage } from './general-settings.page'
|
||||
import { Routes, RouterModule } from '@angular/router'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
import { PwaBackComponentModule } from 'src/app/components/pwa-back-button/pwa-back.component.module'
|
||||
import { ObjectConfigComponentModule } from 'src/app/components/object-config/object-config.component.module'
|
||||
// TODO: EJECT-DISKS
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: ExternalDrivesPage,
|
||||
component: GeneralSettingsPage,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -20,10 +18,11 @@ const routes: Routes = [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
SharingModule,
|
||||
ObjectConfigComponentModule,
|
||||
RouterModule.forChild(routes),
|
||||
PwaBackComponentModule,
|
||||
],
|
||||
declarations: [ExternalDrivesPage],
|
||||
declarations: [
|
||||
GeneralSettingsPage,
|
||||
],
|
||||
})
|
||||
export class ExternalDrivesPageModule { }
|
||||
export class GeneralSettingsPageModule { }
|
||||
@@ -3,25 +3,22 @@
|
||||
<ion-buttons slot="start">
|
||||
<pwa-back-button></pwa-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Config</ion-title>
|
||||
<ion-title>General Settings</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding-top">
|
||||
<ion-refresher slot="fixed" (ionRefresh)="doRefresh($event)">
|
||||
<ion-refresher-content pullingIcon="lines" refreshingSpinner="lines"></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
|
||||
<ion-item-group>
|
||||
<ion-item button (click)="presentModalValueEdit('name')">
|
||||
<ion-label>Device Name</ion-label>
|
||||
<ion-note slot="end">{{ server.name | async }}</ion-note>
|
||||
<ion-item-group *ngrxLet="patch.watch$('ui') as ui">
|
||||
<ion-item button (click)="presentModalValueEdit('name', ui['server-name'])">
|
||||
<ion-label>Embassy Name</ion-label>
|
||||
<ion-note slot="end">{{ ui['server-name'] }}</ion-note>
|
||||
</ion-item>
|
||||
<ion-item button (click)="presentModalValueEdit('autoCheckUpdates')">
|
||||
<ion-item button (click)="presentModalValueEdit('autoCheckUpdates', ui['auto-check-updates'])">
|
||||
<ion-label>Auto Check for Updates</ion-label>
|
||||
<ion-note slot="end">{{ server.autoCheckUpdates | async }}</ion-note>
|
||||
<ion-note slot="end">{{ ui['auto-check-updates'] }}</ion-note>
|
||||
</ion-item>
|
||||
<!-- <ion-item style="word-break: break-all;" button (click)="presentModalValueEdit('password', true)">
|
||||
<!-- <ion-item style="word-break: break-all;" button (click)="presentModalValueEdit('password')">
|
||||
<ion-label>Change Password</ion-label>
|
||||
<ion-note slot="end">********</ion-note>
|
||||
</ion-item> -->
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { ServerConfigService } from 'src/app/services/server-config.service'
|
||||
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
|
||||
|
||||
@Component({
|
||||
selector: 'general-settings',
|
||||
templateUrl: './general-settings.page.html',
|
||||
styleUrls: ['./general-settings.page.scss'],
|
||||
})
|
||||
export class GeneralSettingsPage {
|
||||
constructor (
|
||||
private readonly serverConfigService: ServerConfigService,
|
||||
public readonly patch: PatchDbModel,
|
||||
) { }
|
||||
|
||||
async presentModalValueEdit (key: string, current?: string): Promise<void> {
|
||||
await this.serverConfigService.presentModalValueEdit(key, current)
|
||||
}
|
||||
}
|
||||
@@ -10,26 +10,33 @@
|
||||
<ion-content class="ion-padding-top">
|
||||
|
||||
<ion-item-group>
|
||||
<ion-item lines="none">
|
||||
<!-- about -->
|
||||
<ion-item>
|
||||
<ion-label class="ion-text-wrap">
|
||||
You can connect to your Embassy over your Local Area Network (LAN). This can be useful for achieving a faster experience, as well as a fallback in case the Tor network is experiencing issues.
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item *ngIf="lanDisabled">
|
||||
<ion-label class="ion-text-wrap">
|
||||
<ion-text color="warning" [innerHtml]="lanDisabledExplanation[lanDisabled]"></ion-text>
|
||||
<p style="padding-bottom: 6px;">About</p>
|
||||
<h2>You can connect to your Embassy over your Local Area Network (LAN). This can be useful for achieving a faster experience, as well as a fallback in case the Tor network is experiencing issues.</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item>
|
||||
<ion-button slot="start" fill="clear" color="primary" (click)="viewInstructions()">View Instructions</ion-button>
|
||||
<ion-button slot="start" fill="clear" color="primary" [href]="docsUrl" target="_blank">View Instructions</ion-button>
|
||||
</ion-item>
|
||||
|
||||
<ng-container *ngIf="lanDisabled">
|
||||
<ion-item-divider></ion-item-divider>
|
||||
<ion-item>
|
||||
<ion-label class="ion-text-wrap">
|
||||
<p style="padding-bottom: 4px;">Setup</p>
|
||||
<ion-text color="warning" [innerHtml]="lanDisabledExplanation[lanDisabled]"></ion-text>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
|
||||
<!-- Refresh Network -->
|
||||
<ion-item-divider></ion-item-divider>
|
||||
|
||||
<ion-item>
|
||||
<ion-label class="ion-text-wrap">
|
||||
If you are having issues connecting to your Embassy or services over LAN, you can try refreshing the network by clicking the button below.
|
||||
<p style="padding-bottom: 6px;">Troubleshooting</p>
|
||||
<h2>If you are having issues connecting to your Embassy over LAN, try refreshing the network by clicking the button below.</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item>
|
||||
@@ -39,10 +46,10 @@
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
|
||||
<ion-item-divider></ion-item-divider>
|
||||
|
||||
<!-- Certificate and Lan Address -->
|
||||
<ng-container *ngIf="!lanDisabled">
|
||||
<ion-item-divider class="borderless"></ion-item-divider>
|
||||
<ion-item-divider>Certificate and Address</ion-item-divider>
|
||||
<!-- Certificate -->
|
||||
<ion-item>
|
||||
<ion-label class="ion-text-wrap">
|
||||
<h2>Root Certificate Authority</h2>
|
||||
@@ -52,20 +59,21 @@
|
||||
<ion-icon slot="icon-only" name="download-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
<!-- URL -->
|
||||
|
||||
<ion-item>
|
||||
<ion-label class="ion-text-wrap">
|
||||
<h2>LAN Address</h2>
|
||||
<p>{{ lanAddress }}</p>
|
||||
<p>https://{{ patch.watch$('server-info', 'lan-address') | ngrxPush }}</p>
|
||||
</ion-label>
|
||||
<ion-button slot="end" fill="clear" (click)="copyLAN()">
|
||||
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
<ion-item-divider></ion-item-divider>
|
||||
</ng-container>
|
||||
</ion-item-group>
|
||||
|
||||
<!-- hidden element for downloading cert -->
|
||||
<a id="install-cert" href="/api/v0/certificate" download="Embassy Local CA.crt"></a>
|
||||
<a id="install-cert" href="/public/local.crt" download="Embassy Local CA.crt"></a>
|
||||
|
||||
</ion-content>
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { isPlatform, ToastController } from '@ionic/angular'
|
||||
import { ServerModel } from 'src/app/models/server-model'
|
||||
import { copyToClipboard } from 'src/app/util/web.util'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { LoaderService } from 'src/app/services/loader.service'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
|
||||
|
||||
@Component({
|
||||
selector: 'lan',
|
||||
@@ -12,40 +12,28 @@ import { ApiService } from 'src/app/services/api/api.service'
|
||||
styleUrls: ['./lan.page.scss'],
|
||||
})
|
||||
export class LANPage {
|
||||
torDocs = 'docs.privacy34kn4ez3y3nijweec6w4g54i3g54sdv7r5mr6soma3w4begyd.onion/user-manual/general/lan-setup'
|
||||
lanDocs = 'docs.start9labs.com/user-manual/general/lan-setup'
|
||||
|
||||
lanAddress: string
|
||||
fullDocumentationLink: string
|
||||
lanDisabled: LanSetupIssue
|
||||
readonly lanDisabledExplanation: { [k in LanSetupIssue]: string } = {
|
||||
NotDesktop: `We have detected you are on a mobile device. To setup LAN on a mobile device, use the Start9 Setup App.`,
|
||||
NotTor: `We have detected you are not using a Tor connection. For security reasons, you must setup LAN over a Tor connection. Please navigate to your Embassy Tor Address and try again.`,
|
||||
NotDesktop: `You are using a mobile device. To setup LAN on a mobile device, please use the Start9 Setup App.`,
|
||||
NotTor: `For security reasons, you must setup LAN over a Tor connection. Please navigate to your Embassy Tor Address and try again.`,
|
||||
}
|
||||
readonly docsUrl = 'https://docs.start9.com/user-manual/general/lan-setup'
|
||||
|
||||
constructor (
|
||||
private readonly serverModel: ServerModel,
|
||||
private readonly toastCtrl: ToastController,
|
||||
private readonly config: ConfigService,
|
||||
private readonly loader: LoaderService,
|
||||
private readonly apiService: ApiService,
|
||||
public readonly patch: PatchDbModel,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
if (isPlatform('ios') || isPlatform('android')) {
|
||||
this.lanDisabled = 'NotDesktop'
|
||||
this.lanDisabled = LanSetupIssue.NOT_DESKTOP
|
||||
} else if (!this.config.isTor()) {
|
||||
this.lanDisabled = 'NotTor'
|
||||
this.lanDisabled = LanSetupIssue.NOT_TOR
|
||||
}
|
||||
|
||||
if (this.config.isTor()) {
|
||||
this.fullDocumentationLink = `http://${this.torDocs}`
|
||||
} else {
|
||||
this.fullDocumentationLink = `https://${this.lanDocs}`
|
||||
}
|
||||
|
||||
const server = this.serverModel.peek()
|
||||
this.lanAddress = `https://${server.serverId}.local`
|
||||
}
|
||||
|
||||
async refreshLAN (): Promise<void> {
|
||||
@@ -54,20 +42,12 @@ export class LANPage {
|
||||
spinner: 'lines',
|
||||
cssClass: 'loader',
|
||||
}).displayDuringAsync( async () => {
|
||||
await this.apiService.refreshLAN()
|
||||
await this.apiService.refreshLan({ })
|
||||
}).catch(e => {
|
||||
console.error(e)
|
||||
})
|
||||
}
|
||||
|
||||
viewInstructions (): void {
|
||||
if (this.config.isConsulate) {
|
||||
this.copyInstructions()
|
||||
} else {
|
||||
window.open(this.fullDocumentationLink, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
async copyLAN (): Promise <void> {
|
||||
const message = await copyToClipboard(this.lanAddress).then(success => success ? 'copied to clipboard!' : 'failed to copy')
|
||||
|
||||
@@ -80,23 +60,12 @@ export class LANPage {
|
||||
await toast.present()
|
||||
}
|
||||
|
||||
async copyInstructions (): Promise < void > {
|
||||
const message = await copyToClipboard(this.fullDocumentationLink).then(
|
||||
success => success ? 'copied link to clipboard!' : 'failed to copy',
|
||||
)
|
||||
|
||||
const toast = await this.toastCtrl.create({
|
||||
header: message,
|
||||
position: 'bottom',
|
||||
duration: 1000,
|
||||
cssClass: 'notification-toast',
|
||||
})
|
||||
await toast.present()
|
||||
}
|
||||
|
||||
installCert (): void {
|
||||
document.getElementById('install-cert').click()
|
||||
}
|
||||
}
|
||||
|
||||
type LanSetupIssue = 'NotTor' | 'NotDesktop'
|
||||
enum LanSetupIssue {
|
||||
NOT_TOR = 'NotTor',
|
||||
NOT_DESKTOP = 'NotDesktop',
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { ServerBackupPage } from './server-backup.page'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { BackupConfirmationComponentModule } from 'src/app/modals/backup-confirmation/backup-confirmation.component.module'
|
||||
import { PwaBackComponentModule } from 'src/app/components/pwa-back-button/pwa-back.component.module'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: ServerBackupPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
BackupConfirmationComponentModule,
|
||||
PwaBackComponentModule,
|
||||
],
|
||||
declarations: [
|
||||
ServerBackupPage,
|
||||
],
|
||||
})
|
||||
export class ServerBackupPageModule { }
|
||||
@@ -0,0 +1,69 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<pwa-back-button></pwa-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Create 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">
|
||||
|
||||
<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 to back up your Embassy. Because are diff-based, so your first backup will likely take much longer than subsequent backups.
|
||||
</p>
|
||||
<br />
|
||||
<p>
|
||||
During backup, your Embassy will be unusable.
|
||||
</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 === 'create'" class="ion-text-wrap" color="warning">No partitions available. To begin a backup, insert a storage device into your Embassy.</ion-text>
|
||||
<ion-text *ngIf="type === 'restore'" class="ion-text-wrap" color="warning">No partitions available. Insert the storage device containing the backup you wish 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>
|
||||
@@ -0,0 +1,81 @@
|
||||
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'
|
||||
|
||||
@Component({
|
||||
selector: 'server-backup',
|
||||
templateUrl: './server-backup.page.html',
|
||||
styleUrls: ['./server-backup.page.scss'],
|
||||
})
|
||||
export class ServerBackupPage {
|
||||
disks: DiskInfo
|
||||
loading = true
|
||||
error: string
|
||||
allPartitionsMounted: boolean
|
||||
|
||||
constructor (
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
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.create(logicalname, data.password)
|
||||
})
|
||||
|
||||
return await m.present()
|
||||
}
|
||||
|
||||
private async create (logicalname: string, password: string): Promise<void> {
|
||||
this.error = ''
|
||||
|
||||
const loader = await this.loadingCtrl.create({
|
||||
spinner: 'lines',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.apiService.createBackup({ logicalname, password })
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
this.error = e.message
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { ServerConfigService } from 'src/app/services/server-config.service'
|
||||
import { pauseFor } from 'src/app/util/misc.util'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { PropertySubject } from 'src/app/util/property-subject.util'
|
||||
import { S9Server, ServerModel } from 'src/app/models/server-model'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
|
||||
@Component({
|
||||
selector: 'server-config',
|
||||
templateUrl: './server-config.page.html',
|
||||
styleUrls: ['./server-config.page.scss'],
|
||||
})
|
||||
export class ServerConfigPage {
|
||||
server: PropertySubject<S9Server>
|
||||
|
||||
constructor (
|
||||
private readonly serverModel: ServerModel,
|
||||
private readonly serverConfigService: ServerConfigService,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly navController: NavController,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
this.server = this.serverModel.watch()
|
||||
}
|
||||
|
||||
async doRefresh (event: any) {
|
||||
await Promise.all([
|
||||
this.apiService.getServer(),
|
||||
pauseFor(600),
|
||||
])
|
||||
event.target.complete()
|
||||
}
|
||||
|
||||
async presentModalValueEdit (key: string, add = false): Promise<void> {
|
||||
await this.serverConfigService.presentModalValueEdit(key, add)
|
||||
}
|
||||
|
||||
navigateBack () {
|
||||
this.navController.back()
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,10 @@
|
||||
<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>
|
||||
<p style="white-space: pre-line;">{{ logs }}</p>
|
||||
|
||||
<ion-spinner *ngIf="loading; else loaded" class="center" name="lines" color="warning"></ion-spinner>
|
||||
|
||||
<ng-template #loaded>
|
||||
<p style="white-space: pre-line;">{{ logs }}</p>
|
||||
</ng-template>
|
||||
</ion-content>
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { Component, ViewChild } from '@angular/core'
|
||||
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: 'server-logs',
|
||||
@@ -12,7 +9,7 @@ import { BehaviorSubject } from 'rxjs'
|
||||
})
|
||||
export class ServerLogsPage {
|
||||
@ViewChild(IonContent, { static: false }) private content: IonContent
|
||||
$loading$ = new BehaviorSubject(true)
|
||||
loading = true
|
||||
error = ''
|
||||
logs: string
|
||||
|
||||
@@ -20,25 +17,23 @@ export class ServerLogsPage {
|
||||
private readonly apiService: ApiService,
|
||||
) { }
|
||||
|
||||
async ngOnInit () {
|
||||
markAsLoadingDuringP(this.$loading$, Promise.all([
|
||||
this.getLogs(),
|
||||
pauseFor(600),
|
||||
]))
|
||||
ngOnInit () {
|
||||
this.getLogs()
|
||||
}
|
||||
|
||||
async getLogs () {
|
||||
this.logs = ''
|
||||
this.$loading$.next(true)
|
||||
this.loading = true
|
||||
try {
|
||||
this.logs = (await this.apiService.getServerLogs()).join('\n')
|
||||
const logs = await this.apiService.getServerLogs({ })
|
||||
this.logs = logs.map(l => `${l.timestamp} ${l.log}`).join('\n\n')
|
||||
this.error = ''
|
||||
setTimeout(async () => await this.content.scrollToBottom(100), 200)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
this.error = e.message
|
||||
} finally {
|
||||
this.$loading$.next(false)
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<ion-buttons slot="start">
|
||||
<pwa-back-button></pwa-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Metrics</ion-title>
|
||||
<ion-title>Monitor</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
@@ -12,18 +12,20 @@
|
||||
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
|
||||
</ion-item>
|
||||
|
||||
<ion-spinner *ngIf="loading" class="center" name="lines" color="warning"></ion-spinner>
|
||||
<ion-spinner *ngIf="loading; else loaded" class="center" name="lines" color="warning"></ion-spinner>
|
||||
|
||||
<ion-item-group *ngFor="let metricGroup of metrics | keyvalue : asIsOrder">
|
||||
<ion-item-divider class="divider">{{ metricGroup.key }}</ion-item-divider>
|
||||
<ion-item *ngFor="let metric of metricGroup.value | keyvalue : asIsOrder">
|
||||
<ion-label>
|
||||
<ion-text color="medium">{{ metric.key }}</ion-text>
|
||||
</ion-label>
|
||||
<ion-note *ngIf="metric.value" slot="end" class="metric-note">
|
||||
<ion-text style="color: white;">{{ metric.value.value }} {{ metric.value.unit }}</ion-text>
|
||||
</ion-note>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
<ng-template #loaded>
|
||||
<ion-item-group *ngFor="let metricGroup of metrics | keyvalue : asIsOrder">
|
||||
<ion-item-divider class="divider">{{ metricGroup.key }}</ion-item-divider>
|
||||
<ion-item *ngFor="let metric of metricGroup.value | keyvalue : asIsOrder">
|
||||
<ion-label>
|
||||
<ion-text color="medium">{{ metric.key }}</ion-text>
|
||||
</ion-label>
|
||||
<ion-note *ngIf="metric.value" slot="end" class="metric-note">
|
||||
<ion-text style="color: white;">{{ metric.value.value }} {{ metric.value.unit }}</ion-text>
|
||||
</ion-note>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ng-template>
|
||||
|
||||
</ion-content>
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { ServerMetrics } from 'src/app/models/server-model'
|
||||
import { ServerMetrics } from 'src/app/services/api/api-types'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { pauseFor } from 'src/app/util/misc.util'
|
||||
|
||||
@@ -18,15 +18,11 @@ export class ServerMetricsPage {
|
||||
private readonly apiService: ApiService,
|
||||
) { }
|
||||
|
||||
async ngOnInit () {
|
||||
await Promise.all([
|
||||
this.getMetrics(),
|
||||
pauseFor(600),
|
||||
])
|
||||
|
||||
this.loading = false
|
||||
|
||||
this.startDaemon()
|
||||
ngOnInit () {
|
||||
this.getMetrics().then(() => {
|
||||
this.loading = false
|
||||
this.startDaemon()
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy () {
|
||||
@@ -47,7 +43,7 @@ export class ServerMetricsPage {
|
||||
|
||||
async getMetrics (): Promise<void> {
|
||||
try {
|
||||
const metrics = await this.apiService.getServerMetrics()
|
||||
const metrics = await this.apiService.getServerMetrics({ })
|
||||
Object.keys(metrics).forEach(outerKey => {
|
||||
if (!this.metrics[outerKey]) {
|
||||
this.metrics[outerKey] = metrics[outerKey]
|
||||
|
||||
@@ -1,48 +1,43 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { Routes, RouterModule } from '@angular/router'
|
||||
import { AuthGuard } from '../../guards/auth.guard'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
canActivate: [AuthGuard],
|
||||
loadChildren: () => import('./server-show/server-show.module').then(m => m.ServerShowPageModule),
|
||||
},
|
||||
{
|
||||
path: 'backup',
|
||||
loadChildren: () => import('./server-backup/server-backup.module').then(m => m.ServerBackupPageModule),
|
||||
},
|
||||
{
|
||||
path: 'specs',
|
||||
canActivate: [AuthGuard],
|
||||
loadChildren: () => import('./server-specs/server-specs.module').then(m => m.ServerSpecsPageModule),
|
||||
},
|
||||
{
|
||||
path: 'metrics',
|
||||
canActivate: [AuthGuard],
|
||||
loadChildren: () => import('./server-metrics/server-metrics.module').then(m => m.ServerMetricsPageModule),
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
canActivate: [AuthGuard],
|
||||
loadChildren: () => import('./server-logs/server-logs.module').then(m => m.ServerLogsPageModule),
|
||||
},
|
||||
{
|
||||
path: 'config',
|
||||
canActivate: [AuthGuard],
|
||||
loadChildren: () => import('./server-config/server-config.module').then(m => m.ServerConfigPageModule),
|
||||
path: 'settings',
|
||||
loadChildren: () => import('./general-settings/general-settings.module').then(m => m.GeneralSettingsPageModule),
|
||||
},
|
||||
{
|
||||
path: 'wifi',
|
||||
canActivate: [AuthGuard],
|
||||
loadChildren: () => import('./wifi/wifi.module').then(m => m.WifiListPageModule),
|
||||
},
|
||||
{
|
||||
path: 'lan',
|
||||
canActivate: [AuthGuard],
|
||||
loadChildren: () => import('./lan/lan.module').then(m => m.LANPageModule),
|
||||
},
|
||||
{
|
||||
path: 'developer',
|
||||
canActivate: [AuthGuard],
|
||||
loadChildren: () => import('./developer-routes/developer-routing.module').then( m => m.DeveloperRoutingModule),
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>{{ server.name | async }}</ion-title>
|
||||
<ion-title>{{ patch.watch$('ui', 'server-name') | ngrxPush }}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<badge-menu-button></badge-menu-button>
|
||||
</ion-buttons>
|
||||
@@ -8,78 +8,64 @@
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding-top ion-padding-bottom">
|
||||
<ng-container *ngIf="updating">
|
||||
<ion-item class="ion-text-center">
|
||||
<div style="display: flex; justify-content: center; width: 100%;">
|
||||
<ion-text class="ion-text-wrap" style="margin-right: 5px; margin-top: 5px" color="primary">Server Updating</ion-text>
|
||||
<ion-spinner style="margin-left: 5px" name="lines"></ion-spinner>
|
||||
</div>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="!updating">
|
||||
<ion-item-divider>Backups</ion-item-divider>
|
||||
|
||||
<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 [routerLink]="['backup']">
|
||||
<ion-icon slot="start" name="save-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Create Backup</ion-text></ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item-divider>Insights</ion-item-divider>
|
||||
|
||||
<ion-item-group>
|
||||
<ion-item [routerLink]="['specs']">
|
||||
<ion-icon slot="start" name="information-circle-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">About</ion-text></ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item-group>
|
||||
<ion-item [routerLink]="['metrics']">
|
||||
<ion-icon slot="start" name="pulse" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Monitor</ion-text></ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item [routerLink]="['specs']">
|
||||
<ion-icon slot="start" name="information-circle-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">About</ion-text></ion-label>
|
||||
</ion-item>
|
||||
<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>
|
||||
|
||||
<ion-item [routerLink]="['metrics']">
|
||||
<ion-icon slot="start" name="pulse" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Monitor</ion-text></ion-label>
|
||||
</ion-item>
|
||||
<ion-item-divider>Settings</ion-item-divider>
|
||||
|
||||
<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>
|
||||
<ion-item lines="none" [routerLink]="['settings']">
|
||||
<ion-icon slot="start" name="cog-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">General</ion-text></ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item-divider></ion-item-divider>
|
||||
<ion-item [routerLink]="['lan']">
|
||||
<ion-icon slot="start" name="home-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">LAN</ion-text></ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item lines="none" [routerLink]="['config']">
|
||||
<ion-icon slot="start" name="cog-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Config</ion-text></ion-label>
|
||||
</ion-item>
|
||||
<ion-item [routerLink]="['wifi']">
|
||||
<ion-icon slot="start" name="wifi" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">WiFi</ion-text></ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item [routerLink]="['lan']">
|
||||
<ion-icon slot="start" name="home-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">LAN Settings</ion-text></ion-label>
|
||||
</ion-item>
|
||||
<ion-item lines="none" [routerLink]="['developer']">
|
||||
<ion-icon slot="start" name="terminal-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Developer Options</ion-text></ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item [routerLink]="['wifi']">
|
||||
<ion-icon slot="start" name="wifi" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">WiFi Settings</ion-text></ion-label>
|
||||
</ion-item>
|
||||
<ion-item-divider>Power</ion-item-divider>
|
||||
|
||||
<ion-item-divider></ion-item-divider>
|
||||
<ion-item button (click)="presentAlertRestart()">
|
||||
<ion-icon slot="start" name="reload-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Restart</ion-text></ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item lines="none" [routerLink]="['developer']">
|
||||
<ion-icon slot="start" name="terminal-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Developer Options</ion-text></ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item-divider></ion-item-divider>
|
||||
|
||||
<ion-item button (click)="presentAlertRestart()">
|
||||
<ion-icon slot="start" name="reload-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Restart</ion-text></ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item button lines="none" (click)="presentAlertShutdown()">
|
||||
<ion-icon slot="start" name="power" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Shutdown</ion-text></ion-label>
|
||||
</ion-item>
|
||||
|
||||
</ion-item-group>
|
||||
</ng-container>
|
||||
<ion-item button lines="none" (click)="presentAlertShutdown()">
|
||||
<ion-icon slot="start" name="power" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Shutdown</ion-text></ion-label>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
@@ -1,14 +1,10 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { LoadingOptions } from '@ionic/core'
|
||||
import { ServerModel, ServerStatus } from 'src/app/models/server-model'
|
||||
import { AlertController } from '@ionic/angular'
|
||||
import { S9Server } from 'src/app/models/server-model'
|
||||
import { AlertController, ModalController } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { SyncDaemon } from 'src/app/services/sync.service'
|
||||
import { Subscription, Observable } from 'rxjs'
|
||||
import { PropertySubject, toObservable } from 'src/app/util/property-subject.util'
|
||||
import { doForAtLeast } from 'src/app/util/misc.util'
|
||||
import { LoaderService } from 'src/app/services/loader.service'
|
||||
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
|
||||
import { ServerStatus } from 'src/app/models/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'server-show',
|
||||
@@ -16,66 +12,16 @@ import { LoaderService } from 'src/app/services/loader.service'
|
||||
styleUrls: ['server-show.page.scss'],
|
||||
})
|
||||
export class ServerShowPage {
|
||||
error = ''
|
||||
s9Host$: Observable<string>
|
||||
|
||||
server: PropertySubject<S9Server>
|
||||
currentServer: S9Server
|
||||
|
||||
subsToTearDown: Subscription[] = []
|
||||
|
||||
updatingFreeze = false
|
||||
updating = false
|
||||
ServerStatus = ServerStatus
|
||||
|
||||
constructor (
|
||||
private readonly serverModel: ServerModel,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly loader: LoaderService,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly syncDaemon: SyncDaemon,
|
||||
private readonly modalCtrl: ModalController,
|
||||
public readonly patch: PatchDbModel,
|
||||
) { }
|
||||
|
||||
async ngOnInit () {
|
||||
this.server = this.serverModel.watch()
|
||||
this.subsToTearDown.push(
|
||||
// serverUpdateSubscription
|
||||
this.server.status.subscribe(status => {
|
||||
if (status === ServerStatus.UPDATING) {
|
||||
this.updating = true
|
||||
} else {
|
||||
if (!this.updatingFreeze) { this.updating = false }
|
||||
}
|
||||
}),
|
||||
// currentServerSubscription
|
||||
toObservable(this.server).subscribe(currentServerProperties => {
|
||||
this.currentServer = currentServerProperties
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
ionViewDidEnter () {
|
||||
this.error = ''
|
||||
}
|
||||
|
||||
ngOnDestroy () {
|
||||
this.subsToTearDown.forEach(s => s.unsubscribe())
|
||||
}
|
||||
|
||||
async doRefresh (event: any) {
|
||||
await doForAtLeast([this.getServerAndApps()], 600)
|
||||
event.target.complete()
|
||||
}
|
||||
|
||||
async getServerAndApps (): Promise<void> {
|
||||
try {
|
||||
this.syncDaemon.sync()
|
||||
this.error = ''
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
this.error = e.message
|
||||
}
|
||||
}
|
||||
|
||||
async presentAlertRestart () {
|
||||
const alert = await this.alertCtrl.create({
|
||||
backdropDismiss: false,
|
||||
@@ -122,27 +68,26 @@ export class ServerShowPage {
|
||||
|
||||
private async restart () {
|
||||
this.loader
|
||||
.of(LoadingSpinner(`Restarting ${this.currentServer.name}...`))
|
||||
.of(LoadingSpinner(`Restarting...`))
|
||||
.displayDuringAsync( async () => {
|
||||
this.serverModel.markUnreachable()
|
||||
await this.apiService.restartServer()
|
||||
// this.serverModel.markUnreachable()
|
||||
await this.apiService.restartServer({ })
|
||||
})
|
||||
.catch(e => this.setError(e))
|
||||
}
|
||||
|
||||
private async shutdown () {
|
||||
this.loader
|
||||
.of(LoadingSpinner(`Shutting down ${this.currentServer.name}...`))
|
||||
.of(LoadingSpinner(`Shutting down...`))
|
||||
.displayDuringAsync( async () => {
|
||||
this.serverModel.markUnreachable()
|
||||
await this.apiService.shutdownServer()
|
||||
// this.serverModel.markUnreachable()
|
||||
await this.apiService.shutdownServer({ })
|
||||
})
|
||||
.catch(e => this.setError(e))
|
||||
}
|
||||
|
||||
setError (e: Error) {
|
||||
console.error(e)
|
||||
this.error = e.message
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,25 +8,46 @@
|
||||
</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-item *ngIf="error" style="margin-bottom: 16px;">
|
||||
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
|
||||
<ion-item-divider>Basic</ion-item-divider>
|
||||
|
||||
<ion-item-group *ngIf="patch.watch$('server-info') | ngrxPush as server">
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>Version</h2>
|
||||
<p>{{ server.version | displayEmver }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item-group>
|
||||
<ion-item *ngFor="let spec of (server.specs | async) | keyvalue : asIsOrder" [class.break-all]="spec.key === 'Tor Address'">
|
||||
<ion-label class="ion-text-wrap">
|
||||
<h2>{{ spec.key }}</h2>
|
||||
<p *ngIf="spec.value | isValidEmver">{{ spec.value | displayEmver }}</p>
|
||||
<p *ngIf="!(spec.value | isValidEmver)">{{ spec.value }}</p>
|
||||
</ion-label>
|
||||
<ion-button slot="end" *ngIf="spec.key === 'Tor Address'" fill="clear" (click)="copyTor()">
|
||||
<ion-icon slot="icon-only" name="copy-outline" color="primary"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ng-container>
|
||||
<ion-item-divider>Addresses</ion-item-divider>
|
||||
|
||||
<ion-item>
|
||||
<ion-label class="break-all">
|
||||
<h2>Tor Address</h2>
|
||||
<p>http://{{ server['tor-address'] }}</p>
|
||||
</ion-label>
|
||||
<ion-button slot="end" fill="clear" (click)="copy('http://' + server['tor-address'])">
|
||||
<ion-icon slot="icon-only" name="copy-outline" color="primary"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
<ion-item>
|
||||
<ion-label class="break-all">
|
||||
<h2>LAN Address</h2>
|
||||
<p>https://{{ server['lan-address'] }}</p>
|
||||
</ion-label>
|
||||
<ion-button slot="end" fill="clear" (click)="copy('https://' + server['lan-address'])">
|
||||
<ion-icon slot="icon-only" name="copy-outline" color="primary"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
|
||||
<ion-item-divider>Specs</ion-item-divider>
|
||||
|
||||
<ion-item *ngFor="let spec of server.specs | keyvalue : asIsOrder">
|
||||
<ion-label>
|
||||
<h2>{{ spec.key }}</h2>
|
||||
<p>{{ spec.value }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
|
||||
</ion-content>
|
||||
@@ -1,11 +1,7 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { S9Server } from 'src/app/models/server-model'
|
||||
import { ToastController } from '@ionic/angular'
|
||||
import { copyToClipboard } from 'src/app/util/web.util'
|
||||
import { PropertySubject } from 'src/app/util/property-subject.util'
|
||||
import { ModelPreload } from 'src/app/models/model-preload'
|
||||
import { markAsLoadingDuring$ } from 'src/app/services/loader.service'
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
|
||||
|
||||
@Component({
|
||||
selector: 'server-specs',
|
||||
@@ -13,28 +9,15 @@ import { BehaviorSubject } from 'rxjs'
|
||||
styleUrls: ['./server-specs.page.scss'],
|
||||
})
|
||||
export class ServerSpecsPage {
|
||||
server: PropertySubject<S9Server> = { } as any
|
||||
error = ''
|
||||
$loading$ = new BehaviorSubject(true)
|
||||
|
||||
constructor (
|
||||
private readonly toastCtrl: ToastController,
|
||||
private readonly preload: ModelPreload,
|
||||
public readonly patch: PatchDbModel,
|
||||
) { }
|
||||
|
||||
async ngOnInit () {
|
||||
markAsLoadingDuring$(this.$loading$, this.preload.server()).subscribe({
|
||||
next: s => this.server = s,
|
||||
error: e => {
|
||||
console.error(e)
|
||||
this.error = e.message
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async copyTor () {
|
||||
async copy (address: string) {
|
||||
let message = ''
|
||||
await copyToClipboard((this.server.specs.getValue()['Tor Address'] as string).trim() || '')
|
||||
await copyToClipboard(address || '')
|
||||
.then(success => { message = success ? 'copied to clipboard!' : 'failed to copy'})
|
||||
|
||||
const toast = await this.toastCtrl.create({
|
||||
|
||||
@@ -34,13 +34,13 @@
|
||||
<ion-grid style="margin-top: 40px;">
|
||||
<ion-row>
|
||||
<ion-col size="6">
|
||||
<ion-button [disabled]="!ssid" expand="block" fill="outline" color="primary" (click)="add()">
|
||||
Save for Later
|
||||
<ion-button class="ion-text-wrap" [disabled]="!ssid" expand="block" fill="outline" color="primary" (click)="save()">
|
||||
<p>Save</p>
|
||||
</ion-button>
|
||||
</ion-col>
|
||||
<ion-col size="6">
|
||||
<ion-button [disabled]="!ssid" expand="block" fill="outline" color="primary" (click)="addAndConnect()">
|
||||
Save and Connect Now
|
||||
<ion-button class="ion-text-wrap" [disabled]="!ssid" expand="block" fill="outline" color="primary" (click)="saveAndConnect()">
|
||||
<p>Save & Connect</p>
|
||||
</ion-button>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { NavController } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { WifiService } from '../wifi.service'
|
||||
import { LoaderService } from 'src/app/services/loader.service'
|
||||
import { ServerModel } from 'src/app/models/server-model'
|
||||
|
||||
@Component({
|
||||
selector: 'wifi-add',
|
||||
@@ -22,18 +21,22 @@ export class WifiAddPage {
|
||||
private readonly apiService: ApiService,
|
||||
private readonly loader: LoaderService,
|
||||
private readonly wifiService: WifiService,
|
||||
private readonly serverModel: ServerModel,
|
||||
) { }
|
||||
|
||||
async add (): Promise<void> {
|
||||
async save (): Promise<void> {
|
||||
this.error = ''
|
||||
this.loader.of({
|
||||
message: 'Saving...',
|
||||
spinner: 'lines',
|
||||
cssClass: 'loader',
|
||||
}).displayDuringAsync( async () => {
|
||||
await this.apiService.addWifi(this.ssid, this.password, this.countryCode, false)
|
||||
this.wifiService.addWifi(this.ssid)
|
||||
}).displayDuringAsync(async () => {
|
||||
await this.apiService.addWifi({
|
||||
ssid: this.ssid,
|
||||
password: this.password,
|
||||
country: this.countryCode,
|
||||
priority: 0,
|
||||
connect: false,
|
||||
})
|
||||
this.navCtrl.back()
|
||||
}).catch(e => {
|
||||
console.error(e)
|
||||
@@ -41,23 +44,25 @@ export class WifiAddPage {
|
||||
})
|
||||
}
|
||||
|
||||
async addAndConnect (): Promise<void> {
|
||||
async saveAndConnect (): Promise<void> {
|
||||
this.error = ''
|
||||
this.loader.of({
|
||||
message: 'Connecting. This could take while...',
|
||||
spinner: 'lines',
|
||||
cssClass: 'loader',
|
||||
}).displayDuringAsync( async () => {
|
||||
const current = this.serverModel.peek().wifi.current
|
||||
await this.apiService.addWifi(this.ssid, this.password, this.countryCode, true)
|
||||
const success = await this.wifiService.confirmWifi(this.ssid)
|
||||
if (success) {
|
||||
this.navCtrl.back()
|
||||
this.wifiService.presentAlertSuccess(this.ssid, current)
|
||||
} else {
|
||||
this.wifiService.presentToastFail()
|
||||
}
|
||||
}).catch (e => {
|
||||
}).displayDuringAsync(async () => {
|
||||
await this.apiService.addWifi({
|
||||
ssid: this.ssid,
|
||||
password: this.password,
|
||||
country: this.countryCode,
|
||||
priority: 0,
|
||||
connect: true,
|
||||
})
|
||||
const success = this.wifiService.confirmWifi(this.ssid)
|
||||
if (success) {
|
||||
this.navCtrl.back()
|
||||
}
|
||||
}).catch (e => {
|
||||
console.error(e)
|
||||
this.error = e.message
|
||||
})
|
||||
|
||||
@@ -4,6 +4,7 @@ import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { WifiListPage } from './wifi.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 = [
|
||||
{
|
||||
@@ -22,6 +23,7 @@ const routes: Routes = [
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
PwaBackComponentModule,
|
||||
SharingModule,
|
||||
],
|
||||
declarations: [WifiListPage],
|
||||
})
|
||||
|
||||
@@ -8,11 +8,6 @@
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding-top">
|
||||
|
||||
<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>
|
||||
@@ -20,21 +15,20 @@
|
||||
<ion-item-group>
|
||||
<ion-item>
|
||||
<ion-label class="ion-text-wrap">
|
||||
<ion-text color="dark">By providing your Embassy with WiFi credentials for one or more networks, you can remove the Ethernet cable and place your Embassy anywhere.</ion-text>
|
||||
<p style="padding-bottom: 6px;">About</p>
|
||||
<h2>Embassy will automatically connect to available networks, allowing you to remove the Ethernet cable.</h2>
|
||||
<br />
|
||||
<br />
|
||||
<ion-text color="warning">Warning!</ion-text>
|
||||
<br />
|
||||
<br />
|
||||
<ion-text color="dark">Connecting, disconnecting, or changing WiFi networks can cause your Embassy and its services to become unreachable for up to an hour. Please be patient.</ion-text>
|
||||
<h2>Connecting, disconnecting, or changing WiFi networks can cause your Embassy and its services to become unreachable for up to an hour. Please be patient.</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item-divider>Saved Networks</ion-item-divider>
|
||||
<ion-item button detail="false" *ngFor="let ssid of (server.wifi | async)?.ssids" (click)="presentAction(ssid)">
|
||||
<ion-label>{{ ssid }}</ion-label>
|
||||
<ion-icon *ngIf="ssid === (server.wifi | async).current" name="wifi" color="success"></ion-icon>
|
||||
</ion-item>
|
||||
<ng-container *ngIf="patch.watch$('server-info', 'wifi') | ngrxPush as wifi">
|
||||
<ion-item button detail="false" *ngFor="let ssid of wifi.ssids" (click)="presentAction(ssid, wifi)">
|
||||
<ion-label>{{ ssid }}</ion-label>
|
||||
<ion-icon *ngIf="ssid === wifi.connected" name="wifi" color="success"></ion-icon>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
</ion-item-group>
|
||||
|
||||
<ion-fab vertical="bottom" horizontal="end" slot="fixed">
|
||||
|
||||
@@ -2,12 +2,10 @@ import { Component } from '@angular/core'
|
||||
import { ActionSheetController } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { ActionSheetButton } from '@ionic/core'
|
||||
import { pauseFor } from 'src/app/util/misc.util'
|
||||
import { WifiService } from './wifi.service'
|
||||
import { PropertySubject } from 'src/app/util/property-subject.util'
|
||||
import { S9Server } from 'src/app/models/server-model'
|
||||
import { LoaderService } from 'src/app/services/loader.service'
|
||||
import { ModelPreload } from 'src/app/models/model-preload'
|
||||
import { WiFiInfo } from 'src/app/models/patch-db/data-model'
|
||||
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
|
||||
|
||||
@Component({
|
||||
selector: 'wifi',
|
||||
@@ -15,32 +13,17 @@ import { ModelPreload } from 'src/app/models/model-preload'
|
||||
styleUrls: ['wifi.page.scss'],
|
||||
})
|
||||
export class WifiListPage {
|
||||
server: PropertySubject<S9Server> = { } as any
|
||||
error: string
|
||||
error = ''
|
||||
|
||||
constructor (
|
||||
private readonly apiService: ApiService,
|
||||
private readonly loader: LoaderService,
|
||||
private readonly actionCtrl: ActionSheetController,
|
||||
private readonly wifiService: WifiService,
|
||||
private readonly preload: ModelPreload,
|
||||
public readonly patch: PatchDbModel,
|
||||
) { }
|
||||
|
||||
async ngOnInit () {
|
||||
this.loader.displayDuring$(
|
||||
this.preload.server(),
|
||||
).subscribe(s => this.server = s)
|
||||
}
|
||||
|
||||
async doRefresh (event: any) {
|
||||
await Promise.all([
|
||||
this.apiService.getServer(),
|
||||
pauseFor(600),
|
||||
])
|
||||
event.target.complete()
|
||||
}
|
||||
|
||||
async presentAction (ssid: string) {
|
||||
async presentAction (ssid: string, wifi: WiFiInfo) {
|
||||
const buttons: ActionSheetButton[] = [
|
||||
{
|
||||
text: 'Forget',
|
||||
@@ -51,7 +34,7 @@ export class WifiListPage {
|
||||
},
|
||||
]
|
||||
|
||||
if (ssid !== this.server.wifi.getValue().current) {
|
||||
if (ssid !== wifi.connected) {
|
||||
buttons.unshift(
|
||||
{
|
||||
text: 'Connect',
|
||||
@@ -69,7 +52,7 @@ export class WifiListPage {
|
||||
await action.present()
|
||||
}
|
||||
|
||||
// Let's add country code here.
|
||||
// Let's add country code here
|
||||
async connect (ssid: string): Promise<void> {
|
||||
this.error = ''
|
||||
this.loader.of({
|
||||
@@ -77,17 +60,11 @@ export class WifiListPage {
|
||||
spinner: 'lines',
|
||||
cssClass: 'loader',
|
||||
}).displayDuringAsync(async () => {
|
||||
const current = this.server.wifi.getValue().current
|
||||
await this.apiService.connectWifi(ssid)
|
||||
const success = await this.wifiService.confirmWifi(ssid)
|
||||
if (success) {
|
||||
this.wifiService.presentAlertSuccess(ssid, current)
|
||||
} else {
|
||||
this.wifiService.presentToastFail()
|
||||
}
|
||||
await this.apiService.connectWifi({ ssid })
|
||||
this.wifiService.confirmWifi(ssid)
|
||||
}).catch(e => {
|
||||
console.error(e)
|
||||
this.error = e.message
|
||||
this.error = ''
|
||||
})
|
||||
}
|
||||
|
||||
@@ -97,13 +74,12 @@ export class WifiListPage {
|
||||
message: 'Deleting...',
|
||||
spinner: 'lines',
|
||||
cssClass: 'loader',
|
||||
}).displayDuringAsync( async () => {
|
||||
await this.apiService.deleteWifi(ssid)
|
||||
this.wifiService.removeWifi(ssid)
|
||||
}).displayDuringAsync(async () => {
|
||||
await this.apiService.deleteWifi({ ssid })
|
||||
this.error = ''
|
||||
}).catch(e => {
|
||||
console.error(e)
|
||||
this.error = e.message
|
||||
this.error = ''
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { AlertController, ToastController } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { pauseFor } from 'src/app/util/misc.util'
|
||||
import { ServerModel } from 'src/app/models/server-model'
|
||||
import { merge, Observable, timer } from 'rxjs'
|
||||
import { filter, map, take, tap } from 'rxjs/operators'
|
||||
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -10,53 +10,39 @@ import { ServerModel } from 'src/app/models/server-model'
|
||||
export class WifiService {
|
||||
|
||||
constructor (
|
||||
private readonly apiService: ApiService,
|
||||
private readonly toastCtrl: ToastController,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly serverModel: ServerModel,
|
||||
private readonly patch: PatchDbModel,
|
||||
) { }
|
||||
|
||||
addWifi (ssid: string): void {
|
||||
const wifi = this.serverModel.peek().wifi
|
||||
this.serverModel.update({ wifi: { ...wifi, ssids: [...new Set([ssid, ...wifi.ssids])] } })
|
||||
confirmWifi (ssid: string): Observable<boolean> {
|
||||
const success$ = this.patch.watch$('server-info', 'wifi', 'connected')
|
||||
.pipe(
|
||||
filter(connected => connected === ssid),
|
||||
tap(connected => this.presentAlertSuccess(connected)),
|
||||
map(_ => true),
|
||||
)
|
||||
|
||||
const timer$ = timer(20000)
|
||||
.pipe(
|
||||
map(_ => false),
|
||||
tap(_ => this.presentToastFail()),
|
||||
)
|
||||
|
||||
return merge(success$, timer$).pipe(take(1))
|
||||
}
|
||||
|
||||
removeWifi (ssid: string): void {
|
||||
const wifi = this.serverModel.peek().wifi
|
||||
this.serverModel.update({ wifi: { ...wifi, ssids: wifi.ssids.filter(s => s !== ssid) } })
|
||||
private async presentAlertSuccess (ssid: string): Promise<void> {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: `Connected to "${ssid}"`,
|
||||
message: 'Note. It may take several minutes to an hour for your Embassy to reconnect over Tor.',
|
||||
buttons: ['OK'],
|
||||
})
|
||||
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
async confirmWifi (ssid: string): Promise<boolean> {
|
||||
const timeout = 4000
|
||||
const maxAttempts = 5
|
||||
let attempts = 0
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
const start = new Date().valueOf()
|
||||
const { current, ssids } = (await this.apiService.getServer(timeout)).wifi
|
||||
const end = new Date().valueOf()
|
||||
if (current === ssid) {
|
||||
this.serverModel.update({ wifi: { current, ssids } })
|
||||
break
|
||||
} else {
|
||||
attempts++
|
||||
const diff = end - start
|
||||
await pauseFor(Math.max(2000, timeout - diff))
|
||||
if (attempts === maxAttempts) {
|
||||
this.serverModel.update({ wifi: { current, ssids } })
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
attempts++
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
return this.serverModel.peek().wifi.current === ssid
|
||||
}
|
||||
|
||||
async presentToastFail (): Promise<void> {
|
||||
private async presentToastFail (): Promise<void> {
|
||||
const toast = await this.toastCtrl.create({
|
||||
header: 'Failed to connect:',
|
||||
message: `Check credentials and try again`,
|
||||
@@ -71,20 +57,9 @@ export class WifiService {
|
||||
},
|
||||
},
|
||||
],
|
||||
cssClass: 'notification-toast',
|
||||
cssClass: 'notification-toast-error',
|
||||
})
|
||||
|
||||
await toast.present()
|
||||
}
|
||||
|
||||
async presentAlertSuccess (current: string, old?: string): Promise<void> {
|
||||
let message = 'Note. It may take a while for your Embassy to reconnect over Tor, upward of a few hours. Unplugging the device and plugging it back in may help, but it may also just need time. You may also need to hard refresh your browser cache.'
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: `Connected to "${current}"`,
|
||||
message: old ? message : 'You may now unplug your Embassy from Ethernet.<br /></br />' + message,
|
||||
buttons: ['OK'],
|
||||
})
|
||||
|
||||
await alert.present()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user