0.2.5 initial commit

Makefile incomplete
This commit is contained in:
Aiden McClelland
2020-11-23 13:44:28 -07:00
commit 95d3845906
503 changed files with 53448 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
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'
const routes: Routes = [
{
path: '',
component: AppAvailableListPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
StatusComponentModule,
SharingModule,
PwaBackComponentModule,
BadgeMenuComponentModule,
],
declarations: [AppAvailableListPage],
})
export class AppAvailableListPageModule { }

View File

@@ -0,0 +1,54 @@
<ion-header>
<ion-toolbar>
<ion-title>Service Marketplace</ion-title>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
</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-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>
<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-content>

View File

@@ -0,0 +1,6 @@
.beneath-title {
font-size: 12px;
font-style: italic;
font-family: 'Open Sans';
padding: 1px 0px 1.5px 0px;
}

View File

@@ -0,0 +1,82 @@
import { Component, NgZone } 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'
@Component({
selector: 'app-available-list',
templateUrl: './app-available-list.page.html',
styleUrls: ['./app-available-list.page.scss'],
})
export class AppAvailableListPage {
$loading$ = new BehaviorSubject(true)
error = ''
installedAppDeltaSubscription: Subscription
apps: PropertySubjectId<AppAvailablePreview>[] = []
appsInstalled: PropertySubjectId<AppInstalledPreview>[] = []
constructor (
private readonly apiService: ApiService,
private readonly appModel: AppModel,
private readonly zone: NgZone,
) { }
async ngOnInit () {
this.installedAppDeltaSubscription = this.appModel
.watchDelta('update')
.subscribe(({ id }) => this.mergeInstalledProps(id))
markAsLoadingDuringP(this.$loading$, Promise.all([
this.getApps(),
pauseFor(600),
]))
}
ionViewDidEnter () {
this.appModel.getContents().forEach(appInstalled => this.mergeInstalledProps(appInstalled.id))
}
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.map(a => ({ id: a.id, subject: initPropertySubject(a) })),
)
this.appModel.getContents().forEach(appInstalled => this.mergeInstalledProps(appInstalled.id))
} catch (e) {
console.error(e)
this.error = e.message
}
}
}

View File

@@ -0,0 +1,42 @@
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'
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
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'
import { AppReleaseNotesPageModule } from 'src/app/modals/app-release-notes/app-release-notes.module'
const routes: Routes = [
{
path: '',
component: AppAvailableShowPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
StatusComponentModule,
DependencyListComponentModule,
RouterModule.forChild(routes),
SharingModule,
PwaBackComponentModule,
RecommendationButtonComponentModule,
BadgeMenuComponentModule,
InstallWizardComponentModule,
ErrorMessageComponentModule,
InformationPopoverComponentModule,
AppReleaseNotesPageModule,
],
declarations: [AppAvailableShowPage],
})
export class AppAvailableShowPageModule { }

View File

@@ -0,0 +1,115 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<pwa-back-button></pwa-back-button>
</ion-buttons>
<ion-title>Marketplace Details</ion-title>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
</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,
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>
<error-message [$error$]="$error$" [dismissable]="vars.id"></error-message>
<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>
</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 && 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>
<ion-item-group>
<ng-container *ngIf="recommendation">
<ion-item class="recommendation-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]="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>
</ion-label>
</ion-item>
</ng-container>
<ion-item-divider class="divider">Description</ion-item-divider>
<ion-item lines="none">
<ion-label class="ion-text-wrap">
<ion-text color="medium">
<h5>{{ vars.descriptionLong }}</h5>
</ion-text>
</ion-label>
</ion-item>
<ion-item-divider>Release Notes</ion-item-divider>
<ion-item lines="none" button details="true" [disabled]="" (click)="presentModalReleaseNotes()" [disabled]="$versionSpecificLoading$ | async">
<ion-icon slot="start" name="newspaper-outline" color="medium"></ion-icon>
<ion-label *ngIf="!($versionSpecificLoading$ | async)"><ion-text color="medium">New in {{ vars.versionViewing | displayEmver }}</ion-text></ion-label>
<ion-spinner style="display: block; margin: auto;" name="lines" color="dark" *ngIf="$versionSpecificLoading$ | async"></ion-spinner>
</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)">
<ion-icon name="help-circle-outline"></ion-icon>
</ion-button>
</ion-item-divider>
<dependency-list [$loading$]="$versionSpecificLoading$" [hostApp]="app$ | peekProperties" [dependencies]="vars.serviceRequirements"></dependency-list>
</ng-container>
<ion-item-divider></ion-item-divider>
<ion-item lines="none" button (click)="presentAlertVersions()">
<ion-icon color="medium" slot="start" name="file-tray-stacked-outline"></ion-icon>
<ion-label color="medium">Other versions</ion-label>
</ion-item>
</ion-item-group>
</ng-container>
</ion-content>

View File

@@ -0,0 +1,34 @@
// .recommendation-container {
// margin-top: 3px;
// display: grid;
// grid-template-columns: auto auto;
// justify-content: start;
// grid-column-gap: 5px;
// align-items: center;
// }
.recommendation-text {
font-style: italic;
}
// @media (min-width:1000px) {
// .recommendation-text {
// font-size: small;
// }
// }
.recommendation-error {
color: var(--ion-color-danger);
}
.main-action-button {
margin: 20px 5px 20px 5px;
}
.divider {
margin-top: 15px;
color: var(--ion-color-medium);
font-size: medium;
padding-left: 10px;
font-weight: unset;
}

View File

