0.3.0 refactor

ui: adds overlay layer to patch-db-client

ui: getting towards mocks

ui: cleans up factory init

ui: nice type hack

ui: live api for patch

ui: api service source + http

starts up

ui: api source + http

ui: rework patchdb config, pass stashTimeout into patchDbModel

wires in temp patching into api service

ui: example of wiring patchdbmodel into page

begin integration

remove unnecessary method

linting

first data rendering

rework app initialization

http source working for ssh delete call

temp patches working

entire Embassy tab complete

not in kansas anymore

ripping, saving progress

progress for API request response types and endoint defs

Update data-model.ts

shambles, but in a good way

progress

big progress

progress

installed list working

big progress

progress

progress

begin marketplace redesign

Update api-types.ts

Update api-types.ts

marketplace improvements

cosmetic

dependencies and recommendations

begin nym auth approach

install wizard

restore flow and donations
This commit is contained in:
Aaron Greenspan
2021-02-16 13:45:09 -07:00
committed by Aiden McClelland
parent fd685ae32c
commit 594d93eb3b
238 changed files with 15137 additions and 21331 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 { }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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 { 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 { }

View File

@@ -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>

View File

@@ -0,0 +1,3 @@
.sharp-button {
--border-radius: 1px;
}

View File

@@ -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
}
}
}

View 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 { }

View 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>

View File

@@ -0,0 +1,3 @@
.sharp-button {
--border-radius: 1px;
}

View 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
}
}
}

View 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 { }

View File

@@ -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 {}

View File

@@ -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 { }

View File

@@ -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>

View File

@@ -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)
}
}
}

View 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 { }

View 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>

View 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,
) { }
}

View File

@@ -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],
})

View File

@@ -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)">

View File

@@ -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()
}
}

View File

@@ -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 { }

View File

@@ -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>

View File

@@ -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)
}
}

View File

@@ -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],
})

View File

@@ -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>

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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>

View File

@@ -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()
}
}

View File

@@ -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 { }

View File

@@ -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> -->

View File

@@ -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)
}
}

View File

@@ -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>

View File

@@ -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',
}

View File

@@ -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 { }

View File

@@ -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>

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}

View File

@@ -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>

View File

@@ -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
}
}
}

View File

@@ -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>

View File

@@ -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]

View File

@@ -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({

View File

@@ -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>

View File

@@ -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
}
}

View File

@@ -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>

View File

@@ -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({

View File

@@ -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>

View File

@@ -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
})

View File

@@ -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],
})

View File

@@ -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">

View File

@@ -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 = ''
})
}
}

View File

@@ -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()
}
}