0.3.0 refactor

ui: adds overlay layer to patch-db-client

ui: getting towards mocks

ui: cleans up factory init

ui: nice type hack

ui: live api for patch

ui: api service source + http

starts up

ui: api source + http

ui: rework patchdb config, pass stashTimeout into patchDbModel

wires in temp patching into api service

ui: example of wiring patchdbmodel into page

begin integration

remove unnecessary method

linting

first data rendering

rework app initialization

http source working for ssh delete call

temp patches working

entire Embassy tab complete

not in kansas anymore

ripping, saving progress

progress for API request response types and endoint defs

Update data-model.ts

shambles, but in a good way

progress

big progress

progress

installed list working

big progress

progress

progress

begin marketplace redesign

Update api-types.ts

Update api-types.ts

marketplace improvements

cosmetic

dependencies and recommendations

begin nym auth approach

install wizard

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

21908
ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -21,29 +21,26 @@
"@angular/platform-browser": "^11.0.0",
"@angular/platform-browser-dynamic": "^11.0.0",
"@angular/router": "^11.0.0",
"@ionic/angular": "^5.4.0",
"@ionic/storage": "2.2.0",
"@start9labs/emver": "^0.1.1",
"@ionic/angular": "^5.6.0",
"@ionic/storage": "^3.0.0",
"@ionic/storage-angular": "^3.0.0",
"@ngrx/component": "^11.1.1",
"@start9labs/emver": "^0.1.4",
"ajv": "^6.12.6",
"angularx-qrcode": "^10.0.11",
"base32.js": "^0.1.0",
"base64url": "^3.0.1",
"bip39": "^3.0.2",
"bitcoinjs-lib": "^5.2.0",
"angularx-qrcode": "^11.0.0",
"compare-versions": "^3.5.0",
"core-js": "^3.4.0",
"handlebars": "^4.7.6",
"json-pointer": "^0.6.1",
"jsonpointerx": "^1.0.30",
"jsontokens": "^3.0.0",
"marked": "^2.0.0",
"rxjs": "^6.6.3",
"uuid": "^8.3.1",
"zone.js": "^0.11.2",
"patch-db-client": "file: ../../../../patch-db-client"
"patch-db-client": "file: ../../../../patch-db-client",
"rxjs": "^6.6.0",
"uuid": "^8.3.0",
"zone.js": "^0.11.2"
},
"devDependencies": {
"@angular-devkit/build-angular": "^0.1100.0",
"@angular-devkit/build-angular": "^0.1102.0",
"@angular/cli": "^11.0.0",
"@angular/compiler": "^11.0.0",
"@angular/compiler-cli": "^11.0.0",
@@ -51,12 +48,12 @@
"@ionic/angular-toolkit": "^3.0.0",
"@ionic/lab": "^3.2.9",
"@types/json-pointer": "^1.0.30",
"@types/marked": "^1.1.0",
"@types/node": "^14.11.10",
"@types/marked": "^2.0.0",
"@types/node": "^15.0.0",
"@types/uuid": "^8.0.0",
"node-html-parser": "2.0.0",
"node-html-parser": "^3.2.0",
"ts-node": "^9.1.0",
"tslint": "^6.1.0",
"typescript": "4.0.5"
"typescript": "4.1.5"
}
}

View File