@@ -0,0 +1,249 @@
import { Component, HostListener, NgZone } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { AppAvailableFull, AppAvailableVersionSpecificInfo, AppDependency } 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, merge, Observable, of, Subscription } 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 { pauseFor } from 'src/app/util/misc.util'
import { AppReleaseNotesPage } from 'src/app/modals/app-release-notes/app-release-notes.page'
import { Emver } from 'src/app/services/emver.service'
import { displayEmver } from 'src/app/pipes/emver.pipe'
@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)
$versionSpecificLoading$ = new BehaviorSubject(false)
$error$ = new BehaviorSubject(undefined)
app$: PropertySubject<AppAvailableFull> = { } as any
appId: string
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.'
showMoreReleaseNotes = false
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()
}
async ngOnInit () {
this.appId = this.route.snapshot.paramMap.get('appId') as string
this.cleanup(
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(),
merge(this.$loading$, this.$versionSpecificLoading$).pipe(concatMap(l => {
if (l) {
this.showMoreReleaseNotes = false
}
return pauseFor(125)
})).subscribe(
() => this.setMoreReleaseNotes(),
),
)
}
ionViewDidEnter () {
markAsLoadingDuring$(this.$versionSpecificLoading$, this.fetchAppVersionInfo()).subscribe({
error: e => this.setError(e),
})
}
async presentPopover (information: string, ev: any) {
const popover = await this.popoverController.create({
component: InformationPopoverComponent,
event: ev,
translucent: false,
showBackdrop: true,
backdropDismiss: true,
componentProps: {
information,
},
})
return await popover.present()
}
fetchAppVersionInfo (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.syncVersionSpecificInfo(versionInfo)),
)
}
private syncVersionSpecificInfo (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
type: 'radio',
label: displayEmver(v), // appearance on screen
value: v, // literal SEM version value
checked: app.versionViewing === v,
}
}),
buttons: [
{
text: 'Cancel',
role: 'cancel',
}, {
text: 'Ok',
handler: (version: string) => {
const previousVersion = this.app$.versionViewing.getValue()
this.app$.versionViewing.next(version)
markAsLoadingDuring$(this.$versionSpecificLoading$, this.fetchAppVersionInfo(`=${version}`))
.subscribe({
error: e => {
this.setError(e)
this.app$.versionViewing.next(previousVersion)
},
})
},
},
],
})
await alert.present()
}
async install () {
const app = peekProperties(this.app$)
const { cancelled } = await wizardModal(
this.modalCtrl,
this.wizardBaker.install({
id: app.id,
title: app.title,
version: app.versionViewing,
serviceRequirements: app.serviceRequirements,
}),
)
if (cancelled) return
this.navCtrl.back()
}
async update (action: 'update' | 'downgrade') {
const app = peekProperties(this.app$)
const value = {
id: app.id,
title: app.title,
version: app.versionViewing,
serviceRequirements: app.serviceRequirements,
}
switch (action) {
case 'update':
return wizardModal(
this.modalCtrl,
this.wizardBaker.update(value),
).then(({ cancelled }) => cancelled || this.navCtrl.back())
case 'downgrade':
return wizardModal(
this.modalCtrl,
this.wizardBaker.downgrade(value),
).then(({ cancelled }) => cancelled || this.navCtrl.back())
}
}
async presentModalReleaseNotes () {
const { releaseNotes, versionViewing } = peekProperties(this.app$)
const modal = await this.modalCtrl.create({
component: AppReleaseNotesPage,
componentProps: {
releaseNotes,
version: versionViewing,
},
})
await modal.present()
}
private fetchRecommendation (): Observable<any> {
this.recommendation = history.state && history.state.installationRecommendation
if (this.recommendation) {
return from(this.fetchAppVersionInfo(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.$versionSpecificLoading$, this.fetchAppVersionInfo())),
catchError(e => of(console.error(e))),
)
}
private setError (e: Error) {
console.error(e)
this.$error$.next(e.message)
}
private setMoreReleaseNotes () {
const releaseNotes = document.getElementById(`release-notes-${this.appId}`)
if (releaseNotes) {
this.showMoreReleaseNotes = isTextOverflow(releaseNotes)
}
}
@HostListener('window:resize', ['$event'])
onResize () {
this.setMoreReleaseNotes()
}
}
function isTextOverflow (elem: any): boolean {
if (elem) {
return (elem.offsetWidth < elem.scrollWidth)
}
return false
}

View File

@@ -0,0 +1,45 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { FormsModule } from '@angular/forms'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppConfigPage } from './app-config.page'
import { ObjectConfigComponentModule } from 'src/app/components/object-config/object-config.component.module'
import { AppConfigListPageModule } from 'src/app/modals/app-config-list/app-config-list.module'
import { AppConfigObjectPageModule } from 'src/app/modals/app-config-object/app-config-object.module'
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'
const routes: Routes = [
{
path: '',
component: AppConfigPage,
// canDeactivate: [CanDeactivateGuard],
},
]
@NgModule({
imports: [
ObjectConfigComponentModule,
AppConfigListPageModule,
AppConfigObjectPageModule,
AppConfigUnionPageModule,
AppConfigValuePageModule,
SharingModule,
CommonModule,
FormsModule,
IonicModule,
RouterModule.forChild(routes),
PwaBackComponentModule,
BadgeMenuComponentModule,
RecommendationButtonComponentModule,
InformationPopoverComponentModule,
],
declarations: [AppConfigPage],
})
export class AppConfigPageModule { }

View File

@@ -0,0 +1,119 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-button (click)="cancel()">
<ion-icon name="arrow-back"></ion-icon>
</ion-button>
</ion-buttons>
<ion-title>{{ app['title'] | async }}</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>
<!-- not loading -->
<ng-container *ngIf="!($loading$ | async)">
<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><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>
<!-- presentPopover(error.moreInfo.title, error.moreInfo.description, $event) -->
<ng-container *ngIf="openErrorMoreInfo">
<p style="margin-top: 10px; color: var(--ion-color-medium);" [innerHTML]="error.moreInfo.title"></p>
<p style="margin-top: 10px; color: var(--ion-color-medium); font-size: small" [innerHTML]="error.moreInfo.description"></p>
<a style="font-size: x-small; font-style: italic;" (click)="openErrorMoreInfo = false">Hide</a>
</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-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">
<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>
</ion-label>
</ion-item>
</ng-container>
<ng-container *ngIf="recommendation && showRecommendation">
<ion-item class="recommendation-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"/>
</ion-avatar>
<ion-text style="margin: 5px; font-family: 'Montserrat'; font-size: smaller;">{{recommendation.title}}</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}}.
<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>
</ng-container>
<ion-button style="position: absolute; right: 0; top: 0" color="primary" fill="clear" (click)="dismissRecommendation()">
<ion-icon name="close-outline"></ion-icon>
</ion-button>
</div>
</ion-label>
</ion-item>
<ion-item-divider></ion-item-divider>
</ng-container>
<ion-item *ngIf="invalid" class="notifier-item">
<ion-icon size="small" slot="start" color="danger" name="warning-outline"></ion-icon>
<ion-label class="ion-text-wrap">
<p style="color: var(--ion-color-danger)">{{invalid}}</p>
</ion-label>
</ion-item>
<!-- no config -->
<ion-item *ngIf="!hasConfig">
<ion-label class="ion-text-wrap">
<p>No config options for {{ app.title | async }} {{ app.versionInstalled | async }}.</p>
</ion-label>
</ion-item>
<!-- has config -->
<ng-container *ngIf="hasConfig">
<ion-button
[disabled]="invalid || (!edited && !added && !(['NEEDS_CONFIG'] | includes: (app.status | async)))"
fill="outline"
expand="block"
style="margin: 10px"
color="primary"
(click)="save()"
>
<ion-text color="primary" style="font-weight: bold">
Save
</ion-text>
</ion-button>
<ion-item-group class="ion-text-wrap ion-padding-bottom">
<ion-item-divider>Config Options</ion-item-divider>
<object-config [cursor]="rootCursor" (onEdit)="handleObjectEdit()"></object-config>
</ion-item-group>
</ng-container>
</ng-container>
</ng-container>
</ion-content>

View File

