mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-31 04:23:40 +00:00
0.2.5 initial commit
Makefile incomplete
This commit is contained in:
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
.beneath-title {
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
font-family: 'Open Sans';
|
||||
padding: 1px 0px 1.5px 0px;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
45
ui/src/app/pages/apps-routes/app-config/app-config.module.ts
Normal file
45
ui/src/app/pages/apps-routes/app-config/app-config.module.ts
Normal 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 { }
|
||||
119
ui/src/app/pages/apps-routes/app-config/app-config.page.html
Normal file
119
ui/src/app/pages/apps-routes/app-config/app-config.page.html
Normal 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>
|
||||
244
ui/src/app/pages/apps-routes/app-config/app-config.page.ts
Normal file
244
ui/src/app/pages/apps-routes/app-config/app-config.page.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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))),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -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
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
28
ui/src/app/pages/apps-routes/app-logs/app-logs.module.ts
Normal file
28
ui/src/app/pages/apps-routes/app-logs/app-logs.module.ts
Normal 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 { }
|
||||
22
ui/src/app/pages/apps-routes/app-logs/app-logs.page.html
Normal file
22
ui/src/app/pages/apps-routes/app-logs/app-logs.page.html
Normal 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>
|
||||
50
ui/src/app/pages/apps-routes/app-logs/app-logs.page.ts
Normal file
50
ui/src/app/pages/apps-routes/app-logs/app-logs.page.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
.metric-note {
|
||||
font-size: 16px;
|
||||
}
|
||||
139
ui/src/app/pages/apps-routes/app-metrics/app-metrics.page.ts
Normal file
139
ui/src/app/pages/apps-routes/app-metrics/app-metrics.page.ts
Normal 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
|
||||
}
|
||||
}
|
||||
15
ui/src/app/pages/apps-routes/app-metrics/metric-store.ts
Normal file
15
ui/src/app/pages/apps-routes/app-metrics/metric-store.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
52
ui/src/app/pages/apps-routes/apps-routing.module.ts
Normal file
52
ui/src/app/pages/apps-routes/apps-routing.module.ts
Normal 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 { }
|
||||
17
ui/src/app/pages/authenticate/authenticate-routing.module.ts
Normal file
17
ui/src/app/pages/authenticate/authenticate-routing.module.ts
Normal 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 {}
|
||||
21
ui/src/app/pages/authenticate/authenticate.module.ts
Normal file
21
ui/src/app/pages/authenticate/authenticate.module.ts
Normal 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 { }
|
||||
27
ui/src/app/pages/authenticate/authenticate.page.html
Normal file
27
ui/src/app/pages/authenticate/authenticate.page.html
Normal 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>
|
||||
44
ui/src/app/pages/authenticate/authenticate.page.ts
Normal file
44
ui/src/app/pages/authenticate/authenticate.page.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
26
ui/src/app/pages/notifications/notifications.module.ts
Normal file
26
ui/src/app/pages/notifications/notifications.module.ts
Normal 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 { }
|
||||
64
ui/src/app/pages/notifications/notifications.page.html
Normal file
64
ui/src/app/pages/notifications/notifications.page.html
Normal 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>
|
||||
3
ui/src/app/pages/notifications/notifications.page.scss
Normal file
3
ui/src/app/pages/notifications/notifications.page.scss
Normal file
@@ -0,0 +1,3 @@
|
||||
.notification-message {
|
||||
margin: 10px 0 12px 0;
|
||||
}
|
||||
98
ui/src/app/pages/notifications/notifications.page.ts
Normal file
98
ui/src/app/pages/notifications/notifications.page.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
28
ui/src/app/pages/server-routes/lan/lan.module.ts
Normal file
28
ui/src/app/pages/server-routes/lan/lan.module.ts
Normal 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 { }
|
||||
67
ui/src/app/pages/server-routes/lan/lan.page.html
Normal file
67
ui/src/app/pages/server-routes/lan/lan.page.html
Normal 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>
|
||||
0
ui/src/app/pages/server-routes/lan/lan.page.scss
Normal file
0
ui/src/app/pages/server-routes/lan/lan.page.scss
Normal file
82
ui/src/app/pages/server-routes/lan/lan.page.ts
Normal file
82
ui/src/app/pages/server-routes/lan/lan.page.ts
Normal 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'
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
.metric-note {
|
||||
font-size: 16px;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
47
ui/src/app/pages/server-routes/server-routing.module.ts
Normal file
47
ui/src/app/pages/server-routes/server-routing.module.ts
Normal 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 { }
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -0,0 +1,12 @@
|
||||
.notification-button {
|
||||
ion-badge {
|
||||
position: absolute;
|
||||
font-size: 8px;
|
||||
bottom: .7rem;
|
||||
left: .8rem;
|
||||
}
|
||||
}
|
||||
|
||||
ion-item-divider {
|
||||
margin-top: 0px;
|
||||
}
|
||||
222
ui/src/app/pages/server-routes/server-show/server-show.page.ts
Normal file
222
ui/src/app/pages/server-routes/server-show/server-show.page.ts
Normal 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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
30
ui/src/app/pages/server-routes/wifi/wifi.module.ts
Normal file
30
ui/src/app/pages/server-routes/wifi/wifi.module.ts
Normal 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 { }
|
||||
47
ui/src/app/pages/server-routes/wifi/wifi.page.html
Normal file
47
ui/src/app/pages/server-routes/wifi/wifi.page.html
Normal 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>
|
||||
0
ui/src/app/pages/server-routes/wifi/wifi.page.scss
Normal file
0
ui/src/app/pages/server-routes/wifi/wifi.page.scss
Normal file
102
ui/src/app/pages/server-routes/wifi/wifi.page.ts
Normal file
102
ui/src/app/pages/server-routes/wifi/wifi.page.ts
Normal 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
|
||||
})
|
||||
}
|
||||
}
|
||||
80
ui/src/app/pages/server-routes/wifi/wifi.service.ts
Normal file
80
ui/src/app/pages/server-routes/wifi/wifi.service.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user