@@ -2,6 +2,8 @@ import { NgModule } from '@angular/core'
import { PreloadAllModules, RouterModule, Routes } from '@angular/router'
import { AuthGuard } from './guards/auth.guard'
import { UnauthGuard } from './guards/unauth.guard'
import { MaintenanceGuard } from './guards/maintenance.guard'
import { UnmaintenanceGuard } from './guards/unmaintenance.guard'
const routes: Routes = [
{
@@ -10,34 +12,32 @@ const routes: Routes = [
path: '',
},
{
path: 'authenticate',
path: 'auth',
canActivate: [UnauthGuard],
pathMatch: 'full',
loadChildren: () => import('./pages/authenticate/authenticate.module').then( m => m.AuthenticatePageModule),
loadChildren: () => import('./pages/auth-routes/auth-routing.module').then(m => m.AuthRoutingModule),
},
{
path: 'embassy',
canActivate: [AuthGuard],
canActivateChild: [AuthGuard],
canActivate: [AuthGuard, MaintenanceGuard],
canActivateChild: [AuthGuard, MaintenanceGuard],
loadChildren: () => import('./pages/server-routes/server-routing.module').then(m => m.ServerRoutingModule),
},
{
path: 'maintenance',
canActivate: [AuthGuard, UnmaintenanceGuard],
loadChildren: () => import('./pages/maintenance/maintenance.module').then(m => m.MaintenancePageModule),
},
{
path: 'notifications',
canActivate: [AuthGuard],
canActivateChild: [AuthGuard],
canActivate: [AuthGuard, MaintenanceGuard],
loadChildren: () => import('./pages/notifications/notifications.module').then(m => m.NotificationsPageModule),
},
{
path: 'services',
canActivate: [AuthGuard],
canActivateChild: [AuthGuard],
canActivate: [AuthGuard, MaintenanceGuard],
canActivateChild: [AuthGuard, MaintenanceGuard],
loadChildren: () => import('./pages/apps-routes/apps-routing.module').then(m => m.AppsRoutingModule),
},
// {
// path: 'drives',
// canActivate: [AuthGuard],
// loadChildren: () => import('./pages/server-routes/external-drives/external-drives.module').then( m => m.ExternalDrivesPageModule),
// },
]
@NgModule({

View File

@@ -1,14 +1,16 @@
<ion-app>
<ion-split-pane (ionSplitPaneVisible)="splitPaneVisible($event)" contentId="main-content">
<ion-app *ngIf="patch.initialized">
<ion-split-pane [disabled]="!showMenu" (ionSplitPaneVisible)="splitPaneVisible($event)" contentId="main-content">
<ion-menu contentId="main-content" type="overlay">
<ion-header *ngIf="$showMenuContent$ | async" class="menu-style">
<ion-header>
<ion-toolbar style="--background: var(--ion-background-color);">
<ion-title *ngIf="serverName$ | async as name">{{ name }}</ion-title>
<ion-title *ngIf="!(serverName$ | async)"><ion-spinner name="dots" color="warning"></ion-spinner></ion-title>
<ion-title *ngIf="patch.watch$('ui', 'server-name') | ngrxPush as name; else dots">{{ name }}</ion-title>
<ng-template #dots>
<ion-title><ion-spinner name="dots" color="warning"></ion-spinner></ion-title>
</ng-template>
</ion-toolbar>
</ion-header>
<ion-content scroll-y="false" class="menu-style">
<ng-container *ngIf="$showMenuContent$ | async">
<ng-container>
<ion-list style="padding: 0px">
<ion-menu-toggle auto-hide="false" *ngFor="let page of appPages; let i = index">
<ion-item
@@ -18,144 +20,132 @@
[routerLink]="[page.url]"
lines="none"
detail="false"
[class.selected]="selectedIndex == i"
[class.selected]="selectedIndex === i"
>
<ion-icon slot="start" [name]="page.icon"></ion-icon>
<ion-label style="font-family: 'Montserrat';">{{page.title}}</ion-label>
<ion-badge *ngIf="page.url === '/notifications' && (serverBadge$ | async) as s" color="danger" style="margin-right: 3%;" [class.selected-badge]="selectedIndex == i">{{s}}</ion-badge>
<ion-label style="font-family: 'Montserrat';">{{ page.title }}</ion-label>
<ion-badge *ngIf="page.url === '/notifications' && (patch.watch$('server-info', 'unread-notification-count') | ngrxPush) as badge" color="danger" style="margin-right: 3%;" [class.selected-badge]="selectedIndex == i">{{ badge }}</ion-badge>
</ion-item>
</ion-menu-toggle>
</ion-list>
</ng-container>
<ng-container *ngIf="!($showMenuContent$ | async)">
<ion-card>
<ion-card-header>
<ion-card-title>Welcome</ion-card-title>
</ion-card-header>
<ion-card-content>
<p>This is the private website of your Start9 Embassy device.</p>
<br />
<p>Please authenticate yourself to continue.</p>
</ion-card-content>
</ion-card>
</ng-container>
</ion-content>
<ion-footer style="padding-bottom: 5%; text-align: center;" class="menu-style">
<ion-footer style="padding-bottom: 16px; text-align: center;" class="menu-style">
<ion-menu-toggle auto-hide="false">
<ion-item *ngIf="$showMenuContent$ | async" button style="--background:var(--ion-background-color); margin-bottom: 10%;" fill="clear" (click)="presentAlertLogout()">
<ion-item button style="--background:var(--ion-background-color); margin-bottom: 16px;" fill="clear" (click)="presentAlertLogout()">
<ion-icon size="small" slot="start" color="dark" name="log-out-outline"></ion-icon>
<ion-label><ion-text color="danger">Logout</ion-text></ion-label>
</ion-item>
</ion-menu-toggle>
<img style="width: 36%;" src="assets/logo-full.png">
<img style="width: 25%;" src="assets/img/logo.png">
</ion-footer>
</ion-menu>
<ion-router-outlet id="main-content"></ion-router-outlet>
</ion-split-pane>
<section id="preload" style="display: none;">
<!-- 3rd party components -->
<qrcode qrdata="hello"></qrcode>
<!-- 3rd party components -->
<qrcode qrdata="hello"></qrcode>
<img src="assets/img/running-bulb.png"/>
<img src="assets/img/issue-bulb.png"/>
<img src="assets/img/warning-bulb.png"/>
<img src="assets/img/off-bulb.png"/>
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=">
<img src="assets/img/running-bulb.png"/>
<img src="assets/img/issue-bulb.png"/>
<img src="assets/img/warning-bulb.png"/>
<img src="assets/img/off-bulb.png"/>
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=">
<!-- Ionicons -->
<ion-icon name="add"></ion-icon>
<ion-icon name="alert-outline"></ion-icon>
<ion-icon name="alert-circle-outline"></ion-icon>
<ion-icon name="arrow-back"></ion-icon>
<ion-icon name="arrow-forward"></ion-icon>
<ion-icon name="arrow-up"></ion-icon>
<ion-icon name="bookmark-outline"></ion-icon>
<ion-icon name="chevron-down"></ion-icon>
<ion-icon name="chevron-up"></ion-icon>
<ion-icon name="close"></ion-icon>
<ion-icon name="close-outline"></ion-icon>
<ion-icon name="code-outline"></ion-icon>
<ion-icon name="cog-outline"></ion-icon>
<ion-icon name="color-wand-outline"></ion-icon>
<ion-icon name="construct-outline"></ion-icon>
<ion-icon name="copy-outline"></ion-icon>
<ion-icon name="cube-outline"></ion-icon>
<ion-icon name="download-outline"></ion-icon>
<ion-icon name="ellipse"></ion-icon>
<ion-icon name="eye-off-outline"></ion-icon>
<ion-icon name="eye-outline"></ion-icon>
<ion-icon name="file-tray-stacked-outline"></ion-icon>
<ion-icon name="flash-outline"></ion-icon>
<ion-icon name="grid-outline"></ion-icon>
<ion-icon name="help-circle-outline"></ion-icon>
<ion-icon name="home-outline"></ion-icon>
<ion-icon name="information-circle-outline"></ion-icon>
<ion-icon name="list-outline"></ion-icon>
<ion-icon name="newspaper-outline"></ion-icon>
<ion-icon name="notifications-outline"></ion-icon>
<ion-icon name="rocket-outline"></ion-icon>
<ion-icon name="power"></ion-icon>
<ion-icon name="pulse"></ion-icon>
<ion-icon name="qr-code-outline"></ion-icon>
<ion-icon name="reload-outline"></ion-icon>
<ion-icon name="refresh-outline"></ion-icon>
<ion-icon name="save-outline"></ion-icon>
<ion-icon name="storefront-outline"></ion-icon>
<ion-icon name="terminal-outline"></ion-icon>
<ion-icon name="trash-outline"></ion-icon>
<ion-icon name="warning-outline"></ion-icon>
<ion-icon name="wifi"></ion-icon>
<!-- Ionic components -->
<ion-action-sheet></ion-action-sheet>
<ion-alert></ion-alert>
<ion-avatar></ion-avatar>
<ion-badge></ion-badge>
<ion-button></ion-button>
<ion-buttons></ion-buttons>
<ion-card></ion-card>
<ion-card-content></ion-card-content>
<ion-card-header></ion-card-header>
<ion-checkbox></ion-checkbox>
<ion-content></ion-content>
<ion-fab></ion-fab>
<ion-fab-button></ion-fab-button>
<ion-footer></ion-footer>
<ion-grid></ion-grid>
<ion-header></ion-header>
<ion-popover></ion-popover>
<ion-content>
<ion-refresher slot="fixed"></ion-refresher>
<ion-refresher-content pullingContent="lines"></ion-refresher-content>
<ion-infinite-scroll></ion-infinite-scroll>
<ion-infinite-scroll-content loadingSpinner="lines"></ion-infinite-scroll-content>
</ion-content>
<ion-input></ion-input>
<ion-input *ngIf="untilLoaded" type="password" value="getdots"></ion-input>
<ion-item></ion-item>
<ion-item-divider></ion-item-divider>
<ion-item-group></ion-item-group>
<ion-label></ion-label>
<ion-list></ion-list>
<ion-loading></ion-loading>
<ion-modal></ion-modal>
<ion-note></ion-note>
<ion-radio></ion-radio>
<ion-row></ion-row>
<ion-segment></ion-segment>
<ion-segment-button></ion-segment-button>
<ion-select></ion-select>
<ion-select-option></ion-select-option>
<ion-slides></ion-slides>
<ion-spinner name="dots"></ion-spinner>
<ion-spinner name="lines"></ion-spinner>
<ion-text></ion-text>
<ion-text style="font-weight: bold">load bold</ion-text>
<ion-textarea></ion-textarea>
<ion-title></ion-title>
<ion-toast></ion-toast>
<ion-toggle></ion-toggle>
<ion-toolbar></ion-toolbar>
<ion-menu-button></ion-menu-button>
<!-- Ionicons -->
<ion-icon name="add"></ion-icon>
<ion-icon name="alert-outline"></ion-icon>
<ion-icon name="alert-circle-outline"></ion-icon>
<ion-icon name="aperture-outline"></ion-icon>
<ion-icon name="arrow-back"></ion-icon>
<ion-icon name="arrow-up"></ion-icon>
<ion-icon name="bookmark-outline"></ion-icon>
<ion-icon name="chevron-down"></ion-icon>
<ion-icon name="chevron-up"></ion-icon>
<ion-icon name="chevron-forward"></ion-icon> <!-- needed for detail="true" on ion-item button -->
<ion-icon name="close"></ion-icon>
<ion-icon name="close-outline"></ion-icon>
<ion-icon name="code-outline"></ion-icon>
<ion-icon name="cog-outline"></ion-icon>
<ion-icon name="color-wand-outline"></ion-icon>
<ion-icon name="construct-outline"></ion-icon>
<ion-icon name="copy-outline"></ion-icon>
<ion-icon name="cube-outline"></ion-icon>
<ion-icon name="download-outline"></ion-icon>
<ion-icon name="ellipse"></ion-icon>
<ion-icon name="eye-off-outline"></ion-icon>
<ion-icon name="eye-outline"></ion-icon>
<ion-icon name="file-tray-stacked-outline"></ion-icon>
<ion-icon name="flash-outline"></ion-icon>
<ion-icon name="grid-outline"></ion-icon>
<ion-icon name="help-circle-outline"></ion-icon>
<ion-icon name="home-outline"></ion-icon>
<ion-icon name="information-circle-outline"></ion-icon>
<ion-icon name="list-outline"></ion-icon>
<ion-icon name="newspaper-outline"></ion-icon>
<ion-icon name="notifications-outline"></ion-icon>
<ion-icon name="rocket-outline"></ion-icon>
<ion-icon name="power"></ion-icon>
<ion-icon name="pulse"></ion-icon>
<ion-icon name="qr-code-outline"></ion-icon>
<ion-icon name="reload-outline"></ion-icon>
<ion-icon name="refresh-outline"></ion-icon>
<ion-icon name="save-outline"></ion-icon>
<ion-icon name="storefront-outline"></ion-icon>
<ion-icon name="terminal-outline"></ion-icon>
<ion-icon name="trash-outline"></ion-icon>
<ion-icon name="warning-outline"></ion-icon>
<ion-icon name="wifi"></ion-icon>
<!-- Ionic components -->
<ion-action-sheet></ion-action-sheet>
<ion-alert></ion-alert>
<ion-avatar></ion-avatar>
<ion-badge></ion-badge>
<ion-button></ion-button>
<ion-buttons></ion-buttons>
<ion-card></ion-card>
<ion-card-content></ion-card-content>
<ion-card-header></ion-card-header>
<ion-checkbox></ion-checkbox>
<ion-content></ion-content>
<ion-fab></ion-fab>
<ion-fab-button></ion-fab-button>
<ion-footer></ion-footer>
<ion-grid></ion-grid>
<ion-header></ion-header>
<ion-popover></ion-popover>
<ion-content>
<ion-refresher slot="fixed"></ion-refresher>
<ion-refresher-content pullingContent="lines"></ion-refresher-content>
<ion-infinite-scroll></ion-infinite-scroll>
<ion-infinite-scroll-content loadingSpinner="lines"></ion-infinite-scroll-content>
</ion-content>
<ion-input></ion-input>
<ion-item></ion-item>
<ion-item-divider></ion-item-divider>
<ion-item-group></ion-item-group>
<ion-label></ion-label>
<ion-list></ion-list>
<ion-loading></ion-loading>
<ion-modal></ion-modal>
<ion-note></ion-note>
<ion-radio></ion-radio>
<ion-row></ion-row>
<ion-segment></ion-segment>
<ion-segment-button></ion-segment-button>
<ion-select></ion-select>
<ion-select-option></ion-select-option>
<ion-slides></ion-slides>
<ion-spinner name="dots"></ion-spinner>
<ion-spinner name="lines"></ion-spinner>
<ion-text></ion-text>
<ion-text style="font-weight: bold">load bold</ion-text>
<ion-textarea></ion-textarea>
<ion-title></ion-title>
<ion-toast></ion-toast>
<ion-toggle></ion-toggle>
<ion-toolbar></ion-toolbar>
<ion-menu-button></ion-menu-button>
</section>
</ion-app>

View File

@@ -1,19 +1,18 @@
import { Component } from '@angular/core'
import { ServerModel, ServerStatus } from './models/server-model'
import { Storage } from '@ionic/storage'
import { SyncDaemon } from './services/sync.service'
import { AuthService, AuthState } from './services/auth.service'
import { ApiService } from './services/api/api.service'
import { Router } from '@angular/router'
import { BehaviorSubject, Observable } from 'rxjs'
import { AppModel } from './models/app-model'
import { filter, take } from 'rxjs/operators'
import { AlertController } from '@ionic/angular'
import { Router, RoutesRecognized } from '@angular/router'
import { distinctUntilChanged, filter, finalize, takeWhile } from 'rxjs/operators'
import { AlertController, ToastController } from '@ionic/angular'
import { LoaderService } from './services/loader.service'
import { Emver } from './services/emver.service'
import { SplitPaneTracker } from './services/split-pane.service'
import { LoadingOptions } from '@ionic/core'
import { pauseFor } from './util/misc.util'
import { PatchDbModel } from './models/patch-db/patch-db-model'
import { HttpService } from './services/http.service'
import { ServerStatus } from './models/patch-db/data-model'
import { ConnectionService } from './services/connection.service'
@Component({
selector: 'app-root',
@@ -21,11 +20,8 @@ import { pauseFor } from './util/misc.util'
styleUrls: ['app.component.scss'],
})
export class AppComponent {
isUpdating = false
fullPageMenu = true
$showMenuContent$ = new BehaviorSubject(false)
serverName$ : Observable<string>
serverBadge$: Observable<number>
ServerStatus = ServerStatus
showMenu = false
selectedIndex = 0
untilLoaded = true
appPages = [
@@ -49,92 +45,118 @@ export class AppComponent {
url: '/notifications',
icon: 'notifications-outline',
},
// {
// title: 'Backup drives',
// url: '/drives',
// icon: 'albums-outline',
// },
]
constructor (
private readonly serverModel: ServerModel,
private readonly syncDaemon: SyncDaemon,
private readonly storage: Storage,
private readonly appModel: AppModel,
private readonly authService: AuthService,
private readonly router: Router,
private readonly api: ApiService,
private readonly http: HttpService,
private readonly alertCtrl: AlertController,
private readonly loader: LoaderService,
private readonly emver: Emver,
private readonly connectionService: ConnectionService,
private readonly toastCtrl: ToastController,
readonly splitPane: SplitPaneTracker,
readonly patch: PatchDbModel,
) {
// set dark theme
document.body.classList.toggle('dark', true)
this.serverName$ = this.serverModel.watch().name
this.serverBadge$ = this.serverModel.watch().badge
this.init()
}
ionViewDidEnter () {
// weird bug where a browser grabbed the value 'getdots' from the app.component.html preload input field.
// this removes that field after prleloading occurs.
pauseFor(500).then(() => this.untilLoaded = false)
}
async init () {
let fromFresh = true
await this.storage.ready()
await this.authService.restoreCache()
await this.storage.create()
await this.patch.init()
await this.authService.init()
await this.emver.init()
this.authService.listen({
[AuthState.VERIFIED]: async () => {
console.log('verified')
this.api.authenticatedRequestsEnabled = true
await this.serverModel.restoreCache()
await this.appModel.restoreCache()
this.syncDaemon.start()
this.$showMenuContent$.next(true)
if (fromFresh) {
this.router.initialNavigation()
fromFresh = false
}
},
[AuthState.UNVERIFIED]: () => {
console.log('unverified')
this.api.authenticatedRequestsEnabled = false
this.serverModel.clear()
this.appModel.clear()
this.syncDaemon.stop()
this.router.initialNavigation()
// watch auth
this.authService.watch$()
.subscribe(auth => {
// VERIFIED
if (auth === AuthState.VERIFIED) {
this.http.authReqEnabled = true
this.showMenu = true
this.patch.start()
// watch network
this.watchNetwork(auth)
// watch router to highlight selected menu item
this.watchRouter(auth)
// watch status to display/hide maintenance page
this.watchStatus(auth)
// watch unread notification count to display toast
this.watchNotifications(auth)
// UNVERIFIED
} else if (auth === AuthState.UNVERIFIED) {
this.http.authReqEnabled = false
this.showMenu = false
this.patch.stop()
this.storage.clear()
this.router.navigate(['/authenticate'], { replaceUrl: true })
this.$showMenuContent$.next(false)
if (fromFresh) {
this.router.initialNavigation()
fromFresh = false
}
},
})
this.serverModel.watch().status.subscribe(s => {
this.isUpdating = (s === ServerStatus.UPDATING)
})
this.router.events.pipe(filter(e => !!(e as any).urlAfterRedirects)).subscribe((e: any) => {
const appPageIndex = this.appPages.findIndex(
appPage => (e.urlAfterRedirects || e.url || '').startsWith(appPage.url),
)
if (appPageIndex > -1) this.selectedIndex = appPageIndex
// TODO: while this works, it is dangerous and impractical.
if (e.urlAfterRedirects !== '/embassy' && e.urlAfterRedirects !== '/authenticate' && this.isUpdating) {
this.router.navigateByUrl('/embassy')
this.router.navigate(['/auth'], { replaceUrl: true })
}
})
this.api.watch401$().subscribe(() => {
this.authService.setAuthStateUnverified()
return this.api.postLogout()
this.http.watch401$().subscribe(() => {
this.authService.setUnverified()
})
}
private watchNetwork (auth: AuthState): void {
this.connectionService.monitor$()
.pipe(
distinctUntilChanged(),
takeWhile(() => auth === AuthState.VERIFIED),
)
.subscribe(c => {
console.log('CONNECTION CHANGED', c)
})
}
private watchRouter (auth: AuthState): void {
this.router.events
.pipe(
filter((e: RoutesRecognized) => !!e.urlAfterRedirects),
takeWhile(() => auth === AuthState.VERIFIED),
)
.subscribe(e => {
const appPageIndex = this.appPages.findIndex(
appPage => e.urlAfterRedirects.startsWith(appPage.url),
)
if (appPageIndex > -1) this.selectedIndex = appPageIndex
})
}
private watchStatus (auth: AuthState): void {
this.patch.watch$('server-info', 'status')
.pipe(
takeWhile(() => auth === AuthState.VERIFIED),
)
.subscribe(status => {
const maintenance = '/maintenance'
const url = this.router.url
if (status === ServerStatus.Running && url.startsWith(maintenance)) {
this.router.navigate([''], { replaceUrl: true })
}
if ([ServerStatus.Updating, ServerStatus.BackingUp].includes(status) && !url.startsWith(maintenance)) {
this.router.navigate([maintenance], { replaceUrl: true })
}
})
}
private watchNotifications (auth: AuthState): void {
let previous: number
this.patch.watch$('server-info', 'unread-notification-count')
.pipe(
takeWhile(() => auth === AuthState.VERIFIED),
finalize(() => console.log('FINALIZING!!!')),
)
.subscribe(count => {
if (previous !== undefined && count > previous) this.presentToastNotifications()
previous = count
})
}
@@ -161,20 +183,45 @@ export class AppComponent {
}
private async logout () {
this.serverName$.pipe(take(1)).subscribe(name => {
this.loader.of(LoadingSpinner(`Logging out ${name || ''}...`))
.displayDuringP(this.api.postLogout())
.then(() => this.authService.setAuthStateUnverified())
.catch(e => this.setError(e))
})
this.loader.of(LoadingSpinner('Logging out...'))
.displayDuringP(this.api.logout({ }))
.then(() => this.authService.setUnverified())
.catch(e => this.setError(e))
}
async setError (e: Error) {
private async presentToastNotifications () {
const toast = await this.toastCtrl.create({
header: 'Embassy',
message: `New notifications`,
position: 'bottom',
duration: 4000,
cssClass: 'notification-toast',
buttons: [
{
side: 'start',
icon: 'close',
handler: () => {
return true
},
},
{
side: 'end',
text: 'View',
handler: () => {
this.router.navigate(['/notifications'])
},
},
],
})
await toast.present()
}
private async setError (e: Error) {
console.error(e)
await this.presentError(e.message)
}
async presentError (e: string) {
private async presentError (e: string) {
const alert = await this.alertCtrl.create({
backdropDismiss: true,
message: `Exception on logout: ${e}`,
@@ -189,7 +236,7 @@ export class AppComponent {
}
splitPaneVisible (e: any) {
this.splitPane.$menuFixedOpenOnLeft$.next(e.detail.visible)
this.splitPane.menuFixedOpenOnLeft$.next(e.detail.visible)
}
}

View File

@@ -2,20 +2,23 @@ import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'
import { BrowserModule } from '@angular/platform-browser'
import { RouteReuseStrategy } from '@angular/router'
import { IonicModule, IonicRouteStrategy } from '@ionic/angular'
import { IonicStorageModule } from '@ionic/storage'
import { Drivers } from '@ionic/storage'
import { IonicStorageModule } from '@ionic/storage-angular'
import { HttpClientModule } from '@angular/common/http'
import { AppComponent } from './app.component'
import { AppRoutingModule } from './app-routing.module'
import { ApiService } from './services/api/api.service'
import { ApiServiceFactory } from './services/api/api.service.factory'
import { AppModel } from './models/app-model'
import { PatchDbModelFactory } from './models/patch-db/patch-db-model.factory'
import { HttpService } from './services/http.service'
import { ServerModel } from './models/server-model'
import { ConfigService } from './services/config.service'
import { QRCodeModule } from 'angularx-qrcode'
import { APP_CONFIG_COMPONENT_MAPPING } from './modals/app-config-injectable/modal-injectable-token'
import { appConfigComponents } from './modals/app-config-injectable/modal-injectable-value';
import { appConfigComponents } from './modals/app-config-injectable/modal-injectable-value'
import { OSWelcomePageModule } from './modals/os-welcome/os-welcome.module'
import { PatchDbModel } from './models/patch-db/patch-db-model'
import { LocalStorageBootstrap } from './models/patch-db/local-storage-bootstrap'
import { SharingModule } from './modules/sharing.module'
@NgModule({
declarations: [AppComponent],
@@ -25,13 +28,21 @@ import { OSWelcomePageModule } from './modals/os-welcome/os-welcome.module'
BrowserModule,
IonicModule.forRoot(),
AppRoutingModule,
IonicStorageModule.forRoot(),
IonicStorageModule.forRoot({
storeName: '_embassykv',
dbKey: '_embassykey',
name: '_embassystorage',
driverOrder: [Drivers.LocalStorage, Drivers.IndexedDB],
}),
QRCodeModule,
OSWelcomePageModule,
SharingModule,
],
providers: [
Storage,
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
{ provide: ApiService, useFactory: ApiServiceFactory, deps: [ConfigService, HttpService, AppModel, ServerModel] },
{ provide: ApiService , useFactory: ApiServiceFactory, deps: [ConfigService, HttpService] },
{ provide: PatchDbModel, useFactory: PatchDbModelFactory, deps: [ConfigService, LocalStorageBootstrap, ApiService] },
{ provide: APP_CONFIG_COMPONENT_MAPPING, useValue: appConfigComponents },
],
bootstrap: [AppComponent],

View File

@@ -1,45 +0,0 @@
import { Component, Input, OnInit } from '@angular/core'
import { ModalController } from '@ionic/angular'
import { BehaviorSubject } from 'rxjs'
import { AppInstalledFull } from 'src/app/models/app-types'
import { DiskPartition } from 'src/app/models/server-model'
@Component({
selector: 'app-backup-confirmation',
templateUrl: './app-backup-confirmation.component.html',
styleUrls: ['./app-backup-confirmation.component.scss'],
})
export class AppBackupConfirmationComponent implements OnInit {
unmasked = false
password: string
$error$: BehaviorSubject<string> = new BehaviorSubject('')
// TODO: EJECT-DISKS pass this through the modalCtrl once ejecting disks is an option in the UI.
eject = true
message: string
@Input() app: AppInstalledFull
@Input() partition: DiskPartition
constructor (private readonly modalCtrl: ModalController) { }
ngOnInit () {
this.message = `Enter your master password to create an encrypted backup of ${this.app.title} to "${this.partition.label || this.partition.logicalname}".`
}
toggleMask () {
this.unmasked = !this.unmasked
}
cancel () {
this.modalCtrl.dismiss({ cancel: true })
}
submit () {
if (!this.password || this.password.length < 12) {
this.$error$.next('Password must be at least 12 characters in length.')
return
}
const { password } = this
this.modalCtrl.dismiss({ password })
}
}

View File

@@ -1,12 +1,4 @@
<div style="position: relative; margin-right: 1vh;">
<ion-icon
*ngIf="(badge$ | async) && !(menuFixedOpen$ | async)"
size="medium"
color="dark"
[class.ios-badge]="isIos"
[class.md-badge]="!isIos"
name="alert-outline"
>
</ion-icon>
<ion-badge mode="md" class="md-badge" *ngIf="(badge$ | ngrxPush) && !(menuFixedOpen$ | ngrxPush)" color="danger">{{ badge$ | ngrxPush }}</ion-badge>
<ion-menu-button color="dark"></ion-menu-button>
</div>

View File

@@ -1,17 +1,18 @@
.ios-badge {
background-color: var(--ion-color-start9);
position: absolute;
top: 1px;
left: 62%;
border-radius: 5px;
z-index: 1;
}
// .ios-badge {
// background-color: var(--ion-color-start9);
// position: absolute;
// top: 1px;
// left: 62%;
// border-radius: 5px;
// z-index: 1;
// }
.md-badge {
background-color: var(--ion-color-start9);
position: absolute;
top: -2px;
top: -8px;
left: 56%;
border-radius: 5px;
z-index: 1;
font-size: 80%;
}

View File

@@ -1,9 +1,7 @@
import { Component } from '@angular/core'
import { ServerModel } from '../../models/server-model'
import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'
import { SplitPaneTracker } from 'src/app/services/split-pane.service'
import { isPlatform } from '@ionic/angular'
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
@Component({
selector: 'badge-menu-button',
@@ -12,16 +10,14 @@ import { isPlatform } from '@ionic/angular'
})
export class BadgeMenuComponent {
badge$: Observable<boolean>
badge$: Observable<number>
menuFixedOpen$: Observable<boolean>
isIos: boolean
constructor (
private readonly serverModel: ServerModel,
private readonly splitPane: SplitPaneTracker,
private readonly patch: PatchDbModel,
) {
this.menuFixedOpen$ = this.splitPane.$menuFixedOpenOnLeft$.asObservable()
this.badge$ = this.serverModel.watch().badge.pipe(map(i => i > 0))
this.isIos = isPlatform('ios')
this.menuFixedOpen$ = this.splitPane.menuFixedOpenOnLeft$.asObservable()
this.badge$ = this.patch.watch$('server-info', 'unread-notification-count')
}
}

View File

@@ -1,5 +1,5 @@
import { Component, Input } from '@angular/core'
import { ValueSpec } from 'src/app/app-config/config-types'
import { ValueSpec } from 'src/app/pkg-config/config-types'
@Component({
selector: 'config-header',

View File

@@ -1,14 +0,0 @@
<div *ngFor="let dep of dependenciesToDisplay; let index = i">
<marketplace-dependency-item *ngIf="depType === 'available'" style="width: 100%"
[dep]="dep"
[hostApp]="hostApp"
[$loading$]="$loading$"
>
</marketplace-dependency-item>
<installed-dependency-item *ngIf="depType === 'installed'" style="width: 100%"
[dep]="dep"
[hostApp]="hostApp"
[$loading$]="$loading$"
>
</installed-dependency-item>
</div>

View File

@@ -1,28 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { DependencyListComponent } from './dependency-list.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
import { InformationPopoverComponentModule } from '../information-popover/information-popover.component.module'
import { StatusComponentModule } from '../status/status.component.module'
import { InstalledDependencyItemComponentModule } from './installed-dependency-item/installed-dependency-item.component.module'
import { MarketplaceDependencyItemComponentModule } from './marketplace-dependency-item/marketplace-dependency-item.component.module'
@NgModule({
declarations: [
DependencyListComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
InformationPopoverComponentModule,
StatusComponentModule,
InstalledDependencyItemComponentModule,
MarketplaceDependencyItemComponentModule,
],
exports: [DependencyListComponent],
})
export class DependencyListComponentModule { }

View File

@@ -1,30 +0,0 @@
import { Component, Input } from '@angular/core'
import { BehaviorSubject } from 'rxjs'
import { AppDependency, BaseApp, isOptional } from 'src/app/models/app-types'
@Component({
selector: 'dependency-list',
templateUrl: './dependency-list.component.html',
styleUrls: ['./dependency-list.component.scss'],
})
export class DependencyListComponent {
@Input() depType: 'installed' | 'available' = 'available'
@Input() hostApp: BaseApp
@Input() dependencies: AppDependency[]
dependenciesToDisplay: AppDependency[]
@Input() $loading$: BehaviorSubject<boolean> = new BehaviorSubject(true)
constructor () { }
ngOnChanges () {
this.dependenciesToDisplay = this.dependencies.filter(dep =>
this.depType === 'available' ? !isOptional(dep) : true,
)
}
ngOnInit () {
this.dependenciesToDisplay = this.dependencies.filter(dep =>
this.depType === 'available' ? !isOptional(dep) : true,
)
}
}

View File

@@ -1,33 +0,0 @@
<ng-container *ngIf="{ loading: $loading$ | async, disabled: installing || ($loading$ | async) } as l" >
<ion-item
class="dependency"
lines="none"
>
<ion-avatar style="position: relative; height: 5vh; width: 5vh; margin: 0px;" slot="start">
<div *ngIf="!l.loading" class="badge" [style]="badgeStyle"></div>
<img [src]="dep.iconURL | iconParse" />
</ion-avatar>
<ion-label class="ion-text-wrap" style="padding: 1vh; padding-left: 2vh">
<h4 style="font-family: 'Montserrat'">{{ dep.title }}</h4>
<p style="font-size: small">{{ dep.versionSpec }}</p>
<p *ngIf="!l.loading" style="padding-top: 2px; position: relative; font-style: italic; font-size: smaller"><ion-text [color]="color">{{statusText}}</ion-text></p>
<p *ngIf="l.loading" style="padding-top: 2px; position: relative; font-style: italic; font-size: smaller"><ion-text color="medium">Refreshing</ion-text></p>
</ion-label>
<ion-button size="small" (click)="action()" *ngIf="!installing && !l.loading" color="primary" fill="outline" style="font-size: x-small">
{{actionText}}
</ion-button>
<div slot="end" *ngIf='installing || (l.loading)' >
<div *ngIf='installing && !(l.loading)' class="spinner">
<ion-spinner [color]="color" style="height: 3vh; width: 3vh" name="dots"></ion-spinner>
</div>
<div *ngIf='(l.loading)' class="spinner">
<ion-spinner [color]="medium" style="height: 3vh; width: 3vh" name="lines"></ion-spinner>
</div>
</div>
</ion-item>
</ng-container>

View File

@@ -1,22 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { InstalledDependencyItemComponent } from './installed-dependency-item.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
import { InformationPopoverComponentModule } from '../../information-popover/information-popover.component.module'
import { StatusComponentModule } from '../../status/status.component.module'
@NgModule({
declarations: [InstalledDependencyItemComponent],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
InformationPopoverComponentModule,
StatusComponentModule,
],
exports: [InstalledDependencyItemComponent],
})
export class InstalledDependencyItemComponentModule { }

View File

@@ -1,30 +0,0 @@
.spinner {
background: rgba(0,0,0,0);
border-radius: 100px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 5px;
}
.badge {
position: absolute; width: 2.5vh;
height: 2.5vh;
border-radius: 50px;
left: -1vh;
top: -1vh;
}
.xSmallText {
font-size: x-small !important;
}
.mediumText {
font-size: medium !important;
}
.opacityUp {
opacity: 0.75;
}

View File

@@ -1,113 +0,0 @@
import { Component, Input, OnInit } from '@angular/core'
import { NavigationExtras } from '@angular/router'
import { AlertController, NavController } from '@ionic/angular'
import { BehaviorSubject, Observable } from 'rxjs'
import { AppStatus } from 'src/app/models/app-model'
import { AppDependency, BaseApp, DependencyViolationSeverity, getInstalledViolationSeverity, getViolationSeverity, isInstalling, isMisconfigured, isMissing, isNotRunning, isVersionMismatch } from 'src/app/models/app-types'
import { Recommendation } from '../../recommendation-button/recommendation-button.component'
@Component({
selector: 'installed-dependency-item',
templateUrl: './installed-dependency-item.component.html',
styleUrls: ['./installed-dependency-item.component.scss'],
})
export class InstalledDependencyItemComponent implements OnInit {
@Input() dep: AppDependency
@Input() hostApp: BaseApp
@Input() $loading$: BehaviorSubject<boolean>
isLoading$: Observable<boolean>
color: string
installing = false
badgeStyle: string
violationSeverity: DependencyViolationSeverity
statusText: string
actionText: string
action: () => Promise<any>
constructor (private readonly navCtrl: NavController, private readonly alertCtrl: AlertController) { }
ngOnInit () {
this.violationSeverity = getInstalledViolationSeverity(this.dep)
const { color, statusText, installing, actionText, action } = this.getValues()
this.color = color
this.statusText = statusText
this.installing = installing
this.actionText = actionText
this.action = action
this.badgeStyle = `background: radial-gradient(var(--ion-color-${this.color}) 40%, transparent)`
}
isDanger () {
// installed dep violations are either REQUIRED or NONE, by getInstalledViolationSeverity above.
return [DependencyViolationSeverity.REQUIRED].includes(this.violationSeverity)
}
getValues (): { color: string, statusText: string, installing: boolean, actionText: string, action: () => Promise<any> } {
if (isInstalling(this.dep)) return { color: 'primary', statusText: 'Installing', installing: true, actionText: undefined, action: () => this.view() }
if (!this.isDanger()) return { color: 'success', statusText: 'Satisfied', installing: false, actionText: 'View', action: () => this.view() }
if (isMissing(this.dep)) return { color: 'warning', statusText: 'Not Installed', installing: false, actionText: 'Install', action: () => this.install() }
if (isVersionMismatch(this.dep)) return { color: 'warning', statusText: 'Incompatible Version Installed', installing: false, actionText: 'Update', action: () => this.install() }
if (isMisconfigured(this.dep)) return { color: 'warning', statusText: 'Incompatible Config', installing: false, actionText: 'Configure', action: () => this.configure() }
if (isNotRunning(this.dep)) return { color: 'warning', statusText: 'Not Running', installing: false, actionText: 'View', action: () => this.view() }
return { color: 'success', statusText: 'Satisfied', installing: false, actionText: 'View', action: () => this.view() }
}
async view () {
return this.navCtrl.navigateForward(`/services/installed/${this.dep.id}`)
}
async install () {
const verb = 'requires'
const description = `${this.hostApp.title} ${verb} an install of ${this.dep.title} satisfying ${this.dep.versionSpec}.`
const whyDependency = this.dep.description
const installationRecommendation: Recommendation = {
iconURL: this.hostApp.iconURL,
appId: this.hostApp.id,
description,
title: this.hostApp.title,
versionSpec: this.dep.versionSpec,
whyDependency,
}
const navigationExtras: NavigationExtras = {
state: { installationRecommendation },
}
return this.navCtrl.navigateForward(`/services/marketplace/${this.dep.id}`, navigationExtras)
}
async configure () {
if (this.dep.violation.name !== 'incompatible-config') return
const configViolationDesc = this.dep.violation.ruleViolations
const configViolationFormatted =
`<ul>${configViolationDesc.map(d => `<li>${d}</li>`).join('\n')}</ul>`
const configRecommendation: Recommendation = {
iconURL: this.hostApp.iconURL,
appId: this.hostApp.id,
description: configViolationFormatted,
title: this.hostApp.title,
}
const navigationExtras: NavigationExtras = {
state: { configRecommendation },
}
return this.navCtrl.navigateForward(`/services/installed/${this.dep.id}/config`, navigationExtras)
}
async presentAlertDescription() {
const description = `<p>${this.dep.description}<\p>`
const alert = await this.alertCtrl.create({
backdropDismiss: true,
message: description,
})
await alert.present()
}
}

View File

@@ -1,45 +0,0 @@
<ng-container *ngIf="{ loading: $loading$ | async, disabled: installing || ($loading$ | async) } as l" >
<ion-item
class="dependency"
style="--border-color: var(--ion-color-medium-shade)"
[lines]="presentAlertDescription ? 'inset' : 'full'"
>
<ion-avatar style="position: relative; height: 5vh; width: 5vh; margin: 0px;" slot="start">
<div *ngIf="!l.loading" class="badge" [style]="badgeStyle"></div>
<img [src]="dep.iconURL | iconParse" />
</ion-avatar>
<ion-label class="ion-text-wrap" style="padding: 1vh; padding-left: 2vh">
<h4 style="font-family: 'Montserrat'">{{ dep.title }}
<span *ngIf="recommended" style="font-family: 'Open Sans'; font-size: small; color: var(--ion-color-medium)">(recommended)</span>
</h4>
<p style="font-size: small">{{ dep.versionSpec }}</p>
<p *ngIf="!l.loading" style="padding-top: 2px; position: relative; font-style: italic; font-size: smaller"><ion-text [color]="color">{{statusText}}</ion-text></p>
<p *ngIf="l.loading" style="padding-top: 2px; position: relative; font-style: italic; font-size: smaller"><ion-text color="medium">Refreshing</ion-text></p>
</ion-label>
<ion-button size="small" (click)="presentAlertDescription=!presentAlertDescription" [disabled]="l.loading" color="medium" fill="clear" style="margin: 14px; font-size: small">
<ion-icon *ngIf="!presentAlertDescription" name="chevron-down"></ion-icon>
<ion-icon *ngIf="presentAlertDescription" name="chevron-up"></ion-icon>
</ion-button>
<ion-button size="small" (click)="toInstall()" *ngIf="!installing && !l.loading" color="primary" fill="outline" style="font-size: small">
{{actionText}}
</ion-button>
<div slot="end" *ngIf='installing || (l.loading)' style="margin: 0" >
<div *ngIf='installing && !(l.loading)' class="spinner">
<ion-spinner [color]="color" style="height: 3vh; width: 3vh" name="dots"></ion-spinner>
</div>
<div *ngIf='(l.loading)' class="spinner">
<ion-spinner [color]="medium" style="height: 3vh; width: 3vh" name="lines"></ion-spinner>
</div>
</div>
</ion-item>
<ion-item style="margin-bottom: 10px"*ngIf="presentAlertDescription" lines="none">
<div style="font-size: small; color: var(--ion-color-medium)" [innerHtml]="descriptionText"></div>
</ion-item>
<div style="height: 8px"></div>
</ng-container>

View File

@@ -1,22 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { MarketplaceDependencyItemComponent } from './marketplace-dependency-item.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
import { InformationPopoverComponentModule } from '../../information-popover/information-popover.component.module'
import { StatusComponentModule } from '../../status/status.component.module'
@NgModule({
declarations: [MarketplaceDependencyItemComponent],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
InformationPopoverComponentModule,
StatusComponentModule,
],
exports: [MarketplaceDependencyItemComponent],
})
export class MarketplaceDependencyItemComponentModule { }

View File

@@ -1,35 +0,0 @@
.spinner {
background: rgba(0,0,0,0);
border-radius: 100px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
margin: 14px;
}
.badge {
position: absolute; width: 2.5vh;
height: 2.5vh;
border-radius: 50px;
left: -1vh;
top: -1vh;
}
.xSmallText {
font-size: x-small !important;
}
.mediumText {
font-size: medium !important;
}
.opacityUp {
opacity: 0.75;
}
.dependency {
--padding-start: 20px;
--padding-end: 2px;
}

View File

@@ -1,88 +0,0 @@
import { Component, Input, OnInit } from '@angular/core'
import { NavigationExtras } from '@angular/router'
import { NavController } from '@ionic/angular'
import { BehaviorSubject, Observable } from 'rxjs'
import { AppDependency, BaseApp, DependencyViolationSeverity, getViolationSeverity, isOptional, isMissing, isInstalling, isRecommended, isVersionMismatch } from 'src/app/models/app-types'
import { Recommendation } from '../../recommendation-button/recommendation-button.component'
@Component({
selector: 'marketplace-dependency-item',
templateUrl: './marketplace-dependency-item.component.html',
styleUrls: ['./marketplace-dependency-item.component.scss'],
})
export class MarketplaceDependencyItemComponent implements OnInit {
@Input() dep: AppDependency
@Input() hostApp: BaseApp
@Input() $loading$: BehaviorSubject<boolean>
presentAlertDescription = false
isLoading$: Observable<boolean>
color: string
installing = false
recommended = false
badgeStyle: string
violationSeverity: DependencyViolationSeverity
statusText: string
actionText: 'View' | 'Get'
descriptionText: string
constructor (
private readonly navCtrl: NavController,
) { }
ngOnInit () {
this.violationSeverity = getViolationSeverity(this.dep)
if (isOptional(this.dep)) throw new Error('Do not display optional deps, satisfied or otherwise, on the AAL')
const { actionText, color, statusText, installing } = this.getValues()
this.color = color
this.statusText = statusText
this.installing = installing
this.recommended = isRecommended(this.dep)
this.actionText = actionText
this.badgeStyle = `background: radial-gradient(var(--ion-color-${this.color}) 40%, transparent)`
this.descriptionText = `<p>${this.dep.description}<\p>`
if (this.recommended) {
this.descriptionText = this.descriptionText + `<p>This service is not required: ${this.dep.optional}<\p>`
}
}
isDanger (): boolean {
return [DependencyViolationSeverity.REQUIRED, DependencyViolationSeverity.RECOMMENDED].includes(this.violationSeverity)
}
getValues (): { color: string, statusText: string, installing: boolean, actionText: 'View' | 'Get' } {
if (isInstalling(this.dep)) return { color: 'primary', statusText: 'Installing', installing: true, actionText: undefined }
if (!this.isDanger()) return { color: 'success', statusText: 'Satisfied', installing: false, actionText: 'View' }
if (isMissing(this.dep)) return { color: 'warning', statusText: 'Not Installed', installing: false, actionText: 'Get' }
if (isVersionMismatch(this.dep)) return { color: 'warning', statusText: 'Incompatible Version Installed', installing: false, actionText: 'Get' }
return { color: 'success', statusText: 'Satisfied', installing: false, actionText: 'View' }
}
async toInstall () {
if (this.actionText === 'View') return this.navCtrl.navigateForward(`/services/marketplace/${this.dep.id}`)
const verb = this.violationSeverity === DependencyViolationSeverity.REQUIRED ? 'requires' : 'recommends'
const description = `${this.hostApp.title} ${verb} an install of ${this.dep.title} satisfying ${this.dep.versionSpec}.`
const whyDependency = this.dep.description
const installationRecommendation: Recommendation = {
iconURL: this.hostApp.iconURL,
appId: this.hostApp.id,
description,
title: this.hostApp.title,
versionSpec: this.dep.versionSpec,
whyDependency,
}
const navigationExtras: NavigationExtras = {
state: { installationRecommendation },
}
return this.navCtrl.navigateForward(`/services/marketplace/${this.dep.id}`, navigationExtras)
}
}

View File

@@ -1,6 +0,0 @@
<ion-item lines="none" *ngIf="$error$ | async as error" class="notifier-item" style="margin-top: 12px">
<ion-label class="ion-text-wrap" color="danger"><p>{{ error }}</p></ion-label>
<ion-button *ngIf="dismissable" size="small" slot="end" fill="outline" color="danger" (click)="clear()">
<ion-icon style="height: 12px; width: 12px;" name="close"></ion-icon>
</ion-button>
</ion-item>

View File

@@ -1,20 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { ErrorMessageComponent } from './error-message.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
@NgModule({
declarations: [
ErrorMessageComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
],
exports: [ErrorMessageComponent],
})
export class ErrorMessageComponentModule { }

View File

@@ -1,10 +0,0 @@
.error-message {
--background: var(--ion-color-danger);
margin: 12px;
border-radius: 3px;
font-weight: bold;
}
.legacy-error-message {
margin: 5px;
}

View File

@@ -1,19 +0,0 @@
import { Component, Input, OnInit } from '@angular/core'
import { BehaviorSubject } from 'rxjs'
@Component({
selector: 'error-message',
templateUrl: './error-message.component.html',
styleUrls: ['./error-message.component.scss'],
})
export class ErrorMessageComponent implements OnInit {
@Input() $error$: BehaviorSubject<string | undefined> = new BehaviorSubject(undefined)
@Input() dismissable = true
constructor () { }
ngOnInit () { }
clear () {
this.$error$.next(undefined)
}
}

View File

@@ -1,7 +1,7 @@
<div *ngIf="!($loading$ | async) && !params.skipCompletionDialogue" class="slide-content">
<div *ngIf="!(loading$ | ngrxPush) && !params.skipCompletionDialogue" class="slide-content">
<div style="margin-top: 25px;">
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;">
<ion-label [color]="$color$ | async" style="font-size: xx-large; font-weight: bold;">
<ion-label [color]="$color$ | ngrxPush" style="font-size: xx-large; font-weight: bold;">
{{successText}}
</ion-label>
</div>
@@ -11,7 +11,7 @@
</div>
</div>
<div *ngIf="$loading$ | async" class="center-spinner">
<div *ngIf="loading$ | ngrxPush" class="center-spinner">
<ion-spinner color="warning" name="lines"></ion-spinner>
<ion-label class="long-message">{{label}}</ion-label>
</div>

View File

@@ -28,17 +28,18 @@ export class CompleteComponent implements OnInit, Loadable {
}
$loading$ = new BehaviorSubject(false)
$color$ = new BehaviorSubject('medium')
$cancel$ = new Subject<void>()
loading$ = new BehaviorSubject(false)
color$ = new BehaviorSubject('medium')
cancel$ = new Subject<void>()
label: string
summary: string
successText: string
load () {
markAsLoadingDuring$(this.$loading$, from(this.params.executeAction())).pipe(takeUntil(this.$cancel$)).subscribe(
{ error: e => this.transitions.error(new Error(`${this.params.action} failed: ${e.message || e}`)),
markAsLoadingDuring$(this.loading$, from(this.params.executeAction())).pipe(takeUntil(this.cancel$)).subscribe(
{
error: e => this.transitions.error(new Error(`${this.params.action} failed: ${e.message || e}`)),
complete: () => this.params.skipCompletionDialogue && this.transitions.final(),
},
)
@@ -50,37 +51,37 @@ export class CompleteComponent implements OnInit, Loadable {
case 'install':
this.summary = `Installation of ${this.params.title} is now in progress. You will receive a notification when the installation has completed.`
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
this.$color$.next('primary')
this.color$.next('primary')
this.successText = 'In Progress'
break
case 'downgrade':
this.summary = `Downgrade for ${this.params.title} is now in progress. You will receive a notification when the downgrade has completed.`
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
this.$color$.next('primary')
this.color$.next('primary')
this.successText = 'In Progress'
break
case 'update':
this.summary = `Update for ${this.params.title} is now in progress. You will receive a notification when the update has completed.`
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
this.$color$.next('primary')
this.color$.next('primary')
this.successText = 'In Progress'
break
case 'uninstall':
this.summary = `${capitalizeFirstLetter(this.params.title)} has been successfully uninstalled.`
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
this.$color$.next('success')
this.color$.next('success')
this.successText = 'Success'
break
case 'stop':
this.summary = `${capitalizeFirstLetter(this.params.title)} has been successfully stopped.`
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
this.$color$.next('success')
this.color$.next('success')
this.successText = 'Success'
break
case 'configure':
this.summary = `New config for ${this.params.title} has been successfully saved.`
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
this.$color$.next('success')
this.color$.next('success')
this.successText = 'Success'
break
}

View File

@@ -1,31 +0,0 @@
<div class="slide-content">
<div style="margin-top: 25px;">
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;">
<ion-label [color]="$color$ | async" style="font-size: xx-large; font-weight: bold;">
{{label}}
</ion-label>
</div>
<div class="long-message">
{{longMessage}}
</div>
<div style="margin: 25px 0px;">
<ion-item
style="--ion-item-background: rgb(0,0,0,0); margin-top: 5px"
*ngFor="let dep of dependencyViolations"
>
<ion-avatar style="position: relative; height: 4vh; width: 4vh" slot="start">
<div class="badge" [style]="dep.badgeStyle"></div>
<img [src]="dep.iconURL | iconParse" />
</ion-avatar>
<ion-label>
<h5>{{dep.title}}</h5>
<ion-text color="medium" style="font-size: smaller">{{dep.versionSpec}}</ion-text>
</ion-label>
<ion-text [color]="dep.color" style="font-size: smaller; font-style: italic; margin-right: 5px;">{{dep.violation}}</ion-text>
<status *ngIf="dep.isInstalling" [appStatus]="'INSTALLING'" size="italics-small"></status>
</ion-item>
</div>
</div>
</div>

View File

@@ -1,22 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { DependenciesComponent } from './dependencies.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
import { StatusComponentModule } from '../../status/status.component.module'
@NgModule({
declarations: [
DependenciesComponent,
],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
SharingModule,
StatusComponentModule,
],
exports: [DependenciesComponent],
})
export class DependenciesComponentModule { }

View File

@@ -1,127 +0,0 @@
import { Component, Input, OnInit } from '@angular/core'
import { PopoverController } from '@ionic/angular'
import { BehaviorSubject, Subject } from 'rxjs'
import { AppStatus } from 'src/app/models/app-model'
import { AppDependency, DependencyViolationSeverity, getViolationSeverity } from 'src/app/models/app-types'
import { displayEmver } from 'src/app/pipes/emver.pipe'
import { InformationPopoverComponent } from '../../information-popover/information-popover.component'
import { Loadable } from '../loadable'
import { WizardAction } from '../wizard-types'
@Component({
selector: 'dependencies',
templateUrl: './dependencies.component.html',
styleUrls: ['../install-wizard.component.scss'],
})
export class DependenciesComponent implements OnInit, Loadable {
@Input() params: {
action: WizardAction,
title: string,
version: string,
serviceRequirements: AppDependency[]
}
filteredServiceRequirements: AppDependency[]
$loading$ = new BehaviorSubject(false)
$cancel$ = new Subject<void>()
longMessage: string
dependencyViolations: {
iconURL: string
title: string,
versionSpec: string,
violation: string,
color: string,
badgeStyle: string
}[]
label: string
$color$ = new BehaviorSubject('medium')
constructor (private readonly popoverController: PopoverController) { }
load () {
this.$color$.next(this.$color$.getValue())
}
ngOnInit () {
this.filteredServiceRequirements = this.params.serviceRequirements.filter(dep => {
return [DependencyViolationSeverity.REQUIRED, DependencyViolationSeverity.RECOMMENDED].includes(getViolationSeverity(dep))
})
.filter(dep => ['incompatible-version', 'missing'].includes(dep.violation.name))
this.dependencyViolations = this.filteredServiceRequirements
.map(dep => ({
iconURL: dep.iconURL,
title: dep.title,
versionSpec: (dep.violation && dep.violation.name === 'incompatible-config' && 'reconfigure') || dep.versionSpec,
isInstalling: dep.violation && dep.violation.name === 'incompatible-status' && dep.violation.status === AppStatus.INSTALLING,
violation: renderViolation(dep),
color: 'medium',
badgeStyle: `background: radial-gradient(var(--ion-color-warning) 40%, transparent)`,
}))
this.setSeverityAttributes()
}
setSeverityAttributes () {
switch (getWorstViolationSeverity(this.filteredServiceRequirements)){
case DependencyViolationSeverity.REQUIRED:
this.longMessage = `${this.params.title} requires the installation of other services. Don't worry, you'll be able to install these requirements later.`
this.label = 'Notice'
this.$color$.next('dark')
break
case DependencyViolationSeverity.RECOMMENDED:
this.longMessage = `${this.params.title} recommends the installation of other services. Don't worry, you'll be able to install these requirements later.`
this.label = 'Notice'
this.$color$.next('dark')
break
default:
this.longMessage = `All installation requirements for ${this.params.title} version ${displayEmver(this.params.version)} are met.`
this.$color$.next('success')
this.label = `Ready`
}
}
async presentPopover (ev: any, information: string) {
const popover = await this.popoverController.create({
component: InformationPopoverComponent,
event: ev,
translucent: false,
showBackdrop: true,
backdropDismiss: true,
componentProps: {
information,
},
})
return popover.present()
}
}
function renderViolation1 (dep: AppDependency): string {
const severity = getViolationSeverity(dep)
switch (severity){
case DependencyViolationSeverity.REQUIRED: return 'mandatory'
case DependencyViolationSeverity.RECOMMENDED: return 'recommended'
case DependencyViolationSeverity.OPTIONAL: return 'optional'
case DependencyViolationSeverity.NONE: return 'none'
}
}
function renderViolation (dep: AppDependency): string {
const severity = renderViolation1(dep)
if (severity === 'none') return ''
switch (dep.violation.name){
case 'missing': return `${severity}`
case 'incompatible-version': return `${severity}`
case 'incompatible-config': return ``
case 'incompatible-status': return ''
default: return ''
}
}
function getWorstViolationSeverity (rs: AppDependency[]) : DependencyViolationSeverity {
if (!rs) return DependencyViolationSeverity.NONE
return rs.map(getViolationSeverity).sort( (a, b) => b - a )[0] || DependencyViolationSeverity.NONE
}

View File

@@ -1,5 +1,5 @@
<div>
<div *ngIf="!($loading$ | async)" class="slide-content">
<div *ngIf="!(loading$ | ngrxPush)" class="slide-content">
<div style="margin-top: 25px;">
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;">
<ion-label color="warning" style="font-size: xx-large; font-weight: bold;"
@@ -26,7 +26,7 @@
*ngFor="let dep of dependentBreakages"
>
<ion-avatar style="position: relative; height: 4vh; width: 4vh" slot="start">
<img [src]="dep.iconURL | iconParse" />
<img [src]="dep.iconURL" />
</ion-avatar>
<ion-label>
<h5>{{dep.title}}</h5>
@@ -35,7 +35,7 @@
</div>
</div>
</div>
<div *ngIf="$loading$ | async" class="center-spinner">
<div *ngIf="loading$ | ngrxPush" class="center-spinner">
<ion-spinner color="warning" name="lines"></ion-spinner>
<ion-label class="long-message">Checking for installed services which depend on {{params.title}}...</ion-label>
</div>

View File

@@ -1,9 +1,9 @@
import { Component, Input, OnInit } from '@angular/core'
import { BehaviorSubject, from, Subject } from 'rxjs'
import { takeUntil, tap } from 'rxjs/operators'
import { DependentBreakage } from 'src/app/models/app-types'
import { Breakages } from 'src/app/services/api/api-types'
import { markAsLoadingDuring$ } from 'src/app/services/loader.service'
import { capitalizeFirstLetter } from 'src/app/util/misc.util'
import { capitalizeFirstLetter, isEmptyObject } from 'src/app/util/misc.util'
import { Loadable } from '../loadable'
import { WizardAction } from '../wizard-types'
@@ -17,7 +17,7 @@ export class DependentsComponent implements OnInit, Loadable {
title: string,
action: WizardAction, //Are you sure you want to *uninstall*...,
verb: string, // *Uninstalling* will cause problems...
fetchBreakages: () => Promise<DependentBreakage[]>,
fetchBreakages: () => Promise<Breakages>,
skipConfirmationDialogue?: boolean
}
@Input() transitions: {
@@ -27,34 +27,33 @@ export class DependentsComponent implements OnInit, Loadable {
error: (e: Error) => void
}
dependentBreakages: DependentBreakage[]
dependentBreakages: Breakages
hasDependentViolation: boolean
longMessage: string | null = null
$color$ = new BehaviorSubject('medium') // this will display disabled while loading
$loading$ = new BehaviorSubject(false)
$cancel$ = new Subject<void>()
color$ = new BehaviorSubject('medium') // this will display disabled while loading
loading$ = new BehaviorSubject(false)
cancel$ = new Subject<void>()
constructor () { }
ngOnInit () { }
load () {
this.$color$.next('medium')
markAsLoadingDuring$(this.$loading$, from(this.params.fetchBreakages())).pipe(
takeUntil(this.$cancel$),
tap(breakages => this.dependentBreakages = breakages || []),
this.color$.next('medium')
markAsLoadingDuring$(this.loading$, from(this.params.fetchBreakages())).pipe(
takeUntil(this.cancel$),
tap(breakages => this.dependentBreakages = breakages),
).subscribe(
{
complete: () => {
this.hasDependentViolation = this.dependentBreakages && this.dependentBreakages.length > 0
this.hasDependentViolation = this.dependentBreakages && !isEmptyObject(this.dependentBreakages)
if (this.hasDependentViolation) {
this.longMessage = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title} will cause the following services to STOP running. Starting them again will require additional actions.`
this.$color$.next('warning')
this.color$.next('warning')
} else if (this.params.skipConfirmationDialogue) {
this.transitions.next()
} else {
this.longMessage = `No other services installed on your Embassy will be affected by this action.`
this.$color$.next('success')
this.color$.next('success')
}
},
error: (e: Error) => this.transitions.error(new Error(`Fetching dependent service information failed: ${e.message || e}`)),

View File

@@ -8,18 +8,18 @@
</ion-header>
<ion-content>
<ion-slides *ngIf="!($error$ | async)" id="slide-show" style="--bullet-background: white" pager="false">
<ion-slides *ngIf="!(error$ | ngrxPush)" id="slide-show" style="--bullet-background: white" pager="false">
<ion-slide *ngFor="let def of params.slideDefinitions">
<!-- We can pass [transitions]="transitions" into the component if logic within the component needs to trigger a transition (not just bottom bar) -->
<dependencies #components *ngIf="def.slide.selector === 'dependencies'" [params]="def.slide.params"></dependencies>
<notes #components *ngIf="def.slide.selector === 'notes'" [params]="def.slide.params"></notes>
<notes #components *ngIf="def.slide.selector === 'notes'" [params]="def.slide.params" style="width: 100%;"></notes>
<dependents #components *ngIf="def.slide.selector === 'dependents'" [params]="def.slide.params" [transitions]="transitions"></dependents>
<complete #components *ngIf="def.slide.selector === 'complete'" [params]="def.slide.params" [transitions]="transitions"></complete>
</ion-slide>
</ion-slides>
<div *ngIf="$error$ | async as error" class="slide-content">
<div *ngIf="error$ | ngrxPush as error" class="slide-content">
<div style="margin-top: 25px;">
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;">
<ion-label color="danger" style="font-size: xx-large; font-weight: bold;">
@@ -35,7 +35,7 @@
<ion-footer>
<ion-toolbar style="padding: 8px;">
<ng-container *ngIf="!($initializing$ | async) && !($error$ | async) && { loading: currentSlide.$loading$ | async, bar: currentBottomBar} as v">
<ng-container *ngIf="!(initializing$ | ngrxPush) && !(error$ | ngrxPush) && { loading: currentSlideloading$ | ngrxPush, bar: currentBottomBar} as v">
<!-- cancel button if loading/not loading -->
<ion-button slot="start" *ngIf="v.loading && v.bar.cancel.whileLoading as cancel" (click)="transitions.cancel()" class="toolbar-button" fill="outline" color="medium">
@@ -58,7 +58,7 @@
</ion-button>
</ng-container>
<ng-container *ngIf="$error$ | async">
<ng-container *ngIf="error$ | ngrxPush">
<ion-button slot="start" (click)="transitions.final()" style="text-transform: capitalize; font-weight: bolder;" color="danger">Dismiss</ion-button>
</ng-container>
</ion-toolbar>

View File

@@ -4,7 +4,6 @@ import { InstallWizardComponent } from './install-wizard.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
import { DependenciesComponentModule } from './dependencies/dependencies.component.module'
import { DependentsComponentModule } from './dependents/dependents.component.module'
import { CompleteComponentModule } from './complete/complete.component.module'
import { NotesComponentModule } from './notes/notes.component.module'
@@ -18,7 +17,6 @@ import { NotesComponentModule } from './notes/notes.component.module'
IonicModule,
RouterModule.forChild([]),
SharingModule,
DependenciesComponentModule,
DependentsComponentModule,
CompleteComponentModule,
NotesComponentModule,

View File

@@ -1,10 +1,8 @@
import { Component, Input, NgZone, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core'
import { Component, Input, NgZone, QueryList, ViewChild, ViewChildren } from '@angular/core'
import { IonContent, IonSlides, ModalController } from '@ionic/angular'
import { BehaviorSubject } from 'rxjs'
import { Cleanup } from 'src/app/util/cleanup'
import { capitalizeFirstLetter, pauseFor } from 'src/app/util/misc.util'
import { CompleteComponent } from './complete/complete.component'
import { DependenciesComponent } from './dependencies/dependencies.component'
import { DependentsComponent } from './dependents/dependents.component'
import { NotesComponent } from './notes/notes.component'
import { Loadable } from './loadable'
@@ -15,7 +13,7 @@ import { WizardAction } from './wizard-types'
templateUrl: './install-wizard.component.html',
styleUrls: ['./install-wizard.component.scss'],
})
export class InstallWizardComponent extends Cleanup implements OnInit {
export class InstallWizardComponent {
@Input() params: {
// defines each slide along with bottom bar
slideDefinitions: SlideDefinition[]
@@ -40,11 +38,13 @@ export class InstallWizardComponent extends Cleanup implements OnInit {
return this.params.slideDefinitions[this.slideIndex].bottomBar
}
$initializing$ = new BehaviorSubject(true)
$error$ = new BehaviorSubject(undefined)
initializing$ = new BehaviorSubject(true)
error$ = new BehaviorSubject(undefined)
constructor (private readonly modalController: ModalController, private readonly zone: NgZone) { super() }
ngOnInit () { }
constructor (
private readonly modalController: ModalController,
private readonly zone: NgZone,
) { }
ngAfterViewInit () {
this.currentSlide.load()
@@ -53,15 +53,15 @@ export class InstallWizardComponent extends Cleanup implements OnInit {
}
ionViewDidEnter () {
this.$initializing$.next(false)
this.initializing$.next(false)
}
// process bottom bar buttons
private transition = (info: { next: any } | { error: Error } | { cancelled: true } | { final: true }) => {
const i = info as { next?: any, error?: Error, cancelled?: true, final?: true }
if (i.cancelled) this.currentSlide.$cancel$.next()
if (i.cancelled) this.currentSlide.cancel$.next()
if (i.final || i.cancelled) return this.modalController.dismiss(i)
if (i.error) return this.$error$.next(capitalizeFirstLetter(i.error.message))
if (i.error) return this.error$.next(capitalizeFirstLetter(i.error.message))
this.moveToNextSlide(i.next)
}
@@ -90,7 +90,6 @@ export class InstallWizardComponent extends Cleanup implements OnInit {
export interface SlideDefinition {
slide:
{ selector: 'dependencies', params: DependenciesComponent['params'] } |
{ selector: 'dependents', params: DependentsComponent['params'] } |
{ selector: 'complete', params: CompleteComponent['params'] } |
{ selector: 'notes', params: NotesComponent['params'] }

View File

@@ -3,7 +3,7 @@ import { BehaviorSubject, Subject } from 'rxjs'
export interface Loadable {
load: (prevResult?: any) => void
result?: any // fill this variable on slide 1 to get passed into the load on slide 2. If this variable is falsey, it will skip the next slide.
$loading$: BehaviorSubject<boolean> // will be true during load function
$cancel$: Subject<void> // will cancel load function
loading$: BehaviorSubject<boolean> // will be true during load function
cancel$: Subject<void> // will cancel load function
}

View File

@@ -14,8 +14,8 @@ export class NotesComponent implements OnInit, Loadable {
titleColor: string
}
$loading$ = new BehaviorSubject(false)
$cancel$ = new Subject<void>()
loading$ = new BehaviorSubject(false)
cancel$ = new Subject<void>()
load () { }

View File

@@ -1,8 +1,7 @@
import { Injectable } from '@angular/core'
import { AppModel, AppStatus } from 'src/app/models/app-model'
import { OsUpdateService } from 'src/app/services/os-update.service'
import { InstalledPackageDataEntry } from 'src/app/models/patch-db/data-model'
import { Breakages } from 'src/app/services/api/api-types'
import { exists } from 'src/app/util/misc.util'
import { AppDependency, DependentBreakage, AppInstalledPreview } from '../../models/app-types'
import { ApiService } from '../../services/api/api.service'
import { InstallWizardComponent, SlideDefinition, TopbarParams } from './install-wizard.component'
import { WizardAction } from './wizard-types'
@@ -11,43 +10,34 @@ import { WizardAction } from './wizard-types'
export class WizardBaker {
constructor (
private readonly apiService: ApiService,
private readonly updateService: OsUpdateService,
private readonly appModel: AppModel,
) { }
install (values: {
id: string, title: string, version: string, serviceRequirements: AppDependency[], installAlert?: string
id: string, title: string, version: string, installAlert?: string
}): InstallWizardComponent['params'] {
const { id, title, version, serviceRequirements, installAlert } = values
const { id, title, version, installAlert } = values
validate(id, exists, 'missing id')
validate(title, exists, 'missing title')
validate(version, exists, 'missing version')
validate(serviceRequirements, t => !!t && Array.isArray(t), 'missing serviceRequirements')
const action = 'install'
const toolbar: TopbarParams = { action, title, version }
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
installAlert ? {
slide: {
selector: 'notes',
params: { notes: installAlert, title: 'Warning', titleColor: 'warning' },
params: {
notes: installAlert,
title: 'Warning',
titleColor: 'warning',
},
},
bottomBar: {
cancel: { afterLoading: { text: 'Cancel' } }, next: 'Next',
},
} : undefined,
{
slide: {
selector: 'dependencies',
params: { action, title, version, serviceRequirements },
},
bottomBar: {
cancel: { afterLoading: { text: 'Cancel' } },
next: 'Install',
},
},
{
slide: {
selector: 'complete',
@@ -55,7 +45,7 @@ export class WizardBaker {
action,
verb: 'beginning installation for',
title,
executeAction: () => this.apiService.installApp(id, version).then(app => { this.appModel.add({ ...app, status: AppStatus.INSTALLING })}),
executeAction: () => this.apiService.installPackage({ id, version }),
},
},
bottomBar: {
@@ -68,14 +58,13 @@ export class WizardBaker {
}
update (values: {
id: string, title: string, version: string, serviceRequirements: AppDependency[], installAlert?: string
id: string, title: string, version: string, installAlert?: string
}): InstallWizardComponent['params'] {
const { id, title, version, serviceRequirements, installAlert } = values
const { id, title, version, installAlert } = values
validate(id, exists, 'missing id')
validate(title, exists, 'missing title')
validate(version, exists, 'missing version')
validate(serviceRequirements, t => !!t && Array.isArray(t), 'missing serviceRequirements')
const action = 'update'
const toolbar: TopbarParams = { action, title, version }
@@ -84,26 +73,26 @@ export class WizardBaker {
installAlert ? {
slide: {
selector: 'notes',
params: { notes: installAlert, title: 'Warning', titleColor: 'warning'},
params: {
notes: installAlert,
title: 'Warning',
titleColor: 'warning',
},
},
bottomBar: {
cancel: { afterLoading: { text: 'Cancel' } },
next: 'Next',
},
} : undefined,
{ slide: {
selector: 'dependencies',
params: { action, title, version, serviceRequirements },
},
bottomBar: {
cancel: { afterLoading: { text: 'Cancel' } },
next: 'Update',
},
},
{ slide: {
{
slide: {
selector: 'dependents',
params: {
skipConfirmationDialogue: true, action, verb: 'updating', title, fetchBreakages: () => this.apiService.installApp(id, version, true).then( ({ breakages }) => breakages ),
skipConfirmationDialogue: true,
action,
verb: 'updating',
title,
fetchBreakages: () => this.apiService.dryUpdatePackage({ id, version }).then( ({ breakages }) => breakages ),
},
},
bottomBar: {
@@ -111,12 +100,14 @@ export class WizardBaker {
next: 'Update Anyways',
},
},
{ slide: {
{
slide: {
selector: 'complete',
params: {
action, verb: 'beginning update for', title, executeAction: () => this.apiService.installApp(id, version).then(app => {
this.appModel.update({ id: app.id, status: AppStatus.INSTALLING })
}),
action,
verb: 'beginning update for',
title,
executeAction: () => this.apiService.installPackage({ id, version }),
},
},
bottomBar: {
@@ -138,18 +129,27 @@ export class WizardBaker {
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
{ slide : {
{
slide : {
selector: 'notes',
params: { notes: releaseNotes, title: 'Release Notes', titleColor: 'dark' },
params: {
notes: releaseNotes,
title: 'Release Notes',
titleColor: 'dark',
},
},
bottomBar: {
cancel: { afterLoading: { text: 'Cancel' } }, next: 'Update OS',
},
},
{ slide: {
{
slide: {
selector: 'complete',
params: {
action, verb: 'beginning update for', title, executeAction: () => this.updateService.updateEmbassyOS(version),
action,
verb: 'beginning update for',
title,
executeAction: () => this.apiService.updateServer({ }),
},
},
bottomBar: {
@@ -162,14 +162,13 @@ export class WizardBaker {
}
downgrade (values: {
id: string, title: string, version: string, serviceRequirements: AppDependency[], installAlert?: string
id: string, title: string, version: string, installAlert?: string
}): InstallWizardComponent['params'] {
const { id, title, version, serviceRequirements, installAlert } = values
const { id, title, version, installAlert } = values
validate(id, exists, 'missing id')
validate(title, exists, 'missing title')
validate(version, exists, 'missing version')
validate(serviceRequirements, t => !!t && Array.isArray(t), 'missing serviceRequirements')
const action = 'downgrade'
const toolbar: TopbarParams = { action, title, version }
@@ -178,23 +177,22 @@ export class WizardBaker {
installAlert ? {
slide: {
selector: 'notes',
params: { notes: installAlert, title: 'Warning', titleColor: 'warning' },
params: {
notes: installAlert,
title: 'Warning',
titleColor: 'warning',
},
},
bottomBar: { cancel: { afterLoading: { text: 'Cancel' } }, next: 'Next' },
} : undefined,
{ slide: {
selector: 'dependencies',
params: { action, title, version, serviceRequirements },
},
bottomBar: {
cancel: { afterLoading: { text: 'Cancel' } },
next: 'Downgrade',
},
},
{ slide: {
selector: 'dependents',
params: {
skipConfirmationDialogue: true, action, verb: 'downgrading', title, fetchBreakages: () => this.apiService.installApp(id, version, true).then( ({ breakages }) => breakages ),
skipConfirmationDialogue: true,
action,
verb: 'downgrading',
title,
fetchBreakages: () => this.apiService.dryUpdatePackage({ id, version }).then( ({ breakages }) => breakages ),
},
},
bottomBar: {
@@ -204,9 +202,10 @@ export class WizardBaker {
{ slide: {
selector: 'complete',
params: {
action, verb: 'beginning downgrade for', title, executeAction: () => this.apiService.installApp(id, version).then(app => {
this.appModel.update({ id: app.id, status: AppStatus.INSTALLING })
}),
action,
verb: 'beginning downgrade for',
title,
executeAction: () => this.apiService.installPackage({ id, version }),
},
},
bottomBar: {
@@ -231,7 +230,8 @@ export class WizardBaker {
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
{ slide: {
{
slide: {
selector: 'notes',
params: {
notes: uninstallAlert || defaultUninstallationWarning(title),
@@ -241,18 +241,26 @@ export class WizardBaker {
},
bottomBar: { cancel: { afterLoading: { text: 'Cancel' } }, next: 'Continue' },
},
{ slide: {
{
slide: {
selector: 'dependents',
params: {
action, verb: 'uninstalling', title, fetchBreakages: () => this.apiService.uninstallApp(id, true).then( ({ breakages }) => breakages ),
action,
verb: 'uninstalling',
title,
fetchBreakages: () => this.apiService.dryRemovePackage({ id }).then( ({ breakages }) => breakages ),
},
},
bottomBar: { cancel: { whileLoading: { }, afterLoading: { text: 'Cancel' } }, next: 'Uninstall' },
},
{ slide: {
{
slide: {
selector: 'complete',
params: {
action, verb: 'uninstalling', title, executeAction: () => this.apiService.uninstallApp(id).then(() => this.appModel.delete(id)),
action,
verb: 'uninstalling',
title,
executeAction: () => this.apiService.removePackage({ id }),
},
},
bottomBar: { finish: 'Dismiss', cancel: { whileLoading: { } } },
@@ -262,7 +270,7 @@ export class WizardBaker {
}
stop (values: {
breakages: DependentBreakage[], id: string, title: string, version: string
breakages: Breakages, id: string, title: string, version: string
}): InstallWizardComponent['params'] {
const { breakages, title, version } = values
@@ -274,10 +282,14 @@ export class WizardBaker {
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
{ slide: {
{
slide: {
selector: 'dependents',
params: {
action, verb: 'stopping', title, fetchBreakages: () => Promise.resolve(breakages),
action,
verb: 'stopping',
title,
fetchBreakages: () => Promise.resolve(breakages),
},
},
bottomBar: { cancel: { afterLoading: { text: 'Cancel' } }, next: 'Stop Anyways' },
@@ -286,19 +298,20 @@ export class WizardBaker {
return { toolbar, slideDefinitions }
}
configure (values: {
breakages: DependentBreakage[], app: AppInstalledPreview
}): InstallWizardComponent['params'] {
const { breakages, app } = values
const { title, versionInstalled: version } = app
configure (values: { breakages: Breakages, pkg: InstalledPackageDataEntry }): InstallWizardComponent['params'] {
const { breakages, pkg } = values
const { title, version } = pkg.manifest
const action = 'configure'
const toolbar: TopbarParams = { action, title, version }
const slideDefinitions: SlideDefinition[] = [
{ slide: {
{
slide: {
selector: 'dependents',
params: {
action, verb: 'saving config for', title, fetchBreakages: () => Promise.resolve(breakages),
action,
verb: 'saving config for',
title, fetchBreakages: () => Promise.resolve(breakages),
},
},
bottomBar: { cancel: { afterLoading: { text: 'Cancel' } }, next: 'Save Config Anyways' },

View File

@@ -1,9 +1,9 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'
import { Annotation, Annotations } from '../../app-config/config-utilities'
import { Annotation, Annotations } from '../../pkg-config/config-utilities'
import { TrackingModalController } from 'src/app/services/tracking-modal-controller.service'
import { ConfigCursor } from 'src/app/app-config/config-cursor'
import { ModalPresentable } from 'src/app/app-config/modal-presentable'
import { ValueSpecOf, ValueSpec } from 'src/app/app-config/config-types'
import { ConfigCursor } from 'src/app/pkg-config/config-cursor'
import { ModalPresentable } from 'src/app/pkg-config/modal-presentable'
import { ValueSpecOf, ValueSpec } from 'src/app/pkg-config/config-types'
import { MaskPipe } from 'src/app/pipes/mask.pipe'
@Component({

View File

@@ -1,5 +1,6 @@
import { Component } from '@angular/core'
import { PwaBackService } from 'src/app/services/pwa-back.service'
import { NavController } from '@ionic/angular'
@Component({
selector: 'pwa-back-button',
templateUrl: './pwa-back.component.html',
@@ -7,10 +8,10 @@ import { PwaBackService } from 'src/app/services/pwa-back.service'
})
export class PwaBackComponent {
constructor (
private readonly pwaBack: PwaBackService,
private readonly nav: NavController,
) { }
navigateBack () {
return this.pwaBack.back()
return this.nav.back()
}
}

View File

@@ -4,6 +4,6 @@
border-color: #FFEB3B;
border-width: medium;
box-shadow: 0 0 10px white;" size="small" (click)="presentPopover($event)">
<img [src]="rec.iconURL | iconParse" />
<img [src]="rec.iconURL" />
</ion-fab-button>
</ion-fab>

View File

@@ -1,8 +1,7 @@
import { Component, Input, OnInit } from '@angular/core'
import { Component, Input } from '@angular/core'
import { Router } from '@angular/router'
import { PopoverController } from '@ionic/angular'
import { filter, take } from 'rxjs/operators'
import { Cleanup } from 'src/app/util/cleanup'
import { capitalizeFirstLetter } from 'src/app/util/misc.util'
import { InformationPopoverComponent } from '../information-popover/information-popover.component'
@@ -11,12 +10,13 @@ import { InformationPopoverComponent } from '../information-popover/information-
templateUrl: './recommendation-button.component.html',
styleUrls: ['./recommendation-button.component.scss'],
})
export class RecommendationButtonComponent extends Cleanup implements OnInit {
export class RecommendationButtonComponent {
@Input() rec: Recommendation
@Input() raise?: { id: string }
constructor (private readonly router: Router, private readonly popoverController: PopoverController) {
super()
}
constructor (
private readonly router: Router,
private readonly popoverController: PopoverController,
) { }
ngOnInit () {
if (!this.raise) return
@@ -41,7 +41,7 @@ export class RecommendationButtonComponent extends Cleanup implements OnInit {
componentProps: {
information: `
<div style="font-size: medium; font-style: italic; margin: 5px 0px;">
${capitalizeFirstLetter(this.rec.title)} Installation Recommendations
${capitalizeFirstLetter(this.rec.dependentTitle)} Installation Recommendations
</div>
<div>
${this.rec.description}
@@ -57,10 +57,9 @@ export class RecommendationButtonComponent extends Cleanup implements OnInit {
}
export type Recommendation = {
title: string
appId: string
iconURL: string,
description: string,
versionSpec?: string
whyDependency?: string
dependentId: string
dependentTitle: string
dependentIcon: string,
description: string
version?: string
}

View File

@@ -1,24 +1,24 @@
<p *ngIf="size === 'small'" style="margin: 0 0 4px 0;">
<ion-text [style]="style" [color]="color">{{ display }}</ion-text>
<ion-text [color]="color">{{ display }}</ion-text>
<ion-spinner *ngIf="showDots" class="dots dots-small" name="dots" [color]="color"></ion-spinner>
</p>
<h3 *ngIf="size === 'italics-small'" style="margin: 0 0 4px 0;">
<ion-text [style]="style" style="font-size: small; font-style: italic; text-transform: lowercase;" [color]="color">{{ display }}</ion-text>
<p *ngIf="size === 'italics-small'" style="margin: 0 0 4px 0; font-style: italic;">
<ion-text [color]="color">{{ display }}</ion-text>
<ion-spinner *ngIf="showDots" class="dots dots-small" name="dots" [color]="color"></ion-spinner>
</h3>
</p>
<h3 *ngIf="size === 'medium'">
<ion-text [style]="style" [color]="color">{{ display }}</ion-text>
<ion-text [color]="color">{{ display }}</ion-text>
<ion-spinner *ngIf="showDots" class="dots dots-medium" name="dots" [color]="color"></ion-spinner>
</h3>
<h1 *ngIf="size === 'large'">
<ion-text [style]="style" [color]="color">{{ display }}</ion-text>
<ion-text [color]="color">{{ display }}</ion-text>
<ion-spinner *ngIf="showDots" class="dots" name="dots" [color]="color"></ion-spinner>
</h1>
<h1 style="font-size: 18px; font-weight: 500" *ngIf="size === 'bold-large'">
<ion-text [style]="style" [color]="color">{{ display }}</ion-text>
<h1 *ngIf="size === 'bold-large'" style="font-size: 18px; font-weight: 500">
<ion-text [color]="color">{{ display }}</ion-text>
<ion-spinner *ngIf="showDots" class="dots" name="dots" [color]="color"></ion-spinner>
</h1>

View File

@@ -15,18 +15,3 @@
height: 24px;
padding-left: 12px;
}
.dots {
vertical-align: middle;
margin-left: 8px;
}
.dots-small {
width: 12px !important;
height: 12px !important;
}
.dots-medium {
width: 16px !important;
height: 16px !important;
}

View File

@@ -1,7 +1,7 @@
import { Component, Input } from '@angular/core'
import { AppStatus } from 'src/app/models/app-model'
import { ServerStatus } from 'src/app/models/server-model'
import { ServerStatusRendering, AppStatusRendering } from '../../util/status-rendering'
import { PackageDataEntry } from 'src/app/models/patch-db/data-model'
import { ConnectionState } from 'src/app/services/connection.service'
import { renderPkgStatus } from 'src/app/services/pkg-status-rendering.service'
@Component({
selector: 'status',
@@ -9,48 +9,18 @@ import { ServerStatusRendering, AppStatusRendering } from '../../util/status-ren
styleUrls: ['./status.component.scss'],
})
export class StatusComponent {
@Input() appStatus?: AppStatus
@Input() serverStatus?: ServerStatus
@Input() pkg: PackageDataEntry
@Input() connection: ConnectionState
@Input() size: 'small' | 'medium' | 'large' | 'italics-small' | 'bold-large' = 'large'
@Input() text: string = ''
color: string
display: string
showDots: boolean
style = ''
display = ''
color = ''
showDots = false
ngOnChanges () {
if (this.serverStatus) {
this.handleServerStatus()
} else if (this.appStatus) {
this.handleAppStatus()
}
}
handleServerStatus () {
let res = ServerStatusRendering[this.serverStatus]
if (!res) {
console.warn(`Received invalid server status from the server: `, this.serverStatus)
res = ServerStatusRendering[ServerStatus.UNKNOWN]
}
const { display, color, showDots } = res
const { display, color, showDots } = renderPkgStatus(this.pkg, this.connection)
this.display = display
this.color = color
this.showDots = showDots
}
handleAppStatus () {
let res = AppStatusRendering[this.appStatus]
if (!res) {
console.warn(`Received invalid app status from the server: `, this.appStatus)
res = AppStatusRendering[AppStatus.UNKNOWN]
}
const { display, color, showDots, style } = res
this.display = display + this.text
this.color = color
this.showDots = showDots
this.style = style
}
}

View File

@@ -1,5 +0,0 @@
<ion-item button lines="none" *ngIf="updateAvailable$ | async as res" (click)="confirmUpdate(res)">
<ion-label>
New EmbassyOS Version {{res.versionLatest | displayEmver}} Available!
</ion-label>
</ion-item>

View File

@@ -1,18 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { UpdateOsBannerComponent } from './update-os-banner.component'
import { IonicModule } from '@ionic/angular'
import { SharingModule } from 'src/app/modules/sharing.module'
@NgModule({
declarations: [
UpdateOsBannerComponent,
],
imports: [
CommonModule,
IonicModule,
SharingModule,
],
exports: [UpdateOsBannerComponent],
})
export class UpdateOsBannerComponentModule { }

View File

@@ -1,11 +0,0 @@
ion-item {
--background: linear-gradient(90deg, var(--ion-color-light), var(--ion-color-primary));
--min-height: 0px;
ion-label {
font-family: 'Open Sans';
font-size: small;
text-align: center;
font-weight: bold;
}
}

View File

@@ -1,35 +0,0 @@
import { Component } from '@angular/core'
import { OsUpdateService } from 'src/app/services/os-update.service'
import { Observable } from 'rxjs'
import { ModalController } from '@ionic/angular'
import { WizardBaker } from '../install-wizard/prebaked-wizards'
import { wizardModal } from '../install-wizard/install-wizard.component'
import { ReqRes } from 'src/app/services/api/api.service'
@Component({
selector: 'update-os-banner',
templateUrl: './update-os-banner.component.html',
styleUrls: ['./update-os-banner.component.scss'],
})
export class UpdateOsBannerComponent {
updateAvailable$: Observable<undefined | ReqRes.GetVersionLatestRes>
constructor (
private readonly osUpdateService: OsUpdateService,
private readonly modalCtrl: ModalController,
private readonly wizardBaker: WizardBaker,
) {
this.updateAvailable$ = this.osUpdateService.watchForUpdateAvailable$()
}
ngOnInit () { }
async confirmUpdate (res: ReqRes.GetVersionLatestRes) {
await wizardModal(
this.modalCtrl,
this.wizardBaker.updateOS({
version: res.versionLatest,
releaseNotes: res.releaseNotes,
}),
)
}
}

View File

@@ -1,36 +1,41 @@
import { Injectable } from '@angular/core'
import { CanActivate, Router, CanActivateChild } from '@angular/router'
import { tap } from 'rxjs/operators'
import { AuthState, AuthService } from '../services/auth.service'
@Injectable({
providedIn: 'root',
})
export class AuthGuard implements CanActivate, CanActivateChild {
authState: AuthState
constructor (
private readonly authService: AuthService,
private readonly router: Router,
) { }
) {
this.authService.watch$()
.pipe(
tap(auth => this.authState = auth),
).subscribe()
}
canActivate (): boolean {
return this.runCheck()
return this.runAuthCheck()
}
canActivateChild (): boolean {
return this.runCheck()
return this.runAuthCheck()
}
private runCheck (): boolean {
const state = this.authService.peek()
switch (state){
case AuthState.VERIFIED: return true
case AuthState.UNVERIFIED: return this.toAuthenticate()
case AuthState.INITIALIZING: return this.toAuthenticate()
private runAuthCheck (): boolean {
switch (this.authState){
case AuthState.VERIFIED:
return true
case AuthState.UNVERIFIED:
// @TODO could initializing cause a loop?
case AuthState.INITIALIZING:
this.router.navigate(['/auth'], { replaceUrl: true })
return false
}
}
private toAuthenticate () {
this.router.navigate(['/authenticate'], { replaceUrl: true })
return false
}
}

View File

@@ -1,26 +0,0 @@
import { Injectable, Directive } from '@angular/core'
import { CanDeactivate } from '@angular/router'
import { HostListener } from '@angular/core'
@Directive()
export abstract class PageCanDeactivate {
abstract canDeactivate (): boolean
@HostListener('window:beforeunload', ['$event'])
unloadNotification (e: any) {
console.log(e)
if (!this.canDeactivate()) {
e.returnValue = true
}
}
}
@Injectable({
providedIn: 'root',
})
export class CanDeactivateGuard implements CanDeactivate<PageCanDeactivate> {
canDeactivate (page: PageCanDeactivate): boolean {
return page.canDeactivate() || confirm('You have unsaved changes. Are you sure you want to leave the page?')
}
}

View File

@@ -0,0 +1,39 @@
import { Injectable } from '@angular/core'
import { CanActivate, Router, CanActivateChild } from '@angular/router'
import { tap } from 'rxjs/operators'
import { ServerStatus } from '../models/patch-db/data-model'
import { PatchDbModel } from '../models/patch-db/patch-db-model'
@Injectable({
providedIn: 'root',
})
export class MaintenanceGuard implements CanActivate, CanActivateChild {
serverStatus: ServerStatus
constructor (
private readonly router: Router,
private readonly patch: PatchDbModel,
) {
this.patch.watch$('server-info', 'status')
.pipe(
tap(status => this.serverStatus = status),
).subscribe()
}
canActivate (): boolean {
return this.runServerStatusCheck()
}
canActivateChild (): boolean {
return this.runServerStatusCheck()
}
private runServerStatusCheck (): boolean {
if ([ServerStatus.Updating, ServerStatus.BackingUp].includes(this.serverStatus)) {
this.router.navigate(['/maintenance'], { replaceUrl: true })
return false
} else {
return true
}
}
}

View File

@@ -1,25 +1,34 @@
import { Injectable } from '@angular/core'
import { CanActivate, Router } from '@angular/router'
import { tap } from 'rxjs/operators'
import { AuthService, AuthState } from '../services/auth.service'
@Injectable({
providedIn: 'root',
})
export class UnauthGuard implements CanActivate {
authState: AuthState
constructor (
private readonly authService: AuthService,
private readonly router: Router,
) { }
) {
this.authService.watch$()
.pipe(
tap(auth => this.authState = auth),
).subscribe()
}
canActivate (): boolean {
const state = this.authService.peek()
switch (state){
switch (this.authState){
case AuthState.VERIFIED: {
this.router.navigateByUrl('')
return false
}
case AuthState.UNVERIFIED: return true
case AuthState.INITIALIZING: return true
case AuthState.UNVERIFIED:
case AuthState.INITIALIZING:
return true
}
}
}

View File

@@ -0,0 +1,31 @@
import { Injectable } from '@angular/core'
import { CanActivate, Router } from '@angular/router'
import { tap } from 'rxjs/operators'
import { ServerStatus } from '../models/patch-db/data-model'
import { PatchDbModel } from '../models/patch-db/patch-db-model'
@Injectable({
providedIn: 'root',
})
export class UnmaintenanceGuard implements CanActivate {
serverStatus: ServerStatus
constructor (
private readonly router: Router,
private readonly patch: PatchDbModel,
) {
this.patch.watch$('server-info', 'status')
.pipe(
tap(status => this.serverStatus = status),
).subscribe()
}
canActivate (): boolean {
if (![ServerStatus.Updating, ServerStatus.BackingUp].includes(this.serverStatus)) {
this.router.navigate([''], { replaceUrl: true })
return false
} else {
return true
}
}
}

View File

@@ -1,17 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { AppBackupPage } from './app-backup.page'
import { AppBackupConfirmationComponentModule } from 'src/app/components/app-backup-confirmation/app-backup-confirmation.component.module'
@NgModule({
declarations: [AppBackupPage],
imports: [
CommonModule,
IonicModule,
AppBackupConfirmationComponentModule,
],
entryComponents: [AppBackupPage],
exports: [AppBackupPage],
})
export class AppBackupPageModule { }

View File

@@ -1,61 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-button (click)="dismiss()">
<ion-icon name="close"></ion-icon>
</ion-button>
</ion-buttons>
<ion-title>{{ type === 'create' ? 'Create Backup' : 'Restore Backup' }}</ion-title>
<ion-buttons slot="end">
<ion-button (click)="presentAlertHelp()" color="primary">
<ion-icon slot="icon-only" name="help-circle-outline"></ion-icon>
</ion-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-spinner *ngIf="loading" class="center" name="lines" color="warning"></ion-spinner>
<ng-container *ngIf="!loading">
<ion-item *ngIf="type === 'restore' && (app.restoreAlert || defaultRestoreAlert) as restore" class="notifier-item" style="box-shadow: 0 0 5px 1px var(--ion-color-danger); margin-bottom: 40px">
<ion-label class="ion-text-wrap">
<h2 style="display: flex; align-items: center; margin-bottom: 3px;">
<ion-icon style="margin-right: 5px;" slot="start" color="danger" slot="start" name="warning-outline"></ion-icon>
<ion-text color="danger" style="font-size: medium; font-weight: bold">Warning</ion-text>
</h2>
<p style="font-size: small">{{restore}}</p>
</ion-label>
</ion-item>
<ion-item *ngIf="allPartitionsMounted">
<ion-text *ngIf="type === 'create'" class="ion-text-wrap" color="warning">No partitions available. To begin a backup, insert a storage device into your Embassy.</ion-text>
<ion-text *ngIf="type === 'restore'" class="ion-text-wrap" color="warning">No partitions available. Insert the storage device containing the backup you wish to restore.</ion-text>
</ion-item>
<ion-item-group *ngFor="let d of disks">
<ion-item-divider>{{ d.logicalname }} ({{ d.size }})</ion-item-divider>
<ion-item-group>
<ion-item button [disabled]="p.isMounted" *ngFor="let p of d.partitions" (click)="presentAlert(d, p)">
<ion-icon slot="start" name="save-outline"></ion-icon>
<ion-label>
<h2>{{ p.label || p.logicalname }}</h2>
<p>{{ p.size || 'unknown size' }}</p>
<p *ngIf="!p.isMounted"><ion-text color="success">Available</ion-text></p>
<p *ngIf="p.isMounted"><ion-text color="danger">Unvailable</ion-text></p>
</ion-label>
</ion-item>
</ion-item-group>
</ion-item-group>
</ng-container>
</ion-content>

View File

@@ -1,3 +0,0 @@
.toast-close-button {
color: var(--ion-color-primary) !important;
}

View File

@@ -1,225 +0,0 @@
import { Component, Input } from '@angular/core'
import { ModalController, AlertController, LoadingController, ToastController } from '@ionic/angular'
import { AppModel, AppStatus } from 'src/app/models/app-model'
import { AppInstalledFull } from 'src/app/models/app-types'
import { ApiService } from 'src/app/services/api/api.service'
import { DiskInfo, DiskPartition } from 'src/app/models/server-model'
import { pauseFor } from 'src/app/util/misc.util'
import { concatMap } from 'rxjs/operators'
import { AppBackupConfirmationComponent } from 'src/app/components/app-backup-confirmation/app-backup-confirmation.component'
@Component({
selector: 'app-backup',
templateUrl: './app-backup.page.html',
styleUrls: ['./app-backup.page.scss'],
})
export class AppBackupPage {
@Input() app: AppInstalledFull
@Input() type: 'create' | 'restore'
disks: DiskInfo[]
loading = true
error: string
allPartitionsMounted: boolean
defaultRestoreAlert: string
constructor (
private readonly modalCtrl: ModalController,
private readonly alertCtrl: AlertController,
private readonly loadingCtrl: LoadingController,
private readonly apiService: ApiService,
private readonly appModel: AppModel,
private readonly toastCtrl: ToastController,
) { }
ngOnInit () {
this.defaultRestoreAlert = `Restoring ${this.app.title} will overwrite its current data.`
return this.getExternalDisks().then(() => this.loading = false)
}
async getExternalDisks (): Promise<void> {
try {
this.disks = await this.apiService.getExternalDisks()
this.allPartitionsMounted = this.disks.every(d => d.partitions.every(p => p.isMounted))
} catch (e) {
console.error(e)
this.error = e.message
}
}
async doRefresh (event: any) {
await Promise.all([
this.getExternalDisks(),
pauseFor(600),
])
event.target.complete()
}
async dismiss () {
await this.modalCtrl.dismiss()
}
async presentAlertHelp (): Promise<void> {
let alert: HTMLIonAlertElement
if (this.type === 'create') {
alert = await this.alertCtrl.create({
backdropDismiss: false,
header: `Backups`,
message: `Select a location to back up ${this.app.title}.<br /><br />Internal drives and drives currently backing up other services will not be available.<br /><br />Depending on the amount of data in ${this.app.title}, your first backup may take a while. Since backups are diff-based, the speed of future backups to the same disk will likely be much faster.`,
buttons: ['Dismiss'],
})
} else if (this.type === 'restore') {
alert = await this.alertCtrl.create({
backdropDismiss: false,
header: `Backups`,
message: `Select a location containing the backup you wish to restore for ${this.app.title}.<br /><br />Restoring ${this.app.title} will re-sync your service with your previous backup. The speed of the restore process depends on the backup size.`,
buttons: ['Dismiss'],
})
}
await alert.present()
}
async presentAlert (disk: DiskInfo, partition: DiskPartition): Promise<void> {
if (this.type === 'create') {
this.presentAlertCreateEncrypted(disk, partition)
} else {
this.presentAlertWarn(partition)
}
}
private async presentAlertCreateEncrypted (disk: DiskInfo, partition: DiskPartition): Promise<void> {
const m = await this.modalCtrl.create({
componentProps: {
app: this.app,
partition,
},
cssClass: 'alertlike-modal',
component: AppBackupConfirmationComponent,
backdropDismiss: false,
})
m.onWillDismiss().then(res => {
const data = res.data
if (data.cancel) return
// TODO: EJECT-DISKS we hard code the 'eject' last argument to be false, until ejection is an option in the UI. When it is, add it to the data object above ^
return this.create(disk, partition, data.password, false)
})
return await m.present()
}
private async presentAlertWarn (partition: DiskPartition): Promise<void> {
const alert = await this.alertCtrl.create({
backdropDismiss: false,
header: `Warning`,
message: `Restoring ${this.app.title} from "${partition.label || partition.logicalname}" will overwrite its current data.<br /><br />Are you sure you want to continue?`,
buttons: [
{
text: 'Cancel',
role: 'cancel',
}, {
text: 'Continue',
handler: () => {
this.presentAlertRestore(partition)
},
},
],
})
await alert.present()
}
private async presentAlertRestore (partition: DiskPartition): Promise<void> {
const alert = await this.alertCtrl.create({
backdropDismiss: false,
header: `Decrypt Backup`,
message: `Enter your master password`,
inputs: [
{
name: 'password',
type: 'password',
placeholder: 'Password',
},
],
buttons: [
{
text: 'Cancel',
role: 'cancel',
}, {
text: 'Restore',
handler: (data) => {
this.restore(partition, data.password)
},
},
],
})
await alert.present()
}
private async restore (partition: DiskPartition, password?: string): Promise<void> {
this.error = ''
const loader = await this.loadingCtrl.create({
spinner: 'lines',
cssClass: 'loader-ontop-of-all',
})
await loader.present()
try {
await this.apiService.restoreAppBackup(this.app.id, partition.logicalname, password)
this.appModel.update({ id: this.app.id, status: AppStatus.RESTORING_BACKUP })
await this.dismiss()
} catch (e) {
console.error(e)
this.error = e.message
} finally {
await loader.dismiss()
}
}
private async create (disk: DiskInfo, partition: DiskPartition, password: string, eject: boolean): Promise<void> {
this.error = ''
const loader = await this.loadingCtrl.create({
spinner: 'lines',
cssClass: 'loader-ontop-of-all',
})
await loader.present()
try {
await this.apiService.createAppBackup(this.app.id, partition.logicalname, password)
this.appModel.update({ id: this.app.id, status: AppStatus.CREATING_BACKUP })
if (eject) {
this.appModel.watchForBackup(this.app.id).pipe(concatMap(
() => this.apiService.ejectExternalDisk(disk.logicalname),
)).subscribe({
next: () => this.toastEjection(disk, true),
error: () => this.toastEjection(disk, false),
})
}
await this.dismiss()
} catch (e) {
console.error(e)
this.error = e.message
} finally {
await loader.dismiss()
}
}
private async toastEjection (disk: DiskInfo, success: boolean) {
const { header, message, cssClass } = success ? {
header: 'Success',
message: `Drive ${disk.logicalname} ejected successfully`,
cssClass: 'notification-toast',
} : {
header: 'Error',
message: `Drive ${disk.logicalname} did not eject successfully`,
cssClass: 'alert-error-message',
}
const t = await this.toastCtrl.create({
header,
message,
cssClass,
duration: 2000,
})
await t.present()
}
}

View File

@@ -1,4 +1,4 @@
import { Type } from '@angular/core'
import { ValueType } from 'src/app/app-config/config-types'
import { ValueType } from 'src/app/pkg-config/config-types'
export type AppConfigComponentMapping = { [k in ValueType]: Type<any> }

View File

@@ -1,10 +1,10 @@
import { Component, Input } from '@angular/core'
import { AlertController } from '@ionic/angular'
import { Annotations, Range } from '../../app-config/config-utilities'
import { Annotations, Range } from '../../pkg-config/config-utilities'
import { TrackingModalController } from 'src/app/services/tracking-modal-controller.service'
import { ConfigCursor } from 'src/app/app-config/config-cursor'
import { ValueSpecList, isValueSpecListOf } from 'src/app/app-config/config-types'
import { ModalPresentable } from 'src/app/app-config/modal-presentable'
import { ConfigCursor } from 'src/app/pkg-config/config-cursor'
import { ValueSpecList, isValueSpecListOf } from 'src/app/pkg-config/config-types'
import { ModalPresentable } from 'src/app/pkg-config/modal-presentable'
@Component({
selector: 'app-config-list',
@@ -106,7 +106,7 @@ export class AppConfigListPage extends ModalPresentable {
async presentAlertDelete (key: number, e: Event) {
e.stopPropagation()
const alert = await this.alertCtrl.create({
backdropDismiss: false,
header: 'Caution',

View File

@@ -1,7 +1,7 @@
import { Component, Input } from '@angular/core'
import { ModalController, AlertController } from '@ionic/angular'
import { ConfigCursor } from 'src/app/app-config/config-cursor'
import { ValueSpecObject } from 'src/app/app-config/config-types'
import { ConfigCursor } from 'src/app/pkg-config/config-cursor'
import { ValueSpecObject } from 'src/app/pkg-config/config-types'
@Component({
selector: 'app-config-object',

View File

@@ -1,9 +1,9 @@
import { Component, Input, ViewChild } from '@angular/core'
import { ModalController } from '@ionic/angular'
import { ConfigCursor } from 'src/app/app-config/config-cursor'
import { ValueSpecUnion } from 'src/app/app-config/config-types'
import { ConfigCursor } from 'src/app/pkg-config/config-cursor'
import { ValueSpecUnion } from 'src/app/pkg-config/config-types'
import { ObjectConfigComponent } from 'src/app/components/object-config/object-config.component'
import { mapUnionSpec } from '../../app-config/config-utilities'
import { mapUnionSpec } from '../../pkg-config/config-utilities'
@Component({
selector: 'app-config-union',

View File

@@ -71,7 +71,7 @@
<p *ngIf="rangeDescription">
<ion-text color="medium">{{ rangeDescription }}</ion-text>
</p>
<p *ngIf="spec.default">
<p *ngIf="spec.default !== undefined">
<ion-text color="medium">
<p>Default: {{ defaultDescription }} <ion-icon style="padding-left: 8px;" name="refresh-outline" color="primary" (click)="refreshDefault()"></ion-icon></p>
<p *ngIf="spec.type === 'number' && spec.units">Units: {{ spec.units }}</p>

View File

@@ -1,10 +1,10 @@
import { Component, Input } from '@angular/core'
import { getDefaultConfigValue, getDefaultDescription, Range } from 'src/app/app-config/config-utilities'
import { getDefaultConfigValue, getDefaultDescription, Range } from 'src/app/pkg-config/config-utilities'
import { AlertController, ToastController } from '@ionic/angular'
import { LoaderService } from 'src/app/services/loader.service'
import { TrackingModalController } from 'src/app/services/tracking-modal-controller.service'
import { ConfigCursor } from 'src/app/app-config/config-cursor'
import { ValueSpecOf } from 'src/app/app-config/config-types'
import { ConfigCursor } from 'src/app/pkg-config/config-cursor'
import { ValueSpecOf } from 'src/app/pkg-config/config-types'
import { copyToClipboard } from 'src/app/util/web.util'
@Component({

View File

@@ -8,10 +8,10 @@
<div>
<ion-item lines="none" style="--background: var(--ion-background-color); --border-color: var(--ion-color-medium);">
<ion-label style="font-size: small" position="floating">Master Password</ion-label>
<ion-input style="border-style: solid; border-width: 0px 0px 1px 0px; border-color: var(--ion-color-dark);" [(ngModel)]="password" type="password" (ionChange)="$error$.next('')"></ion-input>
<ion-input style="border-style: solid; border-width: 0px 0px 1px 0px; border-color: var(--ion-color-dark);" [(ngModel)]="password" type="password" (ionChange)="error = ''"></ion-input>
</ion-item>
<ion-item *ngIf="$error$ | async as e" lines="none" style="--background: var(--ion-background-color);">
<ion-label style="font-size: small" color="danger" class="ion-text-wrap">{{e}}</ion-label>
<ion-item *ngIf="error" lines="none" style="--background: var(--ion-background-color);">
<ion-label style="font-size: small" color="danger" class="ion-text-wrap">{{ error }}</ion-label>
</ion-item>
</div>
<div style="display: flex; justify-content: flex-end; align-items: center;">

View File

@@ -1,14 +1,14 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { AppBackupConfirmationComponent } from './app-backup-confirmation.component'
import { BackupConfirmationComponent } from './backup-confirmation.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
import { FormsModule } from '@angular/forms';
import { FormsModule } from '@angular/forms'
@NgModule({
declarations: [
AppBackupConfirmationComponent,
BackupConfirmationComponent,
],
imports: [
CommonModule,
@@ -17,6 +17,6 @@ import { FormsModule } from '@angular/forms';
SharingModule,
FormsModule,
],
exports: [AppBackupConfirmationComponent],
exports: [BackupConfirmationComponent],
})
export class AppBackupConfirmationComponentModule { }
export class BackupConfirmationComponentModule { }

View File

@@ -0,0 +1,41 @@
import { Component, Input } from '@angular/core'
import { ModalController } from '@ionic/angular'
import { PartitionInfo } from 'src/app/services/api/api-types'
@Component({
selector: 'backup-confirmation',
templateUrl: './backup-confirmation.component.html',
styleUrls: ['./backup-confirmation.component.scss'],
})
export class BackupConfirmationComponent {
@Input() name: string
unmasked = false
password: string
message: string
error = ''
constructor (
private readonly modalCtrl: ModalController,
) { }
ngOnInit () {
this.message = `Enter your master password to create an encrypted backup on "${this.name}".`
}
toggleMask () {
this.unmasked = !this.unmasked
}
cancel () {
this.modalCtrl.dismiss({ cancel: true })
}
submit () {
if (!this.password || this.password.length < 12) {
this.error = 'Password must be at least 12 characters in length.'
return
}
const { password } = this
this.modalCtrl.dismiss({ password })
}
}

View File

@@ -1,6 +1,5 @@
import { Component, Input } from '@angular/core'
import { ModalController } from '@ionic/angular'
import { ServerModel } from 'src/app/models/server-model'
import { ApiService } from 'src/app/services/api/api.service'
import { ConfigService } from 'src/app/services/config.service'
@@ -19,7 +18,7 @@ export class OSWelcomePage {
) { }
async dismiss () {
this.apiService.acknowledgeOSWelcome(this.config.version).catch(console.error)
this.apiService.setDbValue({ pointer: '/welcome-ack', value: this.config.version }).catch(console.error)
// return false to skip subsequent alert modals (e.g. check for updates modals)
// return true to show subsequent alert modals

View File

@@ -1,177 +1,153 @@
import { MapSubject, Delta, Update } from '../util/map-subject.util'
import { diff, partitionArray } from '../util/misc.util'
import { PropertySubject, complete } from '../util/property-subject.util'
import { Injectable } from '@angular/core'
import { merge, Observable, of } from 'rxjs'
import { filter, throttleTime, delay, pairwise, mapTo, take } from 'rxjs/operators'
import { Storage } from '@ionic/storage'
import { StorageKeys } from './storage-keys'
import { AppInstalledFull, AppInstalledPreview } from './app-types'
// import { MapSubject, Delta, Update } from '../util/map-subject.util'
// import { diff, partitionArray } from '../util/misc.util'
// import { Injectable } from '@angular/core'
// import { merge, Observable, of } from 'rxjs'
// import { filter, throttleTime, delay, pairwise, mapTo, take } from 'rxjs/operators'
// import { Storage } from '@ionic/storage'
// import { StorageKeys } from './storage-keys'
// import { AppInstalledFull, AppInstalledPreview } from './app-types'
@Injectable({
providedIn: 'root',
})
export class AppModel extends MapSubject<AppInstalledFull> {
// hasLoaded tells us if we've successfully queried apps from api or storage, even if there are none.
hasLoaded = false
lastUpdatedAt: { [id: string]: Date } = { }
constructor (private readonly storage: Storage) {
super()
// 500ms after first delta, will save to db. Subsequent deltas are ignored for those 500ms.
// Process continues as long as deltas fire.
this.watchDelta().pipe(throttleTime(200), delay(200)).subscribe(() => {
this.commitCache()
})
}
// @Injectable({
// providedIn: 'root',
// })
// export class AppModel extends MapSubject<AppInstalledFull> {
// // hasLoaded tells us if we've successfully queried apps from api or storage, even if there are none.
// hasLoaded = false
// lastUpdatedAt: { [id: string]: Date } = { }
// constructor (private readonly storage: Storage) {
// super()
// // 500ms after first delta, will save to db. Subsequent deltas are ignored for those 500ms.
// // Process continues as long as deltas fire.
// this.watchDelta().pipe(throttleTime(200), delay(200)).subscribe(() => {
// this.commitCache()
// })
// }
update (newValues: Update<AppInstalledFull>, timestamp: Date = new Date()): void {
this.lastUpdatedAt[newValues.id] = this.lastUpdatedAt[newValues.id] || timestamp
if (this.lastUpdatedAt[newValues.id] > timestamp) {
return
} else {
super.update(newValues)
this.lastUpdatedAt[newValues.id] = timestamp
}
}
// update (newValues: Update<AppInstalledFull>, timestamp: Date = new Date()): void {
// this.lastUpdatedAt[newValues.id] = this.lastUpdatedAt[newValues.id] || timestamp
// if (this.lastUpdatedAt[newValues.id] > timestamp) {
// return
// } else {
// super.update(newValues)
// this.lastUpdatedAt[newValues.id] = timestamp
// }
// }
// client fxns
watchDelta (filterFor?: Delta<AppInstalledFull>['action']): Observable<Delta<AppInstalledFull>> {
return filterFor
? this.$delta$.pipe(filter(d => d.action === filterFor))
: this.$delta$.asObservable()
}
// // client fxns
// watchDelta (filterFor?: Delta<AppInstalledFull>['action']): Observable<Delta<AppInstalledFull>> {
// return filterFor
// ? this.$delta$.pipe(filter(d => d.action === filterFor))
// : this.$delta$.asObservable()
// }
watch (appId: string) : PropertySubject<AppInstalledFull> {
const toReturn = super.watch(appId)
if (!toReturn) throw new Error(`Expected Service ${appId} but not found.`)
return toReturn
}
// watch (appId: string) : PropertySubject<AppInstalledFull> {
// const toReturn = super.watch(appId)
// if (!toReturn) throw new Error(`Expected Service ${appId} but not found.`)
// return toReturn
// }
// when an app is installing
watchForInstallation (appId: string): Observable<string | undefined> {
const toWatch = super.watch(appId)
if (!toWatch) return of(undefined)
// // when an app is installing
// watchForInstallation (appId: string): Observable<string | undefined> {
// const toWatch = super.watch(appId)
// if (!toWatch) return of(undefined)
return toWatch.status.pipe(
filter(s => s !== AppStatus.UNREACHABLE && s !== AppStatus.UNKNOWN),
pairwise(),
filter( ([old, _]) => old === AppStatus.INSTALLING ),
take(1),
mapTo(appId),
)
}
// return toWatch.status.pipe(
// filter(s => s !== AppStatus.UNREACHABLE && s !== AppStatus.UNKNOWN),
// pairwise(),
// filter( ([old, _]) => old === AppStatus.INSTALLING ),
// take(1),
// mapTo(appId),
// )
// }
// TODO: EJECT-DISKS: we can use this to watch for an app completing its backup process.
watchForBackup (appId: string): Observable<string | undefined> {
const toWatch = super.watch(appId)
if (!toWatch) return of(undefined)
// // TODO: EJECT-DISKS: we can use this to watch for an app completing its backup process.
// watchForBackup (appId: string): Observable<string | undefined> {
// const toWatch = super.watch(appId)
// if (!toWatch) return of(undefined)
return toWatch.status.pipe(
filter(s => s !== AppStatus.UNREACHABLE && s !== AppStatus.UNKNOWN),
pairwise(),
filter( ([old, _]) => old === AppStatus.CREATING_BACKUP),
take(1),
mapTo(appId),
)
}
// return toWatch.status.pipe(
// filter(s => s !== AppStatus.UNREACHABLE && s !== AppStatus.UNKNOWN),
// pairwise(),
// filter( ([old, _]) => old === AppStatus.CREATING_BACKUP),
// take(1),
// mapTo(appId),
// )
// }
watchForInstallations (appIds: { id: string }[]): Observable<string> {
return merge(...appIds.map(({ id }) => this.watchForInstallation(id))).pipe(
filter(t => !!t),
)
}
// watchForInstallations (appIds: { id: string }[]): Observable<string> {
// return merge(...appIds.map(({ id }) => this.watchForInstallation(id))).pipe(
// filter(t => !!t),
// )
// }
// cache mgmt
clear (): void {
this.ids.forEach(id => {
complete(this.contents[id] || { } as PropertySubject<any>)
delete this.contents[id]
})
this.hasLoaded = false
this.contents = { }
this.lastUpdatedAt = { }
}
// // cache mgmt
// clear (): void {
// this.ids.forEach(id => {
// complete(this.contents[id] || { } as PropertySubject<any>)
// delete this.contents[id]
// })
// this.hasLoaded = false
// this.contents = { }
// this.lastUpdatedAt = { }
// }
private commitCache (): Promise<void> {
return this.storage.set(StorageKeys.APPS_CACHE_KEY, this.all || [])
}
// private commitCache (): Promise<void> {
// return this.storage.set(StorageKeys.APPS_CACHE_KEY, this.all || [])
// }
async restoreCache (): Promise<void> {
const stored = await this.storage.get(StorageKeys.APPS_CACHE_KEY)
console.log(`restored app cache`, stored)
if (stored) this.hasLoaded = true
return (stored || []).map(c => this.add({ ...emptyAppInstalledFull(), ...c, status: AppStatus.UNKNOWN }))
}
// async restoreCache (): Promise<void> {
// const stored = await this.storage.get(StorageKeys.APPS_CACHE_KEY)
// console.log(`restored app cache`, stored)
// if (stored) this.hasLoaded = true
// return (stored || []).map(c => this.add({ ...emptyAppInstalledFull(), ...c, status: AppStatus.UNKNOWN }))
// }
upsertAppFull (app: AppInstalledFull): void {
this.update(app)
}
// upsertAppFull (app: AppInstalledFull): void {
// this.update(app)
// }
// synchronizers
upsertApps (apps: AppInstalledPreview[], timestamp: Date): void {
const [updates, creates] = partitionArray(apps, a => !!this.contents[a.id])
updates.map(u => this.update(u, timestamp))
creates.map(c => this.add({ ...emptyAppInstalledFull(), ...c }))
}
// // synchronizers
// upsertApps (apps: AppInstalledPreview[], timestamp: Date): void {
// const [updates, creates] = partitionArray(apps, a => !!this.contents[a.id])
// updates.map(u => this.update(u, timestamp))
// creates.map(c => this.add({ ...emptyAppInstalledFull(), ...c }))
// }
syncCache (upToDateApps : AppInstalledPreview[], timestamp: Date) {
this.hasLoaded = true
this.deleteNonexistentApps(upToDateApps)
this.upsertApps(upToDateApps, timestamp)
}
// syncCache (upToDateApps : AppInstalledPreview[], timestamp: Date) {
// this.hasLoaded = true
// this.deleteNonexistentApps(upToDateApps)
// this.upsertApps(upToDateApps, timestamp)
// }
private deleteNonexistentApps (apps: AppInstalledPreview[]): void {
const currentAppIds = apps.map(a => a.id)
const previousAppIds = Object.keys(this.contents)
const appsToDelete = diff(previousAppIds, currentAppIds)
appsToDelete.map(appId => this.delete(appId))
}
// private deleteNonexistentApps (apps: AppInstalledPreview[]): void {
// const currentAppIds = apps.map(a => a.id)
// const previousAppIds = Object.keys(this.contents)
// const appsToDelete = diff(previousAppIds, currentAppIds)
// appsToDelete.map(appId => this.delete(appId))
// }
// server state change
markAppsUnreachable (): void {
this.updateAllApps({ status: AppStatus.UNREACHABLE })
}
// // server state change
// markAppsUnreachable (): void {
// this.updateAllApps({ status: AppStatus.UNREACHABLE })
// }
markAppsUnknown (): void {
this.updateAllApps({ status: AppStatus.UNKNOWN })
}
// markAppsUnknown (): void {
// this.updateAllApps({ status: AppStatus.UNKNOWN })
// }
private updateAllApps (uniformUpdate: Partial<AppInstalledFull>) {
this.ids.map(id => {
this.update(Object.assign(uniformUpdate, { id }))
})
}
}
// private updateAllApps (uniformUpdate: Partial<AppInstalledFull>) {
// this.ids.map(id => {
// this.update(Object.assign(uniformUpdate, { id }))
// })
// }
// }
function emptyAppInstalledFull (): Omit<AppInstalledFull, keyof AppInstalledPreview> {
return {
instructions: null,
lastBackup: null,
configuredRequirements: null,
hasFetchedFull: false,
actions: [],
}
}
export interface Rules {
rule: string
description: string
}
export enum AppStatus {
// shared
UNKNOWN = 'UNKNOWN',
UNREACHABLE = 'UNREACHABLE',
INSTALLING = 'INSTALLING',
NEEDS_CONFIG = 'NEEDS_CONFIG',
RUNNING = 'RUNNING',
STOPPED = 'STOPPED',
CREATING_BACKUP = 'CREATING_BACKUP',
RESTORING_BACKUP = 'RESTORING_BACKUP',
CRASHED = 'CRASHED',
REMOVING = 'REMOVING',
DEAD = 'DEAD',
BROKEN_DEPENDENCIES = 'BROKEN_DEPENDENCIES',
STOPPING = 'STOPPING',
RESTARTING = 'RESTARTING',
}
// function emptyAppInstalledFull (): Omit<AppInstalledFull, keyof AppInstalledPreview> {
// return {
// instructions: null,
// lastBackup: null,
// configuredRequirements: null,
// hasFetchedFull: false,
// actions: [],
// }
// }

View File

@@ -1,146 +0,0 @@
import { AppStatus } from './app-model'
/** APPS **/
export interface BaseApp {
id: string
title: string
status: AppStatus | null
versionInstalled: string | null
iconURL: string
}
// available
export interface AppAvailablePreview extends BaseApp {
versionLatest: string
descriptionShort: string
latestVersionTimestamp: Date //used for sorting AAL
}
export type AppAvailableFull =
AppAvailablePreview & {
descriptionLong: string
versions: string[]
licenseName?: string // @TODO required for 0.3.0
licenseLink?: string // @TODO required for 0.3.0
} &
AppAvailableVersionSpecificInfo
export interface AppAvailableVersionSpecificInfo {
releaseNotes: string
serviceRequirements: AppDependency[]
versionViewing: string
installAlert?: string
}
// installed
export interface AppInstalledPreview extends BaseApp {
lanAddress?: string
torAddress: string
versionInstalled: string
lanUi: boolean
torUi: boolean
// FE state only
hasUI: boolean
launchable: boolean
}
export interface AppInstalledFull extends AppInstalledPreview {
licenseName?: string // @TODO required for 0.3.0
licenseLink?: string // @TODO required for 0.3.0
instructions: string | null
lastBackup: string | null
configuredRequirements: AppDependency[] | null // null if not yet configured
startAlert?: string
uninstallAlert?: string
restoreAlert?: string
actions: Actions
// FE state only
hasFetchedFull: boolean
}
export type Actions = ServiceAction[]
export interface ServiceAction {
id: string,
name: string,
description: string,
warning?: string
allowedStatuses: AppStatus[]
}
export interface AppDependency extends InstalledAppDependency {
// explanation of why it *is* optional. null represents it is required.
optional: string | null
// whether it comes as defualt in the config. This will not be present on an installed app, as we only care
default: boolean
}
export interface InstalledAppDependency extends Omit<BaseApp, 'versionInstalled' | 'status'> {
// semver specification
versionSpec: string
// an optional description of how this dependency is utlitized by the host app
description: string | null
// how the requirement is failed, null means satisfied. If the dependency is optional, this should still be set as though it were required.
// This way I can say "it's optional, but also you would need to upgrade it to versionSpec" or "it's optional, but you don't even have it"
// Said another way, if violaion === null, then this thing as a requirement is straight up satisfied.
violation: DependencyViolation | null
}
export enum DependencyViolationSeverity {
NONE = 0,
OPTIONAL = 1,
RECOMMENDED = 2,
REQUIRED = 3,
}
export function getViolationSeverity (r: AppDependency): DependencyViolationSeverity {
if (!r.optional && r.violation) return DependencyViolationSeverity.REQUIRED
if (r.optional && r.default && r.violation) return DependencyViolationSeverity.RECOMMENDED
if (isOptional(r) && r.violation) return DependencyViolationSeverity.OPTIONAL
return DependencyViolationSeverity.NONE
}
// optional not recommended
export function isOptional (r: AppDependency): boolean {
return r.optional && !r.default
}
export function isRecommended (r: AppDependency): boolean {
return r.optional && r.default
}
export function isMissing (r: AppDependency) {
return r.violation && r.violation.name === 'missing'
}
export function isMisconfigured (r: AppDependency) {
return r.violation && r.violation.name === 'incompatible-config'
}
export function isNotRunning (r: AppDependency) {
return r.violation && r.violation.name === 'incompatible-status'
}
export function isVersionMismatch (r: AppDependency) {
return r.violation && r.violation.name === 'incompatible-version'
}
export function isInstalling (r: AppDependency) {
return r.violation && r.violation.name === 'incompatible-status' && r.violation.status === AppStatus.INSTALLING
}
// both or none
export function getInstalledViolationSeverity (r: InstalledAppDependency): DependencyViolationSeverity {
if (r.violation) return DependencyViolationSeverity.REQUIRED
return DependencyViolationSeverity.NONE
}
// e.g. of I try to uninstall a thing, and some installed apps break, those apps will be returned as instances of this type.
export type DependentBreakage = Omit<BaseApp, 'versionInstalled' | 'status'>
export type DependencyViolation =
{ name: 'missing' } |
{ name: 'incompatible-version' } |
{ name: 'incompatible-config'; ruleViolations: string[]; } |
{ name: 'incompatible-status'; status: AppStatus; }

View File

@@ -1,74 +0,0 @@
import { Injectable } from '@angular/core'
import { AppModel } from './app-model'
import { AppInstalledFull, AppInstalledPreview } from './app-types'
import { ApiService } from '../services/api/api.service'
import { PropertySubject, PropertySubjectId } from '../util/property-subject.util'
import { S9Server, ServerModel } from './server-model'
import { Observable, of, from } from 'rxjs'
import { map, concatMap } from 'rxjs/operators'
import { fromSync$ } from '../util/rxjs.util'
@Injectable({
providedIn: 'root',
})
export class ModelPreload {
constructor (
private readonly appModel: AppModel,
private readonly api: ApiService,
private readonly serverModel: ServerModel,
) { }
apps (): Observable<PropertySubjectId<AppInstalledFull | AppInstalledPreview>[]> {
return fromSync$(() => this.appModel.getContents()).pipe(concatMap(apps => {
const now = new Date()
if (this.appModel.hasLoaded) {
return of(apps)
} else {
return from(this.api.getInstalledApps()).pipe(
map(appsRes => {
this.appModel.upsertApps(appsRes, now)
return this.appModel.getContents()
}),
)
}}),
)
}
appFull (appId: string): Observable<PropertySubject<AppInstalledFull> > {
return fromSync$(() => this.appModel.watch(appId)).pipe(
concatMap(app => {
// if we haven't fetched full, don't return till we do
// if we have fetched full, go ahead and return now, but fetch full again in the background
if (!app.hasFetchedFull.getValue()) {
return from(this.loadInstalledApp(appId))
} else {
this.loadInstalledApp(appId)
return of(app)
}
}),
)
}
loadInstalledApp (appId: string): Promise<PropertySubject<AppInstalledFull>> {
const now = new Date()
return this.api.getInstalledApp(appId).then(res => {
this.appModel.update({ id: appId, ...res, hasFetchedFull: true }, now)
return this.appModel.watch(appId)
})
}
server (): Observable<PropertySubject<S9Server>> {
return fromSync$(() => this.serverModel.watch()).pipe(concatMap(sw => {
if (sw.versionInstalled.getValue()) {
return of(sw)
} else {
console.warn(`server not present, preloading`)
return from(this.api.getServer()).pipe(
map(res => {
this.serverModel.update(res)
return this.serverModel.watch()
}))
}
}))
}
}

View File

@@ -0,0 +1,364 @@
import { ConfigSpec } from 'src/app/pkg-config/config-types'
export interface DataModel {
'server-info': ServerInfo
'package-data': { [id: string]: PackageDataEntry }
ui: {
'server-name': string
'welcome-ack': string
'auto-check-updates': boolean
}
}
export interface ServerInfo {
id: string
version: string
'lan-address': URL
'tor-address': URL
status: ServerStatus
registry: URL
wifi: WiFiInfo
'unread-notification-count': number
specs: {
CPU: string
Disk: string
Memory: string
}
}
export enum ServerStatus {
Running = 'running',
Updating = 'updating',
BackingUp = 'backing-up',
}
export interface WiFiInfo {
ssids: string[]
selected: string | null
connected: string | null
}
export interface PackageDataEntry {
state: PackageState
'static-files': {
license: URL
instructions: URL
icon: URL
}
'temp-manifest'?: Manifest // exists when: installing, updating, removing
installed?: InstalledPackageDataEntry, // exists when: installed, updating
'install-progress'?: InstallProgress, // exists when: installing, updating
}
export interface InstallProgress {
size: number | null
downloaded: number
'download-complete': boolean
validated: number
'validation-complete': boolean
read: number
'read-complete': boolean
}
export interface InstalledPackageDataEntry {
manifest: Manifest
status: Status
'interface-info': InterfaceInfo
'system-pointers': any[]
'current-dependents': { [id: string]: CurrentDependencyInfo }
'current-dependencies': { [id: string]: CurrentDependencyInfo }
}
export interface CurrentDependencyInfo {
pointers: any[]
'health-checks': string[] // array of health check IDs
}
export enum PackageState {
Installing = 'installing',
Installed = 'installed',
Updating = 'updating',
Removing = 'removing',
}
export interface Manifest {
id: string
title: string
version: string
description: {
short: string
long: string
}
'release-notes': string
license: string // name
'wrapper-repo': URL
'upstream-repo': URL
'support-site': URL
'marketing-site': URL
'donation-url': URL | null
alerts: {
install: string | null
uninstall: string | null
restore: string | null
start: string | null
stop: string | null
}
main: ActionImpl
'health-check': ActionImpl
config: ConfigActions | null
volumes: { [id: string]: Volume }
'min-os-version': string
interfaces: { [id: string]: InterfaceDef }
backup: BackupActions
migrations: Migrations
actions: { [id: string]: Action }
permissions: any // @TODO
dependencies: DependencyInfo
}
export interface ActionImpl {
type: 'docker'
image: string
system: boolean
entrypoint: string
args: string[]
mounts: { [id: string]: string }
'io-format': DockerIoFormat | null
inject: boolean
'shm-size': string
}
export enum DockerIoFormat {
Json = 'json',
Yaml = 'yaml',
Cbor = 'cbor',
Toml = 'toml',
}
export interface ConfigActions {
get: ActionImpl
set: ActionImpl
}
export type Volume = VolumeData
export interface VolumeData {
type: VolumeType.Data
readonly: boolean
}
export interface VolumePointer {
type: VolumeType.Pointer
'package-id': string
'volume-id': string
path: string
readonly: boolean
}
export interface VolumeCertificate {
type: VolumeType.Certificate
'interface-id': string
}
export interface VolumeHiddenService {
type: VolumeType.HiddenService
'interface-id': string
}
export interface VolumeBackup {
type: VolumeType.Backup
readonly: boolean
}
export enum VolumeType {
Data = 'data',
Pointer = 'pointer',
Certificate = 'certificate',
HiddenService = 'hidden-service',
Backup = 'backup',
}
export interface InterfaceDef {
name: string
description: string
ui: boolean
'tor-config': TorConfig | null
'lan-config': LanConfig | null
protocols: string[]
}
export interface TorConfig {
'hidden-service-version': string
'port-mapping': { [port: number]: number }
}
export type LanConfig = {
[port: number]: { ssl: boolean, mapping: number }
}
export interface BackupActions {
create: ActionImpl
restore: ActionImpl
}
export interface Migrations {
from: { [versionRange: string]: ActionImpl }
to: { [versionRange: string]: ActionImpl }
}
export interface Action {
name: string
description: string
warning: string | null
implementation: ActionImpl
'allowed-statuses': (PackageMainStatus.Stopped | PackageMainStatus.Running)[]
'input-spec': ConfigSpec
}
export interface Status {
configured: boolean
main: MainStatus
'dependency-errors': { [id: string]: DependencyError }
}
export type MainStatus = MainStatusStopped | MainStatusStopping | MainStatusRunning | MainStatusBackingUp | MainStatusRestoring
export interface MainStatusStopped {
status: PackageMainStatus.Stopped
}
export interface MainStatusStopping {
status: PackageMainStatus.Stopping
}
export interface MainStatusRunning {
status: PackageMainStatus.Running
started: string // UTC date string
health: { [id: string]: HealthCheckResult }
}
export interface MainStatusBackingUp {
status: PackageMainStatus.BackingUp
started: string | null // UTC date string
}
export interface MainStatusRestoring {
status: PackageMainStatus.Restoring
running: boolean
}
export enum PackageMainStatus {
Running = 'running',
Stopping = 'stopping',
Stopped = 'stopped',
BackingUp = 'backing-up',
Restoring = 'restoring',
}
export type HealthCheckResult = HealthCheckResultWarmingUp | HealthCheckResultDisabled | HealthCheckResultSuccess | HealthCheckResultFailure
export interface HealthCheckResultWarmingUp {
time: string // UTC date string
result: 'warming-up'
}
export interface HealthCheckResultDisabled {
time: string // UTC date string
result: 'disabled'
}
export interface HealthCheckResultSuccess {
time: string // UTC date string
result: 'success'
}
export interface HealthCheckResultFailure {
time: string // UTC date string
result: 'failure'
error: string
}
export type DependencyError = DependencyErrorNotInstalled | DependencyErrorNotRunning | DependencyErrorIncorrectVersion | DependencyErrorConfigUnsatisfied | DependencyErrorHealthCheckFailed | DependencyErrorInterfaceHealthChecksFailed
export enum DependencyErrorType {
NotInstalled = 'not-installed',
NotRunning = 'not-running',
IncorrectVersion = 'incorrect-version',
ConfigUnsatisfied = 'config-unsatisfied',
HealthCheckFailed = 'health-check-failed',
InterfaceHealthChecksFailed = 'interface-health-checks-failed',
}
export interface DependencyErrorNotInstalled {
type: DependencyErrorType.NotInstalled
title: string
icon: URL
}
export interface DependencyErrorNotRunning {
type: DependencyErrorType.NotRunning
}
export interface DependencyErrorIncorrectVersion {
type: DependencyErrorType.IncorrectVersion
expected: string // version range
received: string // version
}
export interface DependencyErrorConfigUnsatisfied {
type: DependencyErrorType.ConfigUnsatisfied
errors: string[]
}
export interface DependencyErrorHealthCheckFailed {
type: DependencyErrorType.HealthCheckFailed
check: HealthCheckResult
}
export interface DependencyErrorInterfaceHealthChecksFailed {
type: DependencyErrorType.InterfaceHealthChecksFailed
failures: { [id: string]: HealthCheckResult }
}
export interface DependencyInfo {
[id: string]: DependencyEntry
}
export interface DependencyEntry {
version: string
optional: string | null
recommended: boolean
description: string | null
config: ConfigRuleEntryWithSuggestions[]
interfaces: any[] // @TODO placeholder
}
export interface ConfigRuleEntryWithSuggestions {
rule: string
description: string
suggestions: Suggestion[]
}
export interface Suggestion {
condition: string | null
set?: {
var: string
to?: string
'to-value'?: any
'to-entropy'?: { charset: string, len: number }
}
delete?: string
push?: {
to: string
value: any
}
}
export interface InterfaceInfo {
ip: string
addresses: {
[id: string]: { 'tor-address': string, 'lan-address': string }
}
}
export type URL = string

View File

@@ -0,0 +1,29 @@
import { Bootstrapper, DBCache } from 'patch-db-client'
import { DataModel } from './data-model'
import { Injectable } from '@angular/core'
import { Storage } from '@ionic/storage'
@Injectable({
providedIn: 'root',
})
export class LocalStorageBootstrap implements Bootstrapper<DataModel> {
static CONTENT_KEY = 'patch-db-cache'
constructor (
private readonly storage: Storage,
) { }
async init (): Promise<DBCache<DataModel>> {
const cache = await this.storage.get(LocalStorageBootstrap.CONTENT_KEY)
if (!cache) return { sequence: 0, data: { } as DataModel }
return cache
}
async update (cache: DBCache<DataModel>): Promise<void> {
return this.storage.set(LocalStorageBootstrap.CONTENT_KEY, cache)
}
async clear (): Promise<void> {
return this.storage.remove(LocalStorageBootstrap.CONTENT_KEY)
}
}

View File

@@ -0,0 +1,24 @@
import { PollSource, Source, WebsocketSource } from 'patch-db-client'
import { ConfigService } from 'src/app/services/config.service'
import { DataModel } from './data-model'
import { LocalStorageBootstrap } from './local-storage-bootstrap'
import { PatchDbModel } from './patch-db-model'
import { ApiService } from 'src/app/services/api/api.service'
export function PatchDbModelFactory (
config: ConfigService,
bootstrapper: LocalStorageBootstrap,
http: ApiService,
): PatchDbModel {
const { patchDb: { usePollOverride, poll, websocket, timeoutForMissingRevision }, isConsulate } = config
let source: Source<DataModel>
if (isConsulate || usePollOverride) {
source = new PollSource({ ...poll }, http)
} else {
source = new WebsocketSource({ ...websocket })
}
return new PatchDbModel({ sources: [source, http], bootstrapper, http, timeoutForMissingRevision })
}

View File

@@ -0,0 +1,52 @@
import { Inject, Injectable, InjectionToken } from '@angular/core'
import { PatchDB, PatchDbConfig, Store } from 'patch-db-client'
import { Observable, of, Subscription } from 'rxjs'
import { catchError, finalize } from 'rxjs/operators'
import { DataModel } from './data-model'
export const PATCH_CONFIG = new InjectionToken<PatchDbConfig<DataModel>>('app.config')
@Injectable({
providedIn: 'root',
})
export class PatchDbModel {
private patchDb: PatchDB<DataModel>
private syncSub: Subscription
initialized = false
constructor (
@Inject(PATCH_CONFIG) private readonly conf: PatchDbConfig<DataModel>,
) { }
async init (): Promise<void> {
if (this.patchDb) return console.warn('Cannot re-init patchDbModel')
this.patchDb = await PatchDB.init<DataModel>(this.conf)
this.initialized = true
}
start (): void {
if (this.syncSub) this.stop()
this.syncSub = this.patchDb.sync$().subscribe({
error: e => console.error('Critical, patch-db-sync sub error', e),
complete: () => console.error('Critical, patch-db-sync sub complete'),
})
}
stop (): void {
if (this.syncSub) {
this.syncSub.unsubscribe()
this.syncSub = undefined
}
}
watch$: Store<DataModel>['watch$'] = (...args: (string | number)[]): Observable<DataModel> => {
// console.log('WATCHING')
return this.patchDb.store.watch$(...(args as [])).pipe(
catchError(e => {
console.error(e)
return of(e.message)
}),
// finalize(() => console.log('unSUBSCRIBing')),
)
}
}

View File

@@ -1,177 +0,0 @@
import { Injectable } from '@angular/core'
import { Subject, BehaviorSubject } from 'rxjs'
import { PropertySubject, peekProperties, initPropertySubject } from '../util/property-subject.util'
import { AppModel } from './app-model'
import { ConfigService } from 'src/app/services/config.service'
import { Storage } from '@ionic/storage'
import { throttleTime, delay } from 'rxjs/operators'
import { StorageKeys } from './storage-keys'
@Injectable({
providedIn: 'root',
})
export class ServerModel {
lastUpdateTimestamp: Date
$delta$ = new Subject<void>()
private embassy: PropertySubject<S9Server>
constructor (
private readonly storage: Storage,
private readonly appModel: AppModel,
private readonly config: ConfigService,
) {
this.embassy = this.defaultEmbassy()
this.$delta$.pipe(
throttleTime(500), delay(500),
).subscribe(() => {
this.commitCache()
})
}
// client fxns
watch (): PropertySubject<S9Server> {
return this.embassy
}
peek (): S9Server {
return peekProperties(this.embassy)
}
update (update: Partial<S9Server>, timestamp: Date = new Date()): void {
if (this.lastUpdateTimestamp > timestamp) return
if (update.versionInstalled && (update.versionInstalled !== this.config.version) && this.embassy.status.getValue() === ServerStatus.RUNNING) {
console.log('update detected, force reload page')
this.clear()
this.nukeCache().then(
() => location.replace('?upd=' + new Date()),
)
}
Object.entries(update).forEach(
([key, value]) => {
if (!this.embassy[key]) {
console.warn('Received an unexpected key: ', key)
this.embassy[key] = new BehaviorSubject(value)
} else if (JSON.stringify(this.embassy[key].getValue()) !== JSON.stringify(value)) {
this.embassy[key].next(value)
}
},
)
this.$delta$.next()
this.lastUpdateTimestamp = timestamp
}
// cache mgmt
clear () {
this.update(peekProperties(this.defaultEmbassy()))
}
private commitCache (): Promise<void> {
return this.storage.set(StorageKeys.SERVER_CACHE_KEY, peekProperties(this.embassy))
}
private nukeCache (): Promise<void> {
return this.storage.remove(StorageKeys.SERVER_CACHE_KEY)
}
async restoreCache (): Promise<void> {
const emb = await this.storage.get(StorageKeys.SERVER_CACHE_KEY)
if (emb && emb.versionInstalled === this.config.version) this.update(emb)
}
// server state change
markUnreachable (): void {
this.update({ status: ServerStatus.UNREACHABLE })
this.appModel.markAppsUnreachable()
}
markUnknown (): void {
this.update({ status: ServerStatus.UNKNOWN })
this.appModel.markAppsUnknown()
}
defaultEmbassy (): PropertySubject<S9Server> {
return initPropertySubject({
serverId: undefined,
name: undefined,
origin: this.config.origin,
versionInstalled: undefined,
versionLatest: undefined,
status: ServerStatus.UNKNOWN,
badge: 0,
alternativeRegistryUrl: undefined,
specs: { },
wifi: { ssids: [], current: undefined },
ssh: [],
notifications: [],
welcomeAck: true,
autoCheckUpdates: true,
})
}
}
export interface S9Server {
serverId: string
name: string
origin: string
versionInstalled: string
versionLatest: string | undefined // not on the api as of 0.2.8
status: ServerStatus
badge: number
alternativeRegistryUrl: string | null
specs: ServerSpecs
wifi: { ssids: string[], current: string }
ssh: SSHFingerprint[]
notifications: S9Notification[]
welcomeAck: boolean
autoCheckUpdates: boolean
}
export interface S9Notification {
id: string
appId: string
createdAt: string
code: string
title: string
message: string
}
export interface ServerSpecs {
[key: string]: string | number
}
export interface ServerMetrics {
[key: string]: {
[key: string]: {
value: string | number | null
unit?: string
}
}
}
export interface SSHFingerprint {
alg: string
hash: string
hostname: string
}
export interface DiskInfo {
logicalname: string,
size: string,
description: string | null,
partitions: DiskPartition[]
}
export interface DiskPartition {
logicalname: string,
isMounted: boolean, // We do not allow backups to mounted partitions
size: string | null,
label: string | null,
}
export enum ServerStatus {
UNKNOWN = 'UNKNOWN',
UNREACHABLE = 'UNREACHABLE',
UPDATING = 'UPDATING',
NEEDS_CONFIG = 'NEEDS_CONFIG',
RUNNING = 'RUNNING',
}

View File

@@ -1,52 +1,65 @@
import { NgModule } from '@angular/core'
import { EmverComparesPipe, EmverSatisfiesPipe, EmverDisplayPipe, EmverIsValidPipe } from '../pipes/emver.pipe'
import { EmverComparesPipe, EmverSatisfiesPipe, EmverDisplayPipe } from '../pipes/emver.pipe'
import { IncludesPipe } from '../pipes/includes.pipe'
import { IconPipe } from '../pipes/icon.pipe'
import { TypeofPipe } from '../pipes/typeof.pipe'
import { MarkdownPipe } from '../pipes/markdown.pipe'
import { PeekPropertiesPipe } from '../pipes/peek-properties.pipe'
import { InstalledLatestComparisonPipe, InstalledViewingComparisonPipe } from '../pipes/installed-latest-comparison.pipe'
// import { InstalledLatestComparisonPipe, InstalledViewingComparisonPipe } from '../pipes/installed-latest-comparison.pipe'
import { AnnotationStatusPipe } from '../pipes/annotation-status.pipe'
import { TruncateCenterPipe, TruncateEndPipe } from '../pipes/truncate.pipe'
import { MaskPipe } from '../pipes/mask.pipe'
import { DisplayBulbPipe } from '../pipes/display-bulb.pipe'
import { HasUiPipe, LaunchablePipe, ManifestPipe } from '../pipes/ui.pipe'
import { EmptyPipe } from '../pipes/empty.pipe'
import { StatusPipe } from '../pipes/status.pipe'
import { NotificationColorPipe } from '../pipes/notification-color.pipe'
import { ReactiveComponentModule } from '@ngrx/component'
@NgModule({
declarations: [
EmverComparesPipe,
EmverSatisfiesPipe,
TypeofPipe,
IconPipe,
IncludesPipe,
MarkdownPipe,
PeekPropertiesPipe,
InstalledLatestComparisonPipe,
InstalledViewingComparisonPipe,
// InstalledLatestComparisonPipe,
// InstalledViewingComparisonPipe,
AnnotationStatusPipe,
TruncateCenterPipe,
TruncateEndPipe,
MaskPipe,
DisplayBulbPipe,
EmverDisplayPipe,
EmverIsValidPipe,
HasUiPipe,
LaunchablePipe,
ManifestPipe,
EmptyPipe,
StatusPipe,
NotificationColorPipe,
],
imports: [
ReactiveComponentModule,
],
exports: [
EmverComparesPipe,
EmverSatisfiesPipe,
TypeofPipe,
IconPipe,
IncludesPipe,
MarkdownPipe,
PeekPropertiesPipe,
InstalledLatestComparisonPipe,
// InstalledLatestComparisonPipe,
// InstalledViewingComparisonPipe,
AnnotationStatusPipe,
InstalledViewingComparisonPipe,
TruncateEndPipe,
TruncateCenterPipe,
MaskPipe,
DisplayBulbPipe,
EmverDisplayPipe,
EmverIsValidPipe,
HasUiPipe,
LaunchablePipe,
ManifestPipe,
EmptyPipe,
StatusPipe,
NotificationColorPipe,
ReactiveComponentModule,
],
})
export class SharingModule { }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More