@@ -0,0 +1,244 @@
import { Component } from '@angular/core'
import { NavController, AlertController, ModalController, PopoverController } from '@ionic/angular'
import { ActivatedRoute } from '@angular/router'
import { 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 } 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 { 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 { 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'
@Component({
selector: 'app-config',
templateUrl: './app-config.page.html',
styleUrls: ['./app-config.page.scss'],
})
export class AppConfigPage extends Cleanup {
error: { text: string, moreInfo?:
{ title: string, description: string, buttonText: string }
}
invalid: string
$loading$ = new BehaviorSubject(true)
$loadingText$ = new BehaviorSubject(undefined)
app: PropertySubject<AppInstalledFull> = { } as any
appId: string
hasConfig = false
recommendation: Recommendation | null = null
showRecommendation = true
openRecommendation = false
edited: boolean
added: boolean
rootCursor: ConfigCursor<'object'>
spec: ConfigSpec
config: object
AppStatus = AppStatus
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,
) { super() }
backButtonDefense = false
async ngOnInit () {
this.appId = this.route.snapshot.paramMap.get('appId') as string
this.route.params.pipe(take(1)).subscribe(params => {
if (params.edit) {
window.history.back()
}
})
this.cleanup(
fromEvent(window, 'popstate').subscribe(() => {
this.backButtonDefense = false
this.trackingModalCtrl.dismissAll()
}),
this.trackingModalCtrl.onCreateAny$().subscribe(() => {
if (!this.backButtonDefense) {
window.history.pushState(null, null, window.location.href + '/edit')
this.backButtonDefense = true
}
}),
this.trackingModalCtrl.onDismissAny$().subscribe(() => {
if (!this.trackingModalCtrl.anyModals && this.backButtonDefense === true) {
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 {
return of({ spec, config, dependencyConfig: null })
}
}),
map(({ spec, config, dependencyConfig }) => this.setConfig(spec, config, dependencyConfig)),
tap(() => this.$loadingText$.next(undefined)),
),
).subscribe({
error: e => {
console.error(e)
this.error = { text: e.message }
},
},
)
}
async presentPopover (title: string, description: string, ev: any) {
const information = `
<div style="font-size: medium; font-style: italic; margin: 5px 0px;">
${title}
</div>
<div>
${description}
</div>
`
const popover = await this.popoverController.create({
component: InformationPopoverComponent,
event: ev,
translucent: false,
showBackdrop: true,
backdropDismiss: true,
componentProps: {
information,
},
})
return await popover.present()
}
setConfig (spec: ConfigSpec, config: object, dependencyConfig?: object) {
this.rootCursor = dependencyConfig ? new ConfigCursor(spec, config, null, dependencyConfig) : new ConfigCursor(spec, config)
this.spec = this.rootCursor.spec().spec
this.config = this.rootCursor.config()
this.handleObjectEdit()
this.hasConfig = !isEmptyObject(this.spec)
}
dismissRecommendation () {
this.showRecommendation = false
}
dismissError () {
this.error = undefined
}
async cancel () {
if (this.edited) {
await this.presentAlertUnsaved()
} else {
this.navCtrl.back()
}
}
async save () {
const app = peekProperties(this.app)
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)
if (breakages.length) {
const { cancelled } = await wizardModal(
this.modalController,
this.wizardBaker.configure({
app,
breakages,
}),
)
if (cancelled) return { skip: true }
}
return this.apiService.patchAppConfig(app, config).then(
() => this.preload.loadInstalledApp(this.appId).then(() => ({ skip: false})),
)
})
.then(({ skip }) => {
if (skip) return
this.navCtrl.back()
})
.catch(e => this.error = { text: e.message })
}
handleObjectEdit () {
this.edited = this.rootCursor.isEdited()
this.added = this.rootCursor.isNew()
this.invalid = this.rootCursor.checkInvalid()
}
private async presentAlertUnsaved () {
const alert = await this.alertCtrl.create({
backdropDismiss: false,
header: 'Unsaved Changes',
message: 'You have unsaved changes. Are you sure you want to leave?',
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: `Leave`,
cssClass: 'alert-danger',
handler: () => {
this.navCtrl.back()
},
},
],
})
await alert.present()
}
}

View File

@@ -0,0 +1,36 @@
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 = [
{
path: '',
component: AppInstalledListPage,
},
]
@NgModule({
imports: [
CommonModule,
StatusComponentModule,
DependencyListComponentModule,
AppBackupPageModule,
SharingModule,
IonicModule,
RouterModule.forChild(routes),
PwaBackComponentModule,
BadgeMenuComponentModule,
],
declarations: [AppInstalledListPage],
})
export class AppInstalledListPageModule { }

View File

@@ -0,0 +1,53 @@
<ion-header>
<ion-toolbar>
<ion-title>Installed Services</ion-title>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content style="position: relative">
<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">
<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">
<ion-card class="installed-card" [class.installed-card-on]="(app.subject.status | async) === 'RUNNING'" style="position:relative" [routerLink]="['/services', 'installed', app.id]">
<img style="position: absolute" class="main-img" [src]="app.subject.iconURL | async | iconParse" [alt]="app.subject.title | async" />
<img class="main-img" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=">
<img class="bulb-on" *ngIf="app.subject.status | async | displayBulb: 'green'" src="assets/img/running-bulb.png"/>
<img class="bulb-on" *ngIf="app.subject.status | async | displayBulb: 'red'" src="assets/img/issue-bulb.png"/>
<img class="bulb-on" *ngIf="app.subject.status | async | displayBulb: 'yellow'" src="assets/img/warning-bulb.png"/>
<img class="bulb-off" *ngIf="app.subject.status | async | displayBulb: 'off'" src="assets/img/off-bulb.png"/>
<ion-card-header>
<status [appStatus]="app.subject.status | async" size="small"></status>
<p>{{ app.subject.title | async }}</p>
</ion-card-header>
</ion-card>
</ion-col>
</ion-row>
</ion-grid>
<div *ngIf="!apps || !apps.length" 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>
</div>
<ion-button [routerLink]="['/services','marketplace']" style="width: 50%;" fill="outline">
<ion-icon slot="start" name="cart-outline"></ion-icon>
Marketplace
</ion-button>
</div>
</ng-container>
</ion-content>

View File

@@ -0,0 +1,53 @@
.installed-card {
margin: 0;
background: linear-gradient(37deg, #333333, #131313);
border-radius: 10px;
text-align: center;
ion-card-header {
padding: 0;
status {
font-size: 9px;
font-weight: bold;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
p {
font-family: 'Montserrat';
font-size: 11px;
color: white;
margin: 0px 12px 8px 12px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
}
}
.main-img {
width: 50%;
margin: 9px;
color: white;
border-radius: var(--icon-border-radius);
}
.bulb-on {
position: absolute !important;
left: -7px !important;
top: -7px !important;
height: 25px !important;
width: 25px !important;
margin: 9px;
}
.bulb-off {
position: absolute !important;
left: -1px !important;
top: -1px !important;
height: 13px !important;
width: 13px !important;
margin: 9px;
}

View File

@@ -0,0 +1,106 @@
import { Component } from '@angular/core'
import { AppModel } 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'
@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>
server: PropertySubject<S9Server>
currentServer: S9Server
apps: PropertySubjectId<AppInstalledPreview>[] = []
subsToTearDown: Subscription[] = []
updatingFreeze = false
updating = false
segmentValue: 'services' | 'embassy' = 'services'
showCertDownload : boolean
constructor (
private readonly serverModel: ServerModel,
private readonly appModel: AppModel,
private readonly preload: ModelPreload,
private readonly syncDaemon: SyncDaemon,
) {
super()
}
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 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
}
}
}

View File

@@ -0,0 +1,42 @@
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 = [
{
path: '',
component: AppInstalledShowPage,
},
]
@NgModule({
imports: [
CommonModule,
StatusComponentModule,
DependencyListComponentModule,
AppBackupPageModule,
SharingModule,
IonicModule,
RouterModule.forChild(routes),
PwaBackComponentModule,
BadgeMenuComponentModule,
InstallWizardComponentModule,
ErrorMessageComponentModule,
InformationPopoverComponentModule,
],
declarations: [AppInstalledShowPage],
})
export class AppInstalledShowPageModule { }

View File

@@ -0,0 +1,163 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<pwa-back-button></pwa-back-button>
</ion-buttons>
<ion-title>Service Details</ion-title>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content *ngIf="{
id: app.id | async,
torAddress: app.torAddress | async,
status: app.status | async,
versionInstalled: app.versionInstalled | async,
configuredRequirements: app.configuredRequirements | async,
lastBackup: app.lastBackup | async,
hasFetchedFull: app.hasFetchedFull | async,
iconURL: app.iconURL | async,
title: app.title | 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>
<error-message [$error$]="$error$" [dismissable]="!!(app && app.id)"></error-message>
<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>
<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" (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" (click)="start()">
Start
</ion-button>
</ion-label>
</ion-item>
</div>
<ng-container *ngIf="app && app.id && vars.status !== 'INSTALLING'">
<ion-item-group class="ion-padding-bottom">
<ion-item-divider>Tor Address</ion-item-divider>
<ion-item lines="none">
<ion-label style="display: flex; justify-content: space-between; align-items: center;">
<p style="color: var(--ion-color-dark)">{{ vars.torAddress | truncateCenter:18:18:true }}</p>
<ion-button slot="end" fill="clear" (click)="copyTor()">
<ion-icon slot="icon-only" name="copy-outline" color="primary"></ion-icon>
</ion-button>
</ion-label>
</ion-item>
<ion-item-divider>Backups</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 new 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>General</ion-item-divider>
<!-- instructions -->
<ion-item button details="true" (click)="checkForUpdates()">
<ion-icon slot="start" name="refresh-outline" color="primary"></ion-icon>
<ion-label><ion-text style="font-weight: 500;" color="primary">Check for Updates</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]="[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>
<!-- 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="cart-outline" color="primary"></ion-icon>
<ion-label><ion-text color="primary">Marketplace Details</ion-text></ion-label>
</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>
</ion-item>
</ng-container>
</ion-item-group>
</ng-container>
</ng-container>
</ion-content>

View File

@@ -0,0 +1,41 @@
.full-width {
margin: 10px;
}
.about-attribute {
font-size: small;
}
.about-attribute-value {
font-size: small;
}
.less-large {
font-size: 20px !important;
}
.top-plate {
// margin-top: 20px;
background: var(--ion-item-background);
margin: 20px 10px;
border-radius: 10px;
border-style: solid;
border-color: #373737;
}
.status-readout {
display: flex;
justify-content: space-between;
padding: 4px 10px;
border-radius: 10px;
align-items: center;
background: var(--ion-background-color);
margin: 10px 10px 15px 10px;
border-style: solid;
border-width: 1px;
border-color: #404040;
}
.no-cushion-item {
--background: transparent; --padding-start: 0px; --inner-padding-end: 0px; --padding-end: 0px;
}

View File

@@ -0,0 +1,300 @@
import { Component, ViewChild } from '@angular/core'
import { AlertController, NavController, ToastController, 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 { 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 { Emver } from 'src/app/services/emver.service'
@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
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()}`
@ViewChild(IonContent) content: IonContent
constructor (
private readonly alertCtrl: AlertController,
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 emver: Emver,
) {
super()
}
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),
concatMap(() => this.syncWhenDependencyInstalls()), //must be final in stack
catchError(e => of(this.setError(e))),
).subscribe(),
)
}
ionViewDidEnter () {
markAsLoadingDuringP(this.$loadingDependencies$, this.getApp())
}
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 checkForUpdates () {
const app = peekProperties(this.app)
this.loader.of({
message: `Checking for updates...`,
spinner: 'lines',
cssClass: 'loader',
}).displayDuringAsync(
async () => {
const { versionLatest } = await this.apiService.getAvailableApp(this.appId)
if (this.emver.compare(versionLatest, app.versionInstalled) === 1) {
this.presentAlertUpdate(app, versionLatest)
} else {
this.presentAlertUpToDate()
}
},
).catch(e => this.setError(e))
}
async presentAlertUpdate (app: AppInstalledFull, versionLatest: string) {
const alert = await this.alertCtrl.create({
backdropDismiss: false,
header: 'Update Available',
message: `New version ${versionLatest} found for ${app.title}.`,
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'View in Store',
cssClass: 'alert-success',
handler: () => {
this.navCtrl.navigateForward(['/services', 'marketplace', this.appId])
},
},
],
})
await alert.present()
}
async presentAlertUpToDate () {
const alert = await this.alertCtrl.create({
header: 'Up To Date',
message: `You are running the latest version of ${this.app.title.getValue()}!`,
buttons: ['OK'],
})
await alert.present()
}
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 stop (): Promise<void> {
const app = peekProperties(this.app)
await this.loader.of({
message: `Stopping ${app.title}...`,
spinner: 'lines',
cssClass: 'loader',
}).displayDuringAsync(async () => {
const { breakages } = await this.apiService.stopApp(this.appId, true)
if (breakages.length) {
const { cancelled } = await wizardModal(
this.modalCtrl,
this.wizardBaker.stop({
id: app.id,
title: app.title,
version: app.versionInstalled,
breakages,
}),
)
if (cancelled) return { }
}
return this.apiService.stopApp(this.appId).then(chill)
}).catch(e => this.setError(e))
}
async start (): Promise<void> {
const app = peekProperties(this.app)
this.loader.of({
message: `Starting ${app.title}...`,
spinner: 'lines',
cssClass: 'loader',
}).displayDuringP(
this.apiService.startApp(this.appId),
).catch(e => this.setError(e))
}
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 data = await wizardModal(
this.modalCtrl,
this.wizardBaker.uninstall({
id: app.id,
title: app.title,
version: app.versionInstalled,
}),
)
if (data.cancelled) return
return this.navCtrl.navigateRoot('/services/installed')
}
async presentPopover (information: string, ev: any) {
const popover = await this.popoverController.create({
component: InformationPopoverComponent,
event: ev,
translucent: false,
showBackdrop: true,
backdropDismiss: true,
componentProps: {
information,
},
})
return await popover.present()
}
private setError (e: Error) {
this.$error$.next(e.message)
}
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

@@ -0,0 +1,29 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppInstructionsPage } from './app-instructions.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 = [
{
path: '',
component: AppInstructionsPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
PwaBackComponentModule,
BadgeMenuComponentModule,
SharingModule,
],
declarations: [AppInstructionsPage],
})
export class AppInstructionsPageModule { }

View File

@@ -0,0 +1,31 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<pwa-back-button></pwa-back-button>
</ion-buttons>
<ion-title>Instructions</ion-title>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
</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-item *ngIf="!app.instructions">
<ion-label class="ion-text-wrap">
<p>No instructions for {{ app.title }} {{ app.versionInstalled }}.</p>
</ion-label>
</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>
</ion-content>

View File

@@ -0,0 +1,37 @@
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'
@Component({
selector: 'app-instructions',
templateUrl: './app-instructions.page.html',
styleUrls: ['./app-instructions.page.scss'],
})
export class AppInstructionsPage {
$loading$ = new BehaviorSubject(true)
error = ''
app: AppInstalledFull = { } as any
appId: string
instructions: any
constructor (
private readonly route: ActivatedRoute,
private readonly preload: ModelPreload,
) { }
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)
this.error = e.message
},
})
}
}

View File

@@ -0,0 +1,28 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppLogsPage } from './app-logs.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'
const routes: Routes = [
{
path: '',
component: AppLogsPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
PwaBackComponentModule,
BadgeMenuComponentModule,
],
declarations: [AppLogsPage],
})
export class AppLogsPageModule { }

View File

@@ -0,0 +1,22 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<pwa-back-button></pwa-back-button>
</ion-buttons>
<ion-title>Logs</ion-title>
<ion-buttons slot="end" class="ion-padding-end">
<ion-button (click)="getLogs()" color="primary">
<ion-icon name="refresh-outline"></ion-icon>
</ion-button>
<badge-menu-button></badge-menu-button>
</ion-buttons>
</ion-toolbar>
</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>
<p style="white-space: pre-line;">{{ logs }}</p>
</ion-content>

View File

@@ -0,0 +1,50 @@
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',
templateUrl: './app-logs.page.html',
styleUrls: ['./app-logs.page.scss'],
})
export class AppLogsPage {
@ViewChild(IonContent, { static: false }) private content: IonContent
$loading$ = new BehaviorSubject(true)
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),
]))
}
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 = ''
setTimeout(async () => await this.content.scrollToBottom(100), 200)
} catch (e) {
console.error(e)
this.error = e.message
} finally {
this.$loading$.next(false)
}
}
}

View File

@@ -0,0 +1,32 @@
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 { 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 { QRComponentModule } from 'src/app/components/qr/qr.component.module'
import { SharingModule } from 'src/app/modules/sharing.module'
const routes: Routes = [
{
path: '',
component: AppMetricsPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
PwaBackComponentModule,
BadgeMenuComponentModule,
QRComponentModule,
SharingModule,
],
declarations: [AppMetricsPage],
})
export class AppMetricsPageModule { }

View File

@@ -0,0 +1,69 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<pwa-back-button></pwa-back-button>
</ion-buttons>
<ion-title>Properties</ion-title>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
</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>
<!-- 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>
<ion-item-group>
<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

@@ -0,0 +1,3 @@
.metric-note {
font-size: 16px;
}

View File

@@ -0,0 +1,139 @@
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]
console.log(this.unmasked)
}
asIsOrder (a: any, b: any) {
return 0
}
}

View File

@@ -0,0 +1,15 @@
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

@@ -0,0 +1,52 @@
import { NgModule } from '@angular/core'
import { Routes, RouterModule } from '@angular/router'
const routes: Routes = [
{
path: '',
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',
loadChildren: () => import('./app-installed-show/app-installed-show.module').then(m => m.AppInstalledShowPageModule),
},
{
path: 'installed/:appId/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/:appId/config/:edit',
loadChildren: () => import('./app-config/app-config.module').then(m => m.AppConfigPageModule),
},
{
path: 'installed/:appId/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),
},
]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class AppsRoutingModule { }

View File

@@ -0,0 +1,17 @@
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

@@ -0,0 +1,21 @@
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

@@ -0,0 +1,27 @@
<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

@@ -0,0 +1,44 @@
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,26 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
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'
const routes: Routes = [
{
path: '',
component: NotificationsPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
PwaBackComponentModule,
BadgeMenuComponentModule,
],
declarations: [NotificationsPage],
})
export class NotificationsPageModule { }

View File

@@ -0,0 +1,64 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<pwa-back-button></pwa-back-button>
</ion-buttons>
<ion-title>Notifications</ion-title>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<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>
<ion-spinner *ngIf="loading" class="center" name="lines" color="warning"></ion-spinner>
<ion-item-group *ngIf="!notifications.length && !loading">
<ion-item>
<ion-label class="ion-text-wrap">
<h2>
<ion-text color="medium">Notifications about Embassy and services will appear here.</ion-text>
</h2>
</ion-label>
</ion-item>
</ion-item-group>
<ion-item-group style="margin-bottom: 16px;">
<ion-item-sliding *ngFor="let not of notifications; let i = index">
<ion-item-options side="end">
<ion-item-option color="danger" (click)="remove(not.id, i)">
<ion-icon slot="icon-only" name="trash-outline"></ion-icon>
</ion-item-option>
</ion-item-options>
<ion-item>
<ion-label class="ion-text-wrap">
<h2>
<ion-text [color]="getColor(not)"><b>{{ not.title }}</b></ion-text>
</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 }}
</p>
</ion-label>
</ion-item>
</ion-item-sliding>
</ion-item-group>
<ion-infinite-scroll [disabled]="!needInfinite" (ionInfinite)="doInfinite($event)">
<ion-infinite-scroll-content loadingSpinner="lines"></ion-infinite-scroll-content>
</ion-infinite-scroll>
</ion-content>

View File

@@ -0,0 +1,3 @@
.notification-message {
margin: 10px 0 12px 0;
}

View File

@@ -0,0 +1,98 @@
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'
@Component({
selector: 'notifications',
templateUrl: 'notifications.page.html',
styleUrls: ['notifications.page.scss'],
})
export class NotificationsPage {
error = ''
loading = true
notifications: S9Notification[] = []
page = 1
needInfinite = false
readonly perPage = 20
constructor (
private readonly serverModel: ServerModel,
private readonly apiService: ApiService,
private readonly loader: LoaderService,
) { }
async ngOnInit () {
const [notifications] = await Promise.all([
this.getNotifications(),
pauseFor(600),
])
this.notifications = notifications
this.serverModel.update({ badge: 0 })
this.loading = false
}
async doRefresh (e: any) {
this.page = 1
await Promise.all([
this.getNotifications(),
pauseFor(600),
])
e.target.complete()
}
async doInfinite (e: any) {
const notifications = await this.getNotifications()
this.notifications = this.notifications.concat(notifications)
e.target.complete()
}
async getNotifications (): Promise<S9Notification[]> {
let notifications: S9Notification[] = []
try {
notifications = await this.apiService.getNotifications(this.page, this.perPage)
this.needInfinite = notifications.length >= this.perPage
this.page++
this.error = ''
} catch (e) {
console.error(e)
this.error = e.message
} finally {
return notifications
}
}
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> {
this.loader.of({
message: 'Deleting...',
spinner: 'lines',
cssClass: 'loader',
}).displayDuringP(
this.apiService.deleteNotification(notificationId).then(() => {
this.notifications.splice(index, 1)
this.error = ''
}),
).catch(e => {
console.error(e)
this.error = e.message
})
}
}

View File

@@ -0,0 +1,28 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
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 { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
import { ObjectConfigComponentModule } from 'src/app/components/object-config/object-config.component.module'
const routes: Routes = [
{
path: '',
component: DevOptionsPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
ObjectConfigComponentModule,
RouterModule.forChild(routes),
PwaBackComponentModule,
BadgeMenuComponentModule,
],
declarations: [DevOptionsPage],
})
export class DevOptionsPageModule { }

View File

@@ -0,0 +1,29 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<pwa-back-button></pwa-back-button>
</ion-buttons>
<ion-title>Developer Options</ion-title>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
</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 [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-group>
</ion-content>

View File

@@ -0,0 +1,42 @@
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'
@Component({
selector: 'dev-options',
templateUrl: './dev-options.page.html',
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,
) { }
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)
}
}

View File

@@ -0,0 +1,26 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
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 { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
const routes: Routes = [
{
path: '',
component: DevSSHKeysPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
PwaBackComponentModule,
BadgeMenuComponentModule,
],
declarations: [DevSSHKeysPage],
})
export class DevSSHKeysPageModule { }

View File

@@ -0,0 +1,51 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<pwa-back-button></pwa-back-button>
</ion-buttons>
<ion-title>SSH Keys</ion-title>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
</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 *ngIf="error" style="margin-bottom: 16px;">
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
</ion-item>
<ion-item-group>
<ion-item-divider style="margin-top: 0px;">Description</ion-item-divider>
<ion-item lines="none">
<ion-label class="ion-text-wrap">
<h2><ion-text color="medium">Add SSH keys to your Embassy to gain root access from the command line.</ion-text></h2>
</ion-label>
</ion-item>
<ion-item-divider>Saved Keys</ion-item-divider>
<ion-item-sliding *ngFor="let fingerprint of server.ssh | async">
<ion-item-options side="end">
<ion-item-option color="danger" (click)="delete(fingerprint)">
<ion-icon slot="icon-only" name="trash-outline"></ion-icon>
</ion-item-option>
</ion-item-options>
<ion-item>
<ion-label class="ion-text-wrap">{{ fingerprint.alg }} {{ fingerprint.hash }} {{ fingerprint.hostname }}
</ion-label>
</ion-item>
</ion-item-sliding>
</ion-item-group>
<ion-fab vertical="bottom" horizontal="end" slot="fixed">
<ion-fab-button (click)="presentModalAdd()" class="fab-button">
<ion-icon name="add"></ion-icon>
</ion-fab-button>
</ion-fab>
</ion-content>

View File

@@ -0,0 +1,56 @@
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'
@Component({
selector: 'dev-ssh-keys',
templateUrl: 'dev-ssh-keys.page.html',
styleUrls: ['dev-ssh-keys.page.scss'],
})
export class DevSSHKeysPage {
server: PropertySubject<S9Server> = { } as any
error = ''
constructor (
private readonly apiService: ApiService,
private readonly loader: LoaderService,
private readonly preload: ModelPreload,
private readonly serverConfigService: ServerConfigService,
) { }
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 presentModalAdd () {
await this.serverConfigService.presentModalValueEdit('ssh', true)
}
async delete (fingerprint: SSHFingerprint) {
this.loader.of({
message: 'Deleting...',
spinner: 'lines',
cssClass: 'loader',
}).displayDuringP(
this.apiService.deleteSSHKey(fingerprint).then(() => this.error = ''),
).catch(e => {
console.error(e)
this.error = e.message
})
}
}

View File

@@ -0,0 +1,19 @@
import { NgModule } from '@angular/core'
import { Routes, RouterModule } from '@angular/router'
const routes: Routes = [
{
path: '',
loadChildren: () => import('./dev-options/dev-options.module').then(m => m.DevOptionsPageModule),
},
{
path: 'ssh-keys',
loadChildren: () => import('./dev-ssh-keys/dev-ssh-keys.module').then(m => m.DevSSHKeysPageModule),
},
]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class DeveloperRoutingModule { }

View File

@@ -0,0 +1,28 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { LANPage } from './lan.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 = [
{
path: '',
component: LANPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
PwaBackComponentModule,
BadgeMenuComponentModule,
SharingModule,
],
declarations: [LANPage],
})
export class LANPageModule { }

View File

@@ -0,0 +1,67 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
<ion-title>Secure LAN Setup</ion-title>
<ion-buttons slot="start">
<pwa-back-button></pwa-back-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<ion-item-group>
<ion-item lines="none" style="font-size: small; --background: var(--ion-background-color);">
<ion-label size="small" class="ion-text-wrap">
<ion-text color="medium">For a <ion-text style="font-style: italic;">faster</ion-text> experience, you can also securely communicate with your Embassy by visiting its Local Area Network (LAN) address.</ion-text>
</ion-label>
</ion-item>
<!-- info -->
<ion-item lines="none">
<ion-label class="ion-text-wrap">
<h2><ion-text color="warning">Instructions</ion-text></h2>
<ng-container *ngIf="!lanDisabled">
<ul style="font-size: smaller">
<li>Download your Embassy's SSL Certificate Authority by clicking the download button below.</li>
<li>Install and trust the CA.</li>
<li>Connect this device to the same network as the Embassy. This should be your private home network.</li>
<li>Navigate to your Embassy LAN address, indicated below.</li>
</ul>
</ng-container>
<div *ngIf="lanDisabled" class="ion-padding-top ion-padding-bottom">
<p [innerHtml]="lanDisabledExplanation[lanDisabled]"></p>
</div>
<a *ngIf="!isConsulate" [href]="fullDocumentationLink" target="_blank">full documentation</a>
<ion-button *ngIf="isConsulate" fill="outline" (click)="copyDocumentation()">full documentation</ion-button>
</ion-label>
</ion-item>
<ion-item-divider style="margin-top: 0px"></ion-item-divider>
<!-- Certificate -->
<ion-item [disabled]="!!lanDisabled">
<ion-label class="ion-text-wrap">
<h2>SSL Certificate</h2>
<p>Embassy Local CA</p>
</ion-label>
<ion-button [disabled]="!!lanDisabled" slot="end" fill="clear" (click)="installCert()">
<ion-icon slot="icon-only" name="download-outline"></ion-icon>
</ion-button>
</ion-item>
<!-- URL -->
<ion-item [disabled]="!!lanDisabled" >
<ion-label class="ion-text-wrap">
<h2>LAN Address</h2>
<a [href]="lanAddress" target="_blank">{{ lanAddress }}</a>
</ion-label>
<ion-button [disabled]="!!lanDisabled" slot="end" fill="clear" (click)="copyLAN()">
<ion-icon slot="icon-only" name="copy-outline"></ion-icon>
</ion-button>
</ion-item>
</ion-item-group>
<!-- hidden element for downloading cert -->
<a id="install-cert" href="/api/v0/certificate" download="Embassy Local CA.crt"></a>
</ion-content>

View File

@@ -0,0 +1,82 @@
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'
@Component({
selector: 'lan',
templateUrl: './lan.page.html',
styleUrls: ['./lan.page.scss'],
})
export class LANPage {
torDocs = 'docs.privacy34kn4ez3y3nijweec6w4g54i3g54sdv7r5mr6soma3w4begyd.onion/user-manuals/embassyos/general/secure-lan'
lanDocs = 'docs.start9labs.com/user-manuals/embassyos/general/secure-lan'
lanAddress: string
isTor: boolean
fullDocumentationLink: string
isConsulate: boolean
lanDisabled: LanSetupIssue = undefined
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.<br /><br/>Navigate to your Embassy Tor Address and try again.`,
}
constructor (
private readonly serverModel: ServerModel,
private readonly toastCtrl: ToastController,
private readonly config: ConfigService,
) { }
ngOnInit () {
if (isPlatform('ios') || isPlatform('android')) {
this.lanDisabled = 'NotDesktop'
} else if (!this.config.isTor()) {
this.lanDisabled = 'NotTor'
}
this.isConsulate = this.config.isConsulateIos || this.config.isConsulateAndroid
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 copyLAN (): Promise < void > {
const message = await copyToClipboard(this.lanAddress).then(success => 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 copyDocumentation (): Promise < void > {
const message = await copyToClipboard(this.fullDocumentationLink).then(
success => success ? 'copied documentation 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'

View File

@@ -0,0 +1,30 @@
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 { 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 { ObjectConfigComponentModule } from 'src/app/components/object-config/object-config.component.module'
const routes: Routes = [
{
path: '',
component: ServerConfigPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
SharingModule,
ObjectConfigComponentModule,
RouterModule.forChild(routes),
PwaBackComponentModule,
BadgeMenuComponentModule,
],
declarations: [ServerConfigPage],
})
export class ServerConfigPageModule { }

View File

@@ -0,0 +1,29 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<pwa-back-button></pwa-back-button>
</ion-buttons>
<ion-title>Config</ion-title>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
</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>
<!-- <ion-item style="word-break: break-all;" button (click)="presentModalValueEdit('password', true)">
<ion-label>Change Password</ion-label>
<ion-note slot="end">********</ion-note>
</ion-item> -->
</ion-item-group>
</ion-content>

View File

@@ -0,0 +1,43 @@
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

@@ -0,0 +1,28 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { ServerMetricsPage } from './server-metrics.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'
const routes: Routes = [
{
path: '',
component: ServerMetricsPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
PwaBackComponentModule,
BadgeMenuComponentModule,
],
declarations: [ServerMetricsPage],
})
export class ServerMetricsPageModule { }

View File

@@ -0,0 +1,32 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<pwa-back-button></pwa-back-button>
</ion-buttons>
<ion-title>Metrics</ion-title>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<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" 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>
</ion-content>

View File

@@ -0,0 +1,3 @@
.metric-note {
font-size: 16px;
}

View File

@@ -0,0 +1,70 @@
import { Component } from '@angular/core'
import { ServerMetrics } from 'src/app/models/server-model'
import { ApiService } from 'src/app/services/api/api.service'
import { pauseFor } from 'src/app/util/misc.util'
@Component({
selector: 'server-metrics',
templateUrl: './server-metrics.page.html',
styleUrls: ['./server-metrics.page.scss'],
})
export class ServerMetricsPage {
error = ''
loading = true
going = false
metrics: ServerMetrics = { }
constructor (
private readonly apiService: ApiService,
) { }
async ngOnInit () {
await Promise.all([
this.getMetrics(),
pauseFor(600),
])
this.loading = false
this.startDaemon()
}
ngOnDestroy () {
this.stopDaemon()
}
async startDaemon (): Promise<void> {
this.going = true
while (this.going) {
await pauseFor(250)
await this.getMetrics()
}
}
stopDaemon () {
this.going = false
}
async getMetrics (): Promise<void> {
try {
const metrics = await this.apiService.getServerMetrics()
Object.keys(metrics).forEach(outerKey => {
if (!this.metrics[outerKey]) {
this.metrics[outerKey] = metrics[outerKey]
} else {
Object.entries(metrics[outerKey]).forEach(([key, value]) => {
this.metrics[outerKey][key] = value
})
}
})
} catch (e) {
console.error(e)
this.error = e.message
this.stopDaemon()
}
}
asIsOrder (a: any, b: any) {
return 0
}
}

View File

@@ -0,0 +1,47 @@
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: '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: 'config',
canActivate: [AuthGuard],
loadChildren: () => import('./server-config/server-config.module').then(m => m.ServerConfigPageModule),
},
{
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({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class ServerRoutingModule { }

View File

@@ -0,0 +1,32 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { RouterModule, Routes } from '@angular/router'
import { ServerShowPage } from './server-show.page'
import { StatusComponentModule } from 'src/app/components/status/status.component.module'
import { FormsModule } from '@angular/forms'
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'
const routes: Routes = [
{
path: '',
component: ServerShowPage,
},
]
@NgModule({
imports: [
CommonModule,
FormsModule,
StatusComponentModule,
IonicModule,
RouterModule.forChild(routes),
SharingModule,
PwaBackComponentModule,
BadgeMenuComponentModule,
],
declarations: [ServerShowPage],
})
export class ServerShowPageModule { }

View File

@@ -0,0 +1,86 @@
<ion-header>
<ion-toolbar>
<ion-title>{{ server.name | async }}</ion-title>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="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-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-item-group>
<ion-item-divider></ion-item-divider>
<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]="['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 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-divider></ion-item-divider>
<ion-item lines="none" button (click)="checkForUpdates()">
<ion-icon slot="start" name="refresh-outline" color="primary"></ion-icon>
<ion-label><ion-text style="font-weight: bold;" color="primary">Check for Updates</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">Secure LAN Setup</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 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-content>

View File

@@ -0,0 +1,12 @@
.notification-button {
ion-badge {
position: absolute;
font-size: 8px;
bottom: .7rem;
left: .8rem;
}
}
ion-item-divider {
margin-top: 0px;
}

View File

@@ -0,0 +1,222 @@
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 { 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 { Emver } from 'src/app/services/emver.service'
@Component({
selector: 'server-show',
templateUrl: 'server-show.page.html',
styleUrls: ['server-show.page.scss'],
})
export class ServerShowPage {
error = ''
s9Host$: Observable<string>
server: PropertySubject<S9Server>
currentServer: S9Server
subsToTearDown: Subscription[] = []
updatingFreeze = false
updating = false
constructor (
private readonly serverModel: ServerModel,
private readonly alertCtrl: AlertController,
private readonly loader: LoaderService,
private readonly apiService: ApiService,
private readonly syncDaemon: SyncDaemon,
private readonly emver: Emver,
) { }
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 checkForUpdates (): Promise<void> {
const loader = await this.loader.ctrl.create(LoadingSpinner('Checking for updates...'))
await loader.present()
try {
const { versionLatest } = await this.apiService.getVersionLatest()
if (this.emver.compare(this.server.versionInstalled.getValue(), versionLatest) === -1) {
this.presentAlertUpdate(versionLatest)
} else {
this.presentAlertUpToDate()
}
} catch (e) {
console.error(e)
this.error = e.message
} finally {
await loader.dismiss()
}
}
async presentAlertUpToDate () {
const alert = await this.alertCtrl.create({
header: 'Up To Date',
message: `You are running the latest version of EmbassyOS!`,
buttons: ['OK'],
})
await alert.present()
}
async presentAlertUpdate (versionLatest: string) {
const alert = await this.alertCtrl.create({
backdropDismiss: false,
header: 'Confirm',
message: `Update EmbassyOS to ${versionLatest}?`,
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Update',
handler: () => {
this.updateEmbassyOS(versionLatest)
},
},
],
})
await alert.present()
}
async presentAlertRestart () {
const alert = await this.alertCtrl.create({
backdropDismiss: false,
header: 'Confirm',
message: `Are you sure you want to restart your Embassy?`,
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Restart',
cssClass: 'alert-danger',
handler: () => {
this.restart()
},
},
]},
)
await alert.present()
}
async presentAlertShutdown () {
const alert = await this.alertCtrl.create({
backdropDismiss: false,
header: 'Confirm',
message: `Are you sure you shut down your Embassy? To turn it back on, you will need to physically unplug the device and plug it back in.`,
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Shutdown',
cssClass: 'alert-danger',
handler: () => {
this.shutdown()
},
},
],
})
await alert.present()
}
private async updateEmbassyOS (versionLatest: string) {
this.loader
.displayDuringAsync(async () => {
await this.apiService.updateAgent(versionLatest)
this.serverModel.update({ status: ServerStatus.UPDATING })
// hides the "Update Embassy to..." button for this intance of the component
this.updatingFreeze = true
this.updating = true
setTimeout(() => this.updatingFreeze = false, 8000)
})
.catch(e => this.setError(e))
}
private async restart () {
this.loader
.of(LoadingSpinner(`Restarting ${this.currentServer.name}...`))
.displayDuringAsync( async () => {
this.serverModel.markUnreachable()
await this.apiService.restartServer()
})
.catch(e => this.setError(e))
}
private async shutdown () {
this.loader
.of(LoadingSpinner(`Shutting down ${this.currentServer.name}...`))
.displayDuringAsync( async () => {
this.serverModel.markUnreachable()
await this.apiService.shutdownServer()
})
.catch(e => this.setError(e))
}
setError (e: Error) {
console.error(e)
this.error = e.message
}
}
const LoadingSpinner: (m?: string) => LoadingOptions = (m) => {
const toMergeIn = m ? { message: m } : { }
return {
spinner: 'lines',
cssClass: 'loader',
...toMergeIn,
} as LoadingOptions
}

View File

@@ -0,0 +1,30 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { ServerSpecsPage } from './server-specs.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 = [
{
path: '',
component: ServerSpecsPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
PwaBackComponentModule,
BadgeMenuComponentModule,
SharingModule,
],
declarations: [ServerSpecsPage],
})
export class ServerSpecsPageModule { }

View File

@@ -0,0 +1,33 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<pwa-back-button></pwa-back-button>
</ion-buttons>
<ion-title>About</ion-title>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
</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-item *ngIf="error" style="margin-bottom: 16px;">
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
</ion-item>
<ion-item-group>
<!-- TODO: Tor address needs a copy button. -->
<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-item>
</ion-item-group>
</ng-container>
</ion-content>

View File

@@ -0,0 +1,53 @@
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 { LoaderService, markAsLoadingDuring$ } from 'src/app/services/loader.service'
import { BehaviorSubject } from 'rxjs'
@Component({
selector: 'server-specs',
templateUrl: './server-specs.page.html',
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,
) { }
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 () {
let message = ''
await copyToClipboard((this.server.specs.getValue()['Tor Address'] as string).trim() || '')
.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()
}
asIsOrder (a: any, b: any) {
return 0
}
}

View File

@@ -0,0 +1,28 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { FormsModule } from '@angular/forms'
import { IonicModule } from '@ionic/angular'
import { RouterModule, Routes } from '@angular/router'
import { WifiAddPage } from './wifi-add.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'
const routes: Routes = [
{
path: '',
component: WifiAddPage,
},
]
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
RouterModule.forChild(routes),
PwaBackComponentModule,
BadgeMenuComponentModule,
],
declarations: [WifiAddPage],
})
export class WifiAddPageModule { }

View File

@@ -0,0 +1,52 @@
<ion-header>
<ion-buttons slot="start">
<pwa-back-button></pwa-back-button>
</ion-buttons>
<ion-toolbar>
<ion-title>Add Network</ion-title>
</ion-toolbar>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
</ion-header>
<ion-content class="ion-padding-top">
<ion-item *ngIf="error">
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
</ion-item>
<ion-item-group>
<ion-item>
<ion-label>Select Country</ion-label>
<ion-select slot="end" placeholder="Select" [(ngModel)]="countryCode" [selectedText]="countryCode">
<ion-select-option *ngFor="let country of countries | keyvalue : asIsOrder" [value]="country.key">
{{ country.key }} - {{ country.value }}
</ion-select-option>
</ion-select>
</ion-item>
<ion-item-divider>Network and Password</ion-item-divider>
<ion-item>
<ion-input placeholder="Network Name (SSID)" [(ngModel)]="ssid"></ion-input>
</ion-item>
<ion-item>
<ion-input type="password" placeholder="Password" [(ngModel)]="password"></ion-input>
</ion-item>
</ion-item-group>
<ion-grid style="margin-top: 40px;">
<ion-row>
<ion-col size="6">
<ion-button [disabled]="!ssid" expand="block" fill="outline" color="primary" (click)="add()">
Add
</ion-button>
</ion-col>
<ion-col size="6">
<ion-button [disabled]="!ssid" expand="block" fill="outline" color="success" (click)="addAndConnect()">
Add and Connect
</ion-button>
</ion-col>
</ion-row>
</ion-grid>
</ion-content>

View File

@@ -0,0 +1,67 @@
import { Component } from '@angular/core'
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'
@Component({
selector: 'wifi-add',
templateUrl: 'wifi-add.page.html',
styleUrls: ['wifi-add.page.scss'],
})
export class WifiAddPage {
countries = require('../../../../util/countries.json')
countryCode = 'US'
ssid = ''
password = ''
error = ''
constructor (
private readonly navCtrl: NavController,
private readonly apiService: ApiService,
private readonly loader: LoaderService,
private readonly wifiService: WifiService,
) { }
async add (): Promise<void> {
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)
this.ssid = ''
this.password = ''
this.error = ''
this.navCtrl.back()
}).catch(e => {
console.error(e)
this.error = e.message
})
}
async addAndConnect (): Promise<void> {
this.loader.of({
message: 'Connecting. This could take while...',
spinner: 'lines',
cssClass: 'loader',
}).displayDuringAsync( async () => {
await this.apiService.addWifi(this.ssid, this.password, this.countryCode, true)
const success = await this.wifiService.confirmWifi(this.ssid)
if (!success) { return }
this.wifiService.addWifi(this.ssid)
this.ssid = ''
this.password = ''
this.error = ''
this.navCtrl.back()
}).catch (e => {
console.error(e)
this.error = e.message
})
}
asIsOrder (a: any, b: any) {
return 0
}
}

View File

@@ -0,0 +1,30 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
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 { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
const routes: Routes = [
{
path: '',
component: WifiListPage,
},
{
path: 'add',
loadChildren: () => import('./wifi-add/wifi-add.module').then(m => m.WifiAddPageModule),
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
PwaBackComponentModule,
BadgeMenuComponentModule,
],
declarations: [WifiListPage],
})
export class WifiListPageModule { }

View File

@@ -0,0 +1,47 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<pwa-back-button></pwa-back-button>
</ion-buttons>
<ion-title>Wifi</ion-title>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
</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 *ngIf="error">
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
</ion-item>
<ion-item-group>
<ion-item>
<ion-label class="ion-text-wrap">
<p>
Add WiFi credentials to your Embassy so it can connect to the Internet without an ethernet cable.
</p>
</ion-label>
</ion-item>
<ion-item-divider class="borderless"></ion-item-divider>
<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>
</ion-item-group>
<ion-fab vertical="bottom" horizontal="end" slot="fixed">
<ion-fab-button [routerLink]="['add']" class="fab-button">
<ion-icon name="add"></ion-icon>
</ion-fab-button>
</ion-fab>
</ion-content>

View File

@@ -0,0 +1,102 @@
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'
@Component({
selector: 'wifi',
templateUrl: 'wifi.page.html',
styleUrls: ['wifi.page.scss'],
})
export class WifiListPage {
server: PropertySubject<S9Server> = { } as any
error: string
constructor (
private readonly apiService: ApiService,
private readonly loader: LoaderService,
private readonly actionCtrl: ActionSheetController,
private readonly wifiService: WifiService,
private readonly preload: ModelPreload,
) { }
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) {
const buttons: ActionSheetButton[] = [
{
text: 'Forget',
cssClass: 'alert-danger',
handler: () => {
this.delete(ssid)
},
},
]
if (ssid !== this.server.wifi.getValue().current) {
buttons.unshift(
{
text: 'Connect',
handler: () => {
this.connect(ssid)
},
},
)
}
const action = await this.actionCtrl.create({
buttons,
})
await action.present()
}
// Let's add country code here.
async connect (ssid: string): Promise<void> {
this.loader.of({
message: 'Connecting. This could take while...',
spinner: 'lines',
cssClass: 'loader',
}).displayDuringAsync(async () => {
await this.apiService.connectWifi(ssid)
await this.wifiService.confirmWifi(ssid)
this.error = ''
}).catch(e => {
console.error(e)
this.error = e.message
})
}
async delete (ssid: string): Promise<void> {
this.loader.of({
message: 'Deleting...',
spinner: 'lines',
cssClass: 'loader',
}).displayDuringAsync( async () => {
await this.apiService.deleteWifi(ssid)
this.wifiService.removeWifi(ssid)
this.error = ''
}).catch(e => {
console.error(e)
this.error = e.message
})
}
}

View File

@@ -0,0 +1,80 @@
import { Injectable } from '@angular/core'
import { 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'
@Injectable({
providedIn: 'root',
})
export class WifiService {
constructor (
private readonly apiService: ApiService,
private readonly toastCtrl: ToastController,
private readonly serverModel: ServerModel,
) { }
addWifi (ssid: string): void {
const wifi = this.serverModel.peek().wifi
this.serverModel.update({ wifi: { ...wifi, ssids: [...new Set([ssid, ...wifi.ssids])] } })
}
removeWifi (ssid: string): void {
const wifi = this.serverModel.peek().wifi
this.serverModel.update({ wifi: { ...wifi, ssids: wifi.ssids.filter(s => s !== ssid) } })
}
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(0, timeout - diff))
if (attempts === maxAttempts) {
this.serverModel.update({ wifi: { current, ssids } })
}
}
} catch (e) {
attempts++
console.error(e)
}
}
if (this.serverModel.peek().wifi.current === ssid) {
return true
} else {
const toast = await this.toastCtrl.create({
header: 'Failed to connect:',
message: `Check credentials and try again`,
position: 'bottom',
duration: 4000,
buttons: [
{
side: 'start',
icon: 'close',
handler: () => {
return true
},
},
],
cssClass: 'notification-toast',
})
setTimeout(() => toast.present(), 300)
return false
}
}
}