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": "^11.0.0",
"@angular/platform-browser-dynamic": "^11.0.0", "@angular/platform-browser-dynamic": "^11.0.0",
"@angular/router": "^11.0.0", "@angular/router": "^11.0.0",
"@ionic/angular": "^5.4.0", "@ionic/angular": "^5.6.0",
"@ionic/storage": "2.2.0", "@ionic/storage": "^3.0.0",
"@start9labs/emver": "^0.1.1", "@ionic/storage-angular": "^3.0.0",
"@ngrx/component": "^11.1.1",
"@start9labs/emver": "^0.1.4",
"ajv": "^6.12.6", "ajv": "^6.12.6",
"angularx-qrcode": "^10.0.11", "angularx-qrcode": "^11.0.0",
"base32.js": "^0.1.0",
"base64url": "^3.0.1",
"bip39": "^3.0.2",
"bitcoinjs-lib": "^5.2.0",
"compare-versions": "^3.5.0", "compare-versions": "^3.5.0",
"core-js": "^3.4.0", "core-js": "^3.4.0",
"handlebars": "^4.7.6", "handlebars": "^4.7.6",
"json-pointer": "^0.6.1", "json-pointer": "^0.6.1",
"jsonpointerx": "^1.0.30", "jsonpointerx": "^1.0.30",
"jsontokens": "^3.0.0",
"marked": "^2.0.0", "marked": "^2.0.0",
"rxjs": "^6.6.3", "patch-db-client": "file: ../../../../patch-db-client",
"uuid": "^8.3.1", "rxjs": "^6.6.0",
"zone.js": "^0.11.2", "uuid": "^8.3.0",
"patch-db-client": "file: ../../../../patch-db-client" "zone.js": "^0.11.2"
}, },
"devDependencies": { "devDependencies": {
"@angular-devkit/build-angular": "^0.1100.0", "@angular-devkit/build-angular": "^0.1102.0",
"@angular/cli": "^11.0.0", "@angular/cli": "^11.0.0",
"@angular/compiler": "^11.0.0", "@angular/compiler": "^11.0.0",
"@angular/compiler-cli": "^11.0.0", "@angular/compiler-cli": "^11.0.0",
@@ -51,12 +48,12 @@
"@ionic/angular-toolkit": "^3.0.0", "@ionic/angular-toolkit": "^3.0.0",
"@ionic/lab": "^3.2.9", "@ionic/lab": "^3.2.9",
"@types/json-pointer": "^1.0.30", "@types/json-pointer": "^1.0.30",
"@types/marked": "^1.1.0", "@types/marked": "^2.0.0",
"@types/node": "^14.11.10", "@types/node": "^15.0.0",
"@types/uuid": "^8.0.0", "@types/uuid": "^8.0.0",
"node-html-parser": "2.0.0", "node-html-parser": "^3.2.0",
"ts-node": "^9.1.0", "ts-node": "^9.1.0",
"tslint": "^6.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 { PreloadAllModules, RouterModule, Routes } from '@angular/router'
import { AuthGuard } from './guards/auth.guard' import { AuthGuard } from './guards/auth.guard'
import { UnauthGuard } from './guards/unauth.guard' import { UnauthGuard } from './guards/unauth.guard'
import { MaintenanceGuard } from './guards/maintenance.guard'
import { UnmaintenanceGuard } from './guards/unmaintenance.guard'
const routes: Routes = [ const routes: Routes = [
{ {
@@ -10,34 +12,32 @@ const routes: Routes = [
path: '', path: '',
}, },
{ {
path: 'authenticate', path: 'auth',
canActivate: [UnauthGuard], canActivate: [UnauthGuard],
pathMatch: 'full', loadChildren: () => import('./pages/auth-routes/auth-routing.module').then(m => m.AuthRoutingModule),
loadChildren: () => import('./pages/authenticate/authenticate.module').then( m => m.AuthenticatePageModule),
}, },
{ {
path: 'embassy', path: 'embassy',
canActivate: [AuthGuard], canActivate: [AuthGuard, MaintenanceGuard],
canActivateChild: [AuthGuard], canActivateChild: [AuthGuard, MaintenanceGuard],
loadChildren: () => import('./pages/server-routes/server-routing.module').then(m => m.ServerRoutingModule), 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', path: 'notifications',
canActivate: [AuthGuard], canActivate: [AuthGuard, MaintenanceGuard],
canActivateChild: [AuthGuard],
loadChildren: () => import('./pages/notifications/notifications.module').then(m => m.NotificationsPageModule), loadChildren: () => import('./pages/notifications/notifications.module').then(m => m.NotificationsPageModule),
}, },
{ {
path: 'services', path: 'services',
canActivate: [AuthGuard], canActivate: [AuthGuard, MaintenanceGuard],
canActivateChild: [AuthGuard], canActivateChild: [AuthGuard, MaintenanceGuard],
loadChildren: () => import('./pages/apps-routes/apps-routing.module').then(m => m.AppsRoutingModule), 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({ @NgModule({

View File

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

View File

@@ -1,19 +1,18 @@
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { ServerModel, ServerStatus } from './models/server-model'
import { Storage } from '@ionic/storage' import { Storage } from '@ionic/storage'
import { SyncDaemon } from './services/sync.service'
import { AuthService, AuthState } from './services/auth.service' import { AuthService, AuthState } from './services/auth.service'
import { ApiService } from './services/api/api.service' import { ApiService } from './services/api/api.service'
import { Router } from '@angular/router' import { Router, RoutesRecognized } from '@angular/router'
import { BehaviorSubject, Observable } from 'rxjs' import { distinctUntilChanged, filter, finalize, takeWhile } from 'rxjs/operators'
import { AppModel } from './models/app-model' import { AlertController, ToastController } from '@ionic/angular'
import { filter, take } from 'rxjs/operators'
import { AlertController } from '@ionic/angular'
import { LoaderService } from './services/loader.service' import { LoaderService } from './services/loader.service'
import { Emver } from './services/emver.service' import { Emver } from './services/emver.service'
import { SplitPaneTracker } from './services/split-pane.service' import { SplitPaneTracker } from './services/split-pane.service'
import { LoadingOptions } from '@ionic/core' 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({ @Component({
selector: 'app-root', selector: 'app-root',
@@ -21,11 +20,8 @@ import { pauseFor } from './util/misc.util'
styleUrls: ['app.component.scss'], styleUrls: ['app.component.scss'],
}) })
export class AppComponent { export class AppComponent {
isUpdating = false ServerStatus = ServerStatus
fullPageMenu = true showMenu = false
$showMenuContent$ = new BehaviorSubject(false)
serverName$ : Observable<string>
serverBadge$: Observable<number>
selectedIndex = 0 selectedIndex = 0
untilLoaded = true untilLoaded = true
appPages = [ appPages = [
@@ -49,92 +45,118 @@ export class AppComponent {
url: '/notifications', url: '/notifications',
icon: 'notifications-outline', icon: 'notifications-outline',
}, },
// {
// title: 'Backup drives',
// url: '/drives',
// icon: 'albums-outline',
// },
] ]
constructor ( constructor (
private readonly serverModel: ServerModel,
private readonly syncDaemon: SyncDaemon,
private readonly storage: Storage, private readonly storage: Storage,
private readonly appModel: AppModel,
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly router: Router, private readonly router: Router,
private readonly api: ApiService, private readonly api: ApiService,
private readonly http: HttpService,
private readonly alertCtrl: AlertController, private readonly alertCtrl: AlertController,
private readonly loader: LoaderService, private readonly loader: LoaderService,
private readonly emver: Emver, private readonly emver: Emver,
private readonly connectionService: ConnectionService,
private readonly toastCtrl: ToastController,
readonly splitPane: SplitPaneTracker, readonly splitPane: SplitPaneTracker,
readonly patch: PatchDbModel,
) { ) {
// set dark theme // set dark theme
document.body.classList.toggle('dark', true) document.body.classList.toggle('dark', true)
this.serverName$ = this.serverModel.watch().name
this.serverBadge$ = this.serverModel.watch().badge
this.init() 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 () { async init () {
let fromFresh = true await this.storage.create()
await this.storage.ready() await this.patch.init()
await this.authService.restoreCache() await this.authService.init()
await this.emver.init() await this.emver.init()
this.authService.listen({ this.router.initialNavigation()
[AuthState.VERIFIED]: async () => {
console.log('verified') // watch auth
this.api.authenticatedRequestsEnabled = true this.authService.watch$()
await this.serverModel.restoreCache() .subscribe(auth => {
await this.appModel.restoreCache() // VERIFIED
this.syncDaemon.start() if (auth === AuthState.VERIFIED) {
this.$showMenuContent$.next(true) this.http.authReqEnabled = true
if (fromFresh) { this.showMenu = true
this.router.initialNavigation() this.patch.start()
fromFresh = false // watch network
} this.watchNetwork(auth)
}, // watch router to highlight selected menu item
[AuthState.UNVERIFIED]: () => { this.watchRouter(auth)
console.log('unverified') // watch status to display/hide maintenance page
this.api.authenticatedRequestsEnabled = false this.watchStatus(auth)
this.serverModel.clear() // watch unread notification count to display toast
this.appModel.clear() this.watchNotifications(auth)
this.syncDaemon.stop() // UNVERIFIED
} else if (auth === AuthState.UNVERIFIED) {
this.http.authReqEnabled = false
this.showMenu = false
this.patch.stop()
this.storage.clear() this.storage.clear()
this.router.navigate(['/authenticate'], { replaceUrl: true }) this.router.navigate(['/auth'], { 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.api.watch401$().subscribe(() => {
this.authService.setAuthStateUnverified() this.http.watch401$().subscribe(() => {
return this.api.postLogout() 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 () { private async logout () {
this.serverName$.pipe(take(1)).subscribe(name => { this.loader.of(LoadingSpinner('Logging out...'))
this.loader.of(LoadingSpinner(`Logging out ${name || ''}...`)) .displayDuringP(this.api.logout({ }))
.displayDuringP(this.api.postLogout()) .then(() => this.authService.setUnverified())
.then(() => this.authService.setAuthStateUnverified()) .catch(e => this.setError(e))
.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) console.error(e)
await this.presentError(e.message) await this.presentError(e.message)
} }
async presentError (e: string) { private async presentError (e: string) {
const alert = await this.alertCtrl.create({ const alert = await this.alertCtrl.create({
backdropDismiss: true, backdropDismiss: true,
message: `Exception on logout: ${e}`, message: `Exception on logout: ${e}`,
@@ -189,7 +236,7 @@ export class AppComponent {
} }
splitPaneVisible (e: any) { 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 { BrowserModule } from '@angular/platform-browser'
import { RouteReuseStrategy } from '@angular/router' import { RouteReuseStrategy } from '@angular/router'
import { IonicModule, IonicRouteStrategy } from '@ionic/angular' 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 { HttpClientModule } from '@angular/common/http'
import { AppComponent } from './app.component' import { AppComponent } from './app.component'
import { AppRoutingModule } from './app-routing.module' import { AppRoutingModule } from './app-routing.module'
import { ApiService } from './services/api/api.service' import { ApiService } from './services/api/api.service'
import { ApiServiceFactory } from './services/api/api.service.factory' 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 { HttpService } from './services/http.service'
import { ServerModel } from './models/server-model'
import { ConfigService } from './services/config.service' import { ConfigService } from './services/config.service'
import { QRCodeModule } from 'angularx-qrcode' import { QRCodeModule } from 'angularx-qrcode'
import { APP_CONFIG_COMPONENT_MAPPING } from './modals/app-config-injectable/modal-injectable-token' 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 { 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({ @NgModule({
declarations: [AppComponent], declarations: [AppComponent],
@@ -25,13 +28,21 @@ import { OSWelcomePageModule } from './modals/os-welcome/os-welcome.module'
BrowserModule, BrowserModule,
IonicModule.forRoot(), IonicModule.forRoot(),
AppRoutingModule, AppRoutingModule,
IonicStorageModule.forRoot(), IonicStorageModule.forRoot({
storeName: '_embassykv',
dbKey: '_embassykey',
name: '_embassystorage',
driverOrder: [Drivers.LocalStorage, Drivers.IndexedDB],
}),
QRCodeModule, QRCodeModule,
OSWelcomePageModule, OSWelcomePageModule,
SharingModule,
], ],
providers: [ providers: [
Storage,
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }, { 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 }, { provide: APP_CONFIG_COMPONENT_MAPPING, useValue: appConfigComponents },
], ],
bootstrap: [AppComponent], 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;"> <div style="position: relative; margin-right: 1vh;">
<ion-icon <ion-badge mode="md" class="md-badge" *ngIf="(badge$ | ngrxPush) && !(menuFixedOpen$ | ngrxPush)" color="danger">{{ badge$ | ngrxPush }}</ion-badge>
*ngIf="(badge$ | async) && !(menuFixedOpen$ | async)"
size="medium"
color="dark"
[class.ios-badge]="isIos"
[class.md-badge]="!isIos"
name="alert-outline"
>
</ion-icon>
<ion-menu-button color="dark"></ion-menu-button> <ion-menu-button color="dark"></ion-menu-button>
</div> </div>

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { Component, Input } from '@angular/core' 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({ @Component({
selector: 'config-header', 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-top: 25px;">
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;"> <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}} {{successText}}
</ion-label> </ion-label>
</div> </div>
@@ -11,7 +11,7 @@
</div> </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-spinner color="warning" name="lines"></ion-spinner>
<ion-label class="long-message">{{label}}</ion-label> <ion-label class="long-message">{{label}}</ion-label>
</div> </div>

View File

@@ -28,17 +28,18 @@ export class CompleteComponent implements OnInit, Loadable {
} }
$loading$ = new BehaviorSubject(false) loading$ = new BehaviorSubject(false)
$color$ = new BehaviorSubject('medium') color$ = new BehaviorSubject('medium')
$cancel$ = new Subject<void>() cancel$ = new Subject<void>()
label: string label: string
summary: string summary: string
successText: string successText: string
load () { load () {
markAsLoadingDuring$(this.$loading$, from(this.params.executeAction())).pipe(takeUntil(this.$cancel$)).subscribe( 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}`)), {
error: e => this.transitions.error(new Error(`${this.params.action} failed: ${e.message || e}`)),
complete: () => this.params.skipCompletionDialogue && this.transitions.final(), complete: () => this.params.skipCompletionDialogue && this.transitions.final(),
}, },
) )
@@ -50,37 +51,37 @@ export class CompleteComponent implements OnInit, Loadable {
case 'install': case 'install':
this.summary = `Installation of ${this.params.title} is now in progress. You will receive a notification when the installation has completed.` 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.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
this.$color$.next('primary') this.color$.next('primary')
this.successText = 'In Progress' this.successText = 'In Progress'
break break
case 'downgrade': case 'downgrade':
this.summary = `Downgrade for ${this.params.title} is now in progress. You will receive a notification when the downgrade has completed.` 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.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
this.$color$.next('primary') this.color$.next('primary')
this.successText = 'In Progress' this.successText = 'In Progress'
break break
case 'update': case 'update':
this.summary = `Update for ${this.params.title} is now in progress. You will receive a notification when the update has completed.` 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.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
this.$color$.next('primary') this.color$.next('primary')
this.successText = 'In Progress' this.successText = 'In Progress'
break break
case 'uninstall': case 'uninstall':
this.summary = `${capitalizeFirstLetter(this.params.title)} has been successfully uninstalled.` this.summary = `${capitalizeFirstLetter(this.params.title)} has been successfully uninstalled.`
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...` this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
this.$color$.next('success') this.color$.next('success')
this.successText = 'Success' this.successText = 'Success'
break break
case 'stop': case 'stop':
this.summary = `${capitalizeFirstLetter(this.params.title)} has been successfully stopped.` this.summary = `${capitalizeFirstLetter(this.params.title)} has been successfully stopped.`
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...` this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
this.$color$.next('success') this.color$.next('success')
this.successText = 'Success' this.successText = 'Success'
break break
case 'configure': case 'configure':
this.summary = `New config for ${this.params.title} has been successfully saved.` this.summary = `New config for ${this.params.title} has been successfully saved.`
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...` this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
this.$color$.next('success') this.color$.next('success')
this.successText = 'Success' this.successText = 'Success'
break 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>
<div *ngIf="!($loading$ | async)" class="slide-content"> <div *ngIf="!(loading$ | ngrxPush)" class="slide-content">
<div style="margin-top: 25px;"> <div style="margin-top: 25px;">
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;"> <div style="margin: 15px; display: flex; justify-content: center; align-items: center;">
<ion-label color="warning" style="font-size: xx-large; font-weight: bold;" <ion-label color="warning" style="font-size: xx-large; font-weight: bold;"
@@ -26,7 +26,7 @@
*ngFor="let dep of dependentBreakages" *ngFor="let dep of dependentBreakages"
> >
<ion-avatar style="position: relative; height: 4vh; width: 4vh" slot="start"> <ion-avatar style="position: relative; height: 4vh; width: 4vh" slot="start">
<img [src]="dep.iconURL | iconParse" /> <img [src]="dep.iconURL" />
</ion-avatar> </ion-avatar>
<ion-label> <ion-label>
<h5>{{dep.title}}</h5> <h5>{{dep.title}}</h5>
@@ -35,7 +35,7 @@
</div> </div>
</div> </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-spinner color="warning" name="lines"></ion-spinner>
<ion-label class="long-message">Checking for installed services which depend on {{params.title}}...</ion-label> <ion-label class="long-message">Checking for installed services which depend on {{params.title}}...</ion-label>
</div> </div>

View File

@@ -1,9 +1,9 @@
import { Component, Input, OnInit } from '@angular/core' import { Component, Input, OnInit } from '@angular/core'
import { BehaviorSubject, from, Subject } from 'rxjs' import { BehaviorSubject, from, Subject } from 'rxjs'
import { takeUntil, tap } from 'rxjs/operators' 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 { 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 { Loadable } from '../loadable'
import { WizardAction } from '../wizard-types' import { WizardAction } from '../wizard-types'
@@ -17,7 +17,7 @@ export class DependentsComponent implements OnInit, Loadable {
title: string, title: string,
action: WizardAction, //Are you sure you want to *uninstall*..., action: WizardAction, //Are you sure you want to *uninstall*...,
verb: string, // *Uninstalling* will cause problems... verb: string, // *Uninstalling* will cause problems...
fetchBreakages: () => Promise<DependentBreakage[]>, fetchBreakages: () => Promise<Breakages>,
skipConfirmationDialogue?: boolean skipConfirmationDialogue?: boolean
} }
@Input() transitions: { @Input() transitions: {
@@ -27,34 +27,33 @@ export class DependentsComponent implements OnInit, Loadable {
error: (e: Error) => void error: (e: Error) => void
} }
dependentBreakages: Breakages
dependentBreakages: DependentBreakage[]
hasDependentViolation: boolean hasDependentViolation: boolean
longMessage: string | null = null longMessage: string | null = null
$color$ = new BehaviorSubject('medium') // this will display disabled while loading color$ = new BehaviorSubject('medium') // this will display disabled while loading
$loading$ = new BehaviorSubject(false) loading$ = new BehaviorSubject(false)
$cancel$ = new Subject<void>() cancel$ = new Subject<void>()
constructor () { } constructor () { }
ngOnInit () { } ngOnInit () { }
load () { load () {
this.$color$.next('medium') this.color$.next('medium')
markAsLoadingDuring$(this.$loading$, from(this.params.fetchBreakages())).pipe( markAsLoadingDuring$(this.loading$, from(this.params.fetchBreakages())).pipe(
takeUntil(this.$cancel$), takeUntil(this.cancel$),
tap(breakages => this.dependentBreakages = breakages || []), tap(breakages => this.dependentBreakages = breakages),
).subscribe( ).subscribe(
{ {
complete: () => { complete: () => {
this.hasDependentViolation = this.dependentBreakages && this.dependentBreakages.length > 0 this.hasDependentViolation = this.dependentBreakages && !isEmptyObject(this.dependentBreakages)
if (this.hasDependentViolation) { 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.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) { } else if (this.params.skipConfirmationDialogue) {
this.transitions.next() this.transitions.next()
} else { } else {
this.longMessage = `No other services installed on your Embassy will be affected by this action.` 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}`)), 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-header>
<ion-content> <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"> <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) --> <!-- 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> <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> <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> <complete #components *ngIf="def.slide.selector === 'complete'" [params]="def.slide.params" [transitions]="transitions"></complete>
</ion-slide> </ion-slide>
</ion-slides> </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-top: 25px;">
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;"> <div style="margin: 15px; display: flex; justify-content: center; align-items: center;">
<ion-label color="danger" style="font-size: xx-large; font-weight: bold;"> <ion-label color="danger" style="font-size: xx-large; font-weight: bold;">
@@ -35,7 +35,7 @@
<ion-footer> <ion-footer>
<ion-toolbar style="padding: 8px;"> <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 --> <!-- 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"> <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> </ion-button>
</ng-container> </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> <ion-button slot="start" (click)="transitions.final()" style="text-transform: capitalize; font-weight: bolder;" color="danger">Dismiss</ion-button>
</ng-container> </ng-container>
</ion-toolbar> </ion-toolbar>

View File

@@ -4,7 +4,6 @@ import { InstallWizardComponent } from './install-wizard.component'
import { IonicModule } from '@ionic/angular' import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router' import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module' import { SharingModule } from 'src/app/modules/sharing.module'
import { DependenciesComponentModule } from './dependencies/dependencies.component.module'
import { DependentsComponentModule } from './dependents/dependents.component.module' import { DependentsComponentModule } from './dependents/dependents.component.module'
import { CompleteComponentModule } from './complete/complete.component.module' import { CompleteComponentModule } from './complete/complete.component.module'
import { NotesComponentModule } from './notes/notes.component.module' import { NotesComponentModule } from './notes/notes.component.module'
@@ -18,7 +17,6 @@ import { NotesComponentModule } from './notes/notes.component.module'
IonicModule, IonicModule,
RouterModule.forChild([]), RouterModule.forChild([]),
SharingModule, SharingModule,
DependenciesComponentModule,
DependentsComponentModule, DependentsComponentModule,
CompleteComponentModule, CompleteComponentModule,
NotesComponentModule, 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 { IonContent, IonSlides, ModalController } from '@ionic/angular'
import { BehaviorSubject } from 'rxjs' import { BehaviorSubject } from 'rxjs'
import { Cleanup } from 'src/app/util/cleanup'
import { capitalizeFirstLetter, pauseFor } from 'src/app/util/misc.util' import { capitalizeFirstLetter, pauseFor } from 'src/app/util/misc.util'
import { CompleteComponent } from './complete/complete.component' import { CompleteComponent } from './complete/complete.component'
import { DependenciesComponent } from './dependencies/dependencies.component'
import { DependentsComponent } from './dependents/dependents.component' import { DependentsComponent } from './dependents/dependents.component'
import { NotesComponent } from './notes/notes.component' import { NotesComponent } from './notes/notes.component'
import { Loadable } from './loadable' import { Loadable } from './loadable'
@@ -15,7 +13,7 @@ import { WizardAction } from './wizard-types'
templateUrl: './install-wizard.component.html', templateUrl: './install-wizard.component.html',
styleUrls: ['./install-wizard.component.scss'], styleUrls: ['./install-wizard.component.scss'],
}) })
export class InstallWizardComponent extends Cleanup implements OnInit { export class InstallWizardComponent {
@Input() params: { @Input() params: {
// defines each slide along with bottom bar // defines each slide along with bottom bar
slideDefinitions: SlideDefinition[] slideDefinitions: SlideDefinition[]
@@ -40,11 +38,13 @@ export class InstallWizardComponent extends Cleanup implements OnInit {
return this.params.slideDefinitions[this.slideIndex].bottomBar return this.params.slideDefinitions[this.slideIndex].bottomBar
} }
$initializing$ = new BehaviorSubject(true) initializing$ = new BehaviorSubject(true)
$error$ = new BehaviorSubject(undefined) error$ = new BehaviorSubject(undefined)
constructor (private readonly modalController: ModalController, private readonly zone: NgZone) { super() } constructor (
ngOnInit () { } private readonly modalController: ModalController,
private readonly zone: NgZone,
) { }
ngAfterViewInit () { ngAfterViewInit () {
this.currentSlide.load() this.currentSlide.load()
@@ -53,15 +53,15 @@ export class InstallWizardComponent extends Cleanup implements OnInit {
} }
ionViewDidEnter () { ionViewDidEnter () {
this.$initializing$.next(false) this.initializing$.next(false)
} }
// process bottom bar buttons // process bottom bar buttons
private transition = (info: { next: any } | { error: Error } | { cancelled: true } | { final: true }) => { private transition = (info: { next: any } | { error: Error } | { cancelled: true } | { final: true }) => {
const i = info as { 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.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) this.moveToNextSlide(i.next)
} }
@@ -90,7 +90,6 @@ export class InstallWizardComponent extends Cleanup implements OnInit {
export interface SlideDefinition { export interface SlideDefinition {
slide: slide:
{ selector: 'dependencies', params: DependenciesComponent['params'] } |
{ selector: 'dependents', params: DependentsComponent['params'] } | { selector: 'dependents', params: DependentsComponent['params'] } |
{ selector: 'complete', params: CompleteComponent['params'] } | { selector: 'complete', params: CompleteComponent['params'] } |
{ selector: 'notes', params: NotesComponent['params'] } { selector: 'notes', params: NotesComponent['params'] }

View File

@@ -3,7 +3,7 @@ import { BehaviorSubject, Subject } from 'rxjs'
export interface Loadable { export interface Loadable {
load: (prevResult?: any) => void 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. 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 loading$: BehaviorSubject<boolean> // will be true during load function
$cancel$: Subject<void> // will cancel load function cancel$: Subject<void> // will cancel load function
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,6 +4,6 @@
border-color: #FFEB3B; border-color: #FFEB3B;
border-width: medium; border-width: medium;
box-shadow: 0 0 10px white;" size="small" (click)="presentPopover($event)"> 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-button>
</ion-fab> </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 { Router } from '@angular/router'
import { PopoverController } from '@ionic/angular' import { PopoverController } from '@ionic/angular'
import { filter, take } from 'rxjs/operators' import { filter, take } from 'rxjs/operators'
import { Cleanup } from 'src/app/util/cleanup'
import { capitalizeFirstLetter } from 'src/app/util/misc.util' import { capitalizeFirstLetter } from 'src/app/util/misc.util'
import { InformationPopoverComponent } from '../information-popover/information-popover.component' import { InformationPopoverComponent } from '../information-popover/information-popover.component'
@@ -11,12 +10,13 @@ import { InformationPopoverComponent } from '../information-popover/information-
templateUrl: './recommendation-button.component.html', templateUrl: './recommendation-button.component.html',
styleUrls: ['./recommendation-button.component.scss'], styleUrls: ['./recommendation-button.component.scss'],
}) })
export class RecommendationButtonComponent extends Cleanup implements OnInit { export class RecommendationButtonComponent {
@Input() rec: Recommendation @Input() rec: Recommendation
@Input() raise?: { id: string } @Input() raise?: { id: string }
constructor (private readonly router: Router, private readonly popoverController: PopoverController) { constructor (
super() private readonly router: Router,
} private readonly popoverController: PopoverController,
) { }
ngOnInit () { ngOnInit () {
if (!this.raise) return if (!this.raise) return
@@ -41,7 +41,7 @@ export class RecommendationButtonComponent extends Cleanup implements OnInit {
componentProps: { componentProps: {
information: ` information: `
<div style="font-size: medium; font-style: italic; margin: 5px 0px;"> <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>
<div> <div>
${this.rec.description} ${this.rec.description}
@@ -57,10 +57,9 @@ export class RecommendationButtonComponent extends Cleanup implements OnInit {
} }
export type Recommendation = { export type Recommendation = {
title: string dependentId: string
appId: string dependentTitle: string
iconURL: string, dependentIcon: string,
description: string, description: string
versionSpec?: string version?: string
whyDependency?: string
} }

View File

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

View File

@@ -15,18 +15,3 @@
height: 24px; height: 24px;
padding-left: 12px; 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 { Component, Input } from '@angular/core'
import { AppStatus } from 'src/app/models/app-model' import { PackageDataEntry } from 'src/app/models/patch-db/data-model'
import { ServerStatus } from 'src/app/models/server-model' import { ConnectionState } from 'src/app/services/connection.service'
import { ServerStatusRendering, AppStatusRendering } from '../../util/status-rendering' import { renderPkgStatus } from 'src/app/services/pkg-status-rendering.service'
@Component({ @Component({
selector: 'status', selector: 'status',
@@ -9,48 +9,18 @@ import { ServerStatusRendering, AppStatusRendering } from '../../util/status-ren
styleUrls: ['./status.component.scss'], styleUrls: ['./status.component.scss'],
}) })
export class StatusComponent { export class StatusComponent {
@Input() appStatus?: AppStatus @Input() pkg: PackageDataEntry
@Input() serverStatus?: ServerStatus @Input() connection: ConnectionState
@Input() size: 'small' | 'medium' | 'large' | 'italics-small' | 'bold-large' = 'large' @Input() size: 'small' | 'medium' | 'large' | 'italics-small' | 'bold-large' = 'large'
@Input() text: string = '' display = ''
color: string color = ''
display: string showDots = false
showDots: boolean
style = ''
ngOnChanges () { ngOnChanges () {
if (this.serverStatus) { const { display, color, showDots } = renderPkgStatus(this.pkg, this.connection)
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
this.display = display this.display = display
this.color = color this.color = color
this.showDots = showDots 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 { Injectable } from '@angular/core'
import { CanActivate, Router, CanActivateChild } from '@angular/router' import { CanActivate, Router, CanActivateChild } from '@angular/router'
import { tap } from 'rxjs/operators'
import { AuthState, AuthService } from '../services/auth.service' import { AuthState, AuthService } from '../services/auth.service'
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class AuthGuard implements CanActivate, CanActivateChild { export class AuthGuard implements CanActivate, CanActivateChild {
authState: AuthState
constructor ( constructor (
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly router: Router, private readonly router: Router,
) { } ) {
this.authService.watch$()
.pipe(
tap(auth => this.authState = auth),
).subscribe()
}
canActivate (): boolean { canActivate (): boolean {
return this.runCheck() return this.runAuthCheck()
} }
canActivateChild (): boolean { canActivateChild (): boolean {
return this.runCheck() return this.runAuthCheck()
} }
private runCheck (): boolean { private runAuthCheck (): boolean {
const state = this.authService.peek() switch (this.authState){
case AuthState.VERIFIED:
switch (state){ return true
case AuthState.VERIFIED: return true case AuthState.UNVERIFIED:
case AuthState.UNVERIFIED: return this.toAuthenticate() // @TODO could initializing cause a loop?
case AuthState.INITIALIZING: return this.toAuthenticate() 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 { Injectable } from '@angular/core'
import { CanActivate, Router } from '@angular/router' import { CanActivate, Router } from '@angular/router'
import { tap } from 'rxjs/operators'
import { AuthService, AuthState } from '../services/auth.service' import { AuthService, AuthState } from '../services/auth.service'
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class UnauthGuard implements CanActivate { export class UnauthGuard implements CanActivate {
authState: AuthState
constructor ( constructor (
private readonly authService: AuthService, private readonly authService: AuthService,
private readonly router: Router, private readonly router: Router,
) { } ) {
this.authService.watch$()
.pipe(
tap(auth => this.authState = auth),
).subscribe()
}
canActivate (): boolean { canActivate (): boolean {
const state = this.authService.peek()
switch (state){ switch (this.authState){
case AuthState.VERIFIED: { case AuthState.VERIFIED: {
this.router.navigateByUrl('') this.router.navigateByUrl('')
return false return false
} }
case AuthState.UNVERIFIED: return true case AuthState.UNVERIFIED:
case AuthState.INITIALIZING: return true 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 { 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> } export type AppConfigComponentMapping = { [k in ValueType]: Type<any> }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,10 +1,10 @@
import { Component, Input } from '@angular/core' 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 { AlertController, ToastController } from '@ionic/angular'
import { LoaderService } from 'src/app/services/loader.service' import { LoaderService } from 'src/app/services/loader.service'
import { TrackingModalController } from 'src/app/services/tracking-modal-controller.service' import { TrackingModalController } from 'src/app/services/tracking-modal-controller.service'
import { ConfigCursor } from 'src/app/app-config/config-cursor' import { ConfigCursor } from 'src/app/pkg-config/config-cursor'
import { ValueSpecOf } from 'src/app/app-config/config-types' import { ValueSpecOf } from 'src/app/pkg-config/config-types'
import { copyToClipboard } from 'src/app/util/web.util' import { copyToClipboard } from 'src/app/util/web.util'
@Component({ @Component({

View File

@@ -8,10 +8,10 @@
<div> <div>
<ion-item lines="none" style="--background: var(--ion-background-color); --border-color: var(--ion-color-medium);"> <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-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>
<ion-item *ngIf="$error$ | async as e" lines="none" style="--background: var(--ion-background-color);"> <ion-item *ngIf="error" lines="none" style="--background: var(--ion-background-color);">
<ion-label style="font-size: small" color="danger" class="ion-text-wrap">{{e}}</ion-label> <ion-label style="font-size: small" color="danger" class="ion-text-wrap">{{ error }}</ion-label>
</ion-item> </ion-item>
</div> </div>
<div style="display: flex; justify-content: flex-end; align-items: center;"> <div style="display: flex; justify-content: flex-end; align-items: center;">

View File

@@ -1,14 +1,14 @@
import { NgModule } from '@angular/core' import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { AppBackupConfirmationComponent } from './app-backup-confirmation.component' import { BackupConfirmationComponent } from './backup-confirmation.component'
import { IonicModule } from '@ionic/angular' import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router' import { RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module' import { SharingModule } from 'src/app/modules/sharing.module'
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms'
@NgModule({ @NgModule({
declarations: [ declarations: [
AppBackupConfirmationComponent, BackupConfirmationComponent,
], ],
imports: [ imports: [
CommonModule, CommonModule,
@@ -17,6 +17,6 @@ import { FormsModule } from '@angular/forms';
SharingModule, SharingModule,
FormsModule, 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 { Component, Input } from '@angular/core'
import { ModalController } from '@ionic/angular' import { ModalController } from '@ionic/angular'
import { ServerModel } from 'src/app/models/server-model'
import { ApiService } from 'src/app/services/api/api.service' import { ApiService } from 'src/app/services/api/api.service'
import { ConfigService } from 'src/app/services/config.service' import { ConfigService } from 'src/app/services/config.service'
@@ -19,7 +18,7 @@ export class OSWelcomePage {
) { } ) { }
async dismiss () { 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 false to skip subsequent alert modals (e.g. check for updates modals)
// return true to show subsequent alert modals // return true to show subsequent alert modals

View File

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

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 { 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 { IncludesPipe } from '../pipes/includes.pipe'
import { IconPipe } from '../pipes/icon.pipe'
import { TypeofPipe } from '../pipes/typeof.pipe' import { TypeofPipe } from '../pipes/typeof.pipe'
import { MarkdownPipe } from '../pipes/markdown.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 { AnnotationStatusPipe } from '../pipes/annotation-status.pipe'
import { TruncateCenterPipe, TruncateEndPipe } from '../pipes/truncate.pipe' import { TruncateCenterPipe, TruncateEndPipe } from '../pipes/truncate.pipe'
import { MaskPipe } from '../pipes/mask.pipe' import { MaskPipe } from '../pipes/mask.pipe'
import { DisplayBulbPipe } from '../pipes/display-bulb.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({ @NgModule({
declarations: [ declarations: [
EmverComparesPipe, EmverComparesPipe,
EmverSatisfiesPipe, EmverSatisfiesPipe,
TypeofPipe, TypeofPipe,
IconPipe,
IncludesPipe, IncludesPipe,
MarkdownPipe, MarkdownPipe,
PeekPropertiesPipe, // InstalledLatestComparisonPipe,
InstalledLatestComparisonPipe, // InstalledViewingComparisonPipe,
InstalledViewingComparisonPipe,
AnnotationStatusPipe, AnnotationStatusPipe,
TruncateCenterPipe, TruncateCenterPipe,
TruncateEndPipe, TruncateEndPipe,
MaskPipe, MaskPipe,
DisplayBulbPipe, DisplayBulbPipe,
EmverDisplayPipe, EmverDisplayPipe,
EmverIsValidPipe, HasUiPipe,
LaunchablePipe,
ManifestPipe,
EmptyPipe,
StatusPipe,
NotificationColorPipe,
],
imports: [
ReactiveComponentModule,
], ],
exports: [ exports: [
EmverComparesPipe, EmverComparesPipe,
EmverSatisfiesPipe, EmverSatisfiesPipe,
TypeofPipe, TypeofPipe,
IconPipe,
IncludesPipe, IncludesPipe,
MarkdownPipe, MarkdownPipe,
PeekPropertiesPipe, // InstalledLatestComparisonPipe,
InstalledLatestComparisonPipe, // InstalledViewingComparisonPipe,
AnnotationStatusPipe, AnnotationStatusPipe,
InstalledViewingComparisonPipe,
TruncateEndPipe, TruncateEndPipe,
TruncateCenterPipe, TruncateCenterPipe,
MaskPipe, MaskPipe,
DisplayBulbPipe, DisplayBulbPipe,
EmverDisplayPipe, EmverDisplayPipe,
EmverIsValidPipe, HasUiPipe,
LaunchablePipe,
ManifestPipe,
EmptyPipe,
StatusPipe,
NotificationColorPipe,
ReactiveComponentModule,
], ],
}) })
export class SharingModule { } export class SharingModule { }

View File

@@ -8,34 +8,25 @@
</ion-header> </ion-header>
<ion-content class="ion-padding-top"> <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) && { <ion-item *ngIf="manifest.actions | empty; else actions">
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-label class="ion-text-wrap"> <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>No Actions for {{ manifest.title }} {{ manifest.versionInstalled }}.</p>
<p><ion-text color="dark">{{ action.description }}</ion-text></p>
</ion-label> </ion-label>
</ion-item> </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> </ng-container>
</ion-content> </ion-content>

View File

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

View File

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

View File

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

View File

@@ -5,12 +5,24 @@
padding: 1px 0px 1.5px 0px; padding: 1px 0px 1.5px 0px;
} }
.instructions-card { .scrollable {
--background: linear-gradient(45deg, #101010 16%, var(--ion-color-medium) 150%); overflow: auto;
margin: 16px 10px; 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%); --background: linear-gradient(45deg, #101010 16%, var(--ion-color-danger) 150%);
margin: 16px 10px; 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 { ApiService } from 'src/app/services/api/api.service'
import { AppModel } from 'src/app/models/app-model' import { MarketplaceData, MarketplaceEOS, AvailablePreview } from 'src/app/services/api/api-types'
import { AppAvailablePreview, AppInstalledPreview } from 'src/app/models/app-types' import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
import { pauseFor } from 'src/app/util/misc.util' import { ModalController } from '@ionic/angular'
import { PropertySubjectId, initPropertySubject } from 'src/app/util/property-subject.util' import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
import { Subscription, BehaviorSubject, combineLatest } from 'rxjs' import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
import { take } from 'rxjs/operators' import { PackageState } from 'src/app/models/patch-db/data-model'
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'
@Component({ @Component({
selector: 'app-available-list', selector: 'app-available-list',
@@ -16,83 +13,93 @@ import { V1Status } from 'src/app/services/api/api-types'
styleUrls: ['./app-available-list.page.scss'], styleUrls: ['./app-available-list.page.scss'],
}) })
export class AppAvailableListPage { export class AppAvailableListPage {
$loading$ = new BehaviorSubject(true) pageLoading = true
pkgsLoading = true
error = '' error = ''
installedAppDeltaSubscription: Subscription
apps: PropertySubjectId<AppAvailablePreview>[] = [] category = 'featured'
appsInstalled: PropertySubjectId<AppInstalledPreview>[] = [] query: string
v1Status: V1Status = { status: 'nothing', version: '' }
data: MarketplaceData
eos: MarketplaceEOS
pkgs: AvailablePreview[] = []
PackageState = PackageState
page = 1
needInfinite = false
readonly perPage = 20
constructor ( constructor (
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly appModel: AppModel, private readonly modalCtrl: ModalController,
private readonly zone: NgZone, private readonly wizardBaker: WizardBaker,
private readonly osUpdateService: OsUpdateService, public patch: PatchDbModel,
) { } ) { }
async ngOnInit () { 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 { try {
this.v1Status = await this.apiService.checkV1Status() const [data, eos, pkgs] = await Promise.all([
} catch (e) { this.apiService.getMarketplaceData({ }),
console.error(e) this.apiService.getEos({ }),
} this.getPkgs(),
} ])
this.data = data
mergeInstalledProps (appInstalledId: string) { this.eos = eos
const appAvailable = this.apps.find(app => app.id === appInstalledId) this.pkgs = pkgs
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))
} catch (e) { } catch (e) {
console.error(e) console.error(e)
this.error = e.message 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 { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router' import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular' import { IonicModule } from '@ionic/angular'
import { DependencyListComponentModule } from '../../../components/dependency-list/dependency-list.component.module'
import { AppAvailableShowPage } from './app-available-show.page' import { AppAvailableShowPage } from './app-available-show.page'
import { SharingModule } from 'src/app/modules/sharing.module' import { SharingModule } from 'src/app/modules/sharing.module'
import { PwaBackComponentModule } from 'src/app/components/pwa-back-button/pwa-back.component.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 { StatusComponentModule } from 'src/app/components/status/status.component.module'
import { RecommendationButtonComponentModule } from 'src/app/components/recommendation-button/recommendation-button.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 { InstallWizardComponentModule } from 'src/app/components/install-wizard/install-wizard.component.module'
import { ErrorMessageComponentModule } from 'src/app/components/error-message/error-message.component.module'
import { InformationPopoverComponentModule } from 'src/app/components/information-popover/information-popover.component.module' import { InformationPopoverComponentModule } from 'src/app/components/information-popover/information-popover.component.module'
const routes: Routes = [ const routes: Routes = [
@@ -25,14 +23,12 @@ const routes: Routes = [
CommonModule, CommonModule,
IonicModule, IonicModule,
StatusComponentModule, StatusComponentModule,
DependencyListComponentModule,
RouterModule.forChild(routes), RouterModule.forChild(routes),
SharingModule, SharingModule,
PwaBackComponentModule, PwaBackComponentModule,
RecommendationButtonComponentModule, RecommendationButtonComponentModule,
BadgeMenuComponentModule, BadgeMenuComponentModule,
InstallWizardComponentModule, InstallWizardComponentModule,
ErrorMessageComponentModule,
InformationPopoverComponentModule, InformationPopoverComponentModule,
], ],
declarations: [AppAvailableShowPage], declarations: [AppAvailableShowPage],

View File

@@ -10,109 +10,140 @@
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<ion-content class="ion-padding-bottom" *ngIf="{ <ion-content class="ion-padding-bottom">
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>
<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"> <ng-template #loaded>
<ion-item-group> <ion-item *ngIf="error" style="margin-bottom: 16px;">
<ion-item lines="none"> <ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
<ion-avatar slot="start"> </ion-item>
<img [src]="vars.iconURL | iconParse" />
</ion-avatar>
<ion-label class="ion-text-wrap">
<h1 style="font-family: 'Montserrat'">{{ vars.title }}</h1>
<h3>{{ vars.versionViewing | displayEmver }}</h3>
<ng-container *ngIf="vars.status !== 'INSTALLING'">
<h3 *ngIf="installedStatus === 'installed-equal'"><ion-text color="medium">Installed</ion-text></h3>
<h3 *ngIf="installedStatus === 'installed-below' || installedStatus === 'installed-above'"><ion-text color="medium">Installed </ion-text><ion-text style="font-size: small" color="medium"> at {{vars.versionInstalled | displayEmver}}</ion-text></h3>
</ng-container>
<ng-container *ngIf="vars.status === 'INSTALLING'">
<h3>
<status appStatus="INSTALLING" [text]="' (' + (vars.versionInstalled | displayEmver) + ')'" size="medium"></status>
</h3>
</ng-container>
</ion-label>
</ion-item> <ng-container *ngrxLet="patch.watch$('package-data', pkgId) as localPkg">
</ion-item-group> <ion-item-group>
<ion-item lines="none">
<ion-button *ngIf="!vars.versionInstalled" class="main-action-button" expand="block" fill="outline" color="success" (click)="install()"> <ion-avatar slot="start">
Install <img [src]="pkg.icon" />
</ion-button> </ion-avatar>
<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">
<ion-label class="ion-text-wrap"> <ion-label class="ion-text-wrap">
<h2 style="display: flex; align-items: center;"> <h1 style="font-family: 'Montserrat'">{{ pkg.manifest.title }}</h1>
<ion-avatar style="height: 3vh; width: 3vh; margin: 5px" slot="start"> <h3>{{ pkg.manifest.version | displayEmver }}</h3>
<img [src]="recommendation.iconURL | iconParse" [alt]="recommendation.title"/> <!-- no localPkg -->
</ion-avatar> <h3 *ngIf="!localPkg; else local">
<ion-text style="margin: 5px; font-family: 'Montserrat'; font-size: smaller;">{{recommendation.title}}</ion-text> <ion-text color="medium">Not Installed</ion-text>
</h2> </h3>
<div style="margin: 2px 5px"> <!-- localPkg -->
<p style="color: var(--ion-color-medium); font-size: small">{{recommendation.description}}</p> <ng-template #local>
<p *ngIf="vars.versionViewing | satisfiesEmver: recommendation.versionSpec" class="recommendation-text">{{vars.title}} version {{vars.versionViewing | displayEmver}} is compatible.</p> <h3 *ngIf="localPkg.state !== PackageState.Installed; else installed">
<p *ngIf="!(vars.versionViewing | satisfiesEmver: recommendation.versionSpec)" class="recommendation-text recommendation-error">{{vars.title}} version {{vars.versionViewing | displayEmver}} is NOT compatible.</p> <!-- installing, updating, removing -->
</div> <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-label>
</ion-item> </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-item lines="none">
<ion-label *ngIf="!($newVersionLoading$ | async)" style="display: flex; align-items: center; justify-content: space-between;" class="ion-text-wrap" > <ion-label 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> <div id='release-notes' color="dark" [innerHTML]="pkg.manifest['release-notes'] | markdown"></div>
</ion-label> </ion-label>
<ion-spinner style="display: block; margin: auto;" name="lines" color="dark" *ngIf="$newVersionLoading$ | async"></ion-spinner>
</ion-item> </ion-item>
<!-- description -->
<ion-item-divider class="divider">Description</ion-item-divider> <ion-item-divider class="divider">
<ion-text color="dark">Description</ion-text>
</ion-item-divider>
<ion-item lines="none"> <ion-item lines="none">
<ion-label class="ion-text-wrap"> <ion-label class="ion-text-wrap">
<ion-text color="medium"> <ion-text color="dark">
<h5>{{ vars.descriptionLong }}</h5> <h5>{{ pkg.manifest.description.long }}</h5>
</ion-text> </ion-text>
</ion-label> </ion-label>
</ion-item> </ion-item>
<!-- dependencies -->
<ng-container *ngIf="(vars.serviceRequirements)?.length"> <ng-container *ngIf="!(pkg.manifest.dependencies | empty)">
<ion-item-divider class="divider">Service Dependencies <ion-item-divider class="divider">
<ion-button style="position: relative; right: 10px;" size="small" fill="clear" color="medium" (click)="presentPopover(serviceDependencyDefintion, $event)"> <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-icon name="help-circle-outline"></ion-icon>
</ion-button> </ion-button>
</ion-item-divider> </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> </ng-container>
<!-- versions -->
<ion-item-divider></ion-item-divider> <ion-item-divider></ion-item-divider>
<ion-item *ngIf="vars.licenseLink" lines="none" button [href]="vars.licenseLink" target="_blank"> <ion-item *ngIf="vars.licenseLink" lines="none" button [href]="vars.licenseLink" target="_blank">
<ion-icon slot="start" name="newspaper-outline"></ion-icon> <ion-icon slot="start" name="newspaper-outline"></ion-icon>
@@ -120,9 +151,10 @@
<ion-note slot="end">{{ vars.licenseName }}</ion-note> <ion-note slot="end">{{ vars.licenseName }}</ion-note>
</ion-item> </ion-item>
<ion-item lines="none" button (click)="presentAlertVersions()"> <ion-item lines="none" button (click)="presentAlertVersions()">
<ion-icon slot="start" name="file-tray-stacked-outline"></ion-icon> <ion-icon color="dark" slot="start" name="file-tray-stacked-outline"></ion-icon>
<ion-label>Other versions</ion-label> <ion-label color="dark">Other versions</ion-label>
</ion-item> </ion-item>
</ion-item-group> </ion-item-group>
</ng-container>
</ng-template>
</ion-content> </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 { ActivatedRoute } from '@angular/router'
import { AppAvailableFull, AppAvailableVersionSpecificInfo } from 'src/app/models/app-types'
import { ApiService } from 'src/app/services/api/api.service' import { ApiService } from 'src/app/services/api/api.service'
import { AlertController, ModalController, NavController, PopoverController } from '@ionic/angular' 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 { Recommendation } from 'src/app/components/recommendation-button/recommendation-button.component'
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component' import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards' 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 { InformationPopoverComponent } from 'src/app/components/information-popover/information-popover.component'
import { Emver } from 'src/app/services/emver.service' import { Emver } from 'src/app/services/emver.service'
import { displayEmver } from 'src/app/pipes/emver.pipe' import { displayEmver } from 'src/app/pipes/emver.pipe'
import { pauseFor } from 'src/app/util/misc.util' 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({ @Component({
selector: 'app-available-show', selector: 'app-available-show',
templateUrl: './app-available-show.page.html', templateUrl: './app-available-show.page.html',
styleUrls: ['./app-available-show.page.scss'], styleUrls: ['./app-available-show.page.scss'],
}) })
export class AppAvailableShowPage extends Cleanup { export class AppAvailableShowPage {
$loading$ = new BehaviorSubject(true) loading = true
error = ''
pkg: AvailableShow
pkgId: string
// When a new version is selected PackageState = PackageState
$newVersionLoading$ = new BehaviorSubject(false)
// When dependencies are refreshing
$dependenciesLoading$ = new BehaviorSubject(false)
$error$ = new BehaviorSubject(undefined) rec: Recommendation | null = null
$app$: PropertySubject<AppAvailableFull> = { } as any showRec = true
appId: string
openRecommendation = false depDefinition = '<span style="font-style: italic">Service Dependencies</span> are other services that this service recommends or requires in order to run.'
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.'
constructor ( constructor (
private readonly route: ActivatedRoute, private readonly route: ActivatedRoute,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly alertCtrl: AlertController, private readonly alertCtrl: AlertController,
private readonly zone: NgZone,
private readonly modalCtrl: ModalController, private readonly modalCtrl: ModalController,
private readonly wizardBaker: WizardBaker, private readonly wizardBaker: WizardBaker,
private readonly navCtrl: NavController, private readonly navCtrl: NavController,
private readonly appModel: AppModel,
private readonly popoverController: PopoverController, private readonly popoverController: PopoverController,
private readonly emver: Emver, private readonly emver: Emver,
) { public readonly patch: PatchDbModel,
super() ) { }
}
async ngOnInit () { async ngOnInit () {
this.appId = this.route.snapshot.paramMap.get('appId') as string this.pkgId = this.route.snapshot.paramMap.get('pkgId') as string
this.rec = history.state && history.state.installRec as Recommendation
this.cleanup( this.getPkg()
// 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(),
)
} }
ionViewDidEnter () { async getPkg (version?: string): Promise<void> {
markAsLoadingDuring$(this.$dependenciesLoading$, this.syncVersionSpecificInfo()).subscribe({ this.loading = true
error: e => this.setError(e), 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) { async presentPopover (information: string, ev: any) {
@@ -92,34 +75,17 @@ export class AppAvailableShowPage extends Cleanup {
return await popover.present() 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 () { async presentAlertVersions () {
const app = peekProperties(this.$app$)
const alert = await this.alertCtrl.create({ const alert = await this.alertCtrl.create({
header: 'Versions', header: 'Versions',
backdropDismiss: false, backdropDismiss: false,
inputs: app.versions.sort((a, b) => -1 * this.emver.compare(a, b)).map(v => { inputs: this.pkg.versions.sort((a, b) => -1 * this.emver.compare(a, b)).map(v => {
return { name: v, // for CSS return {
name: v, // for CSS
type: 'radio', type: 'radio',
label: displayEmver(v), // appearance on screen label: displayEmver(v), // appearance on screen
value: v, // literal SEM version value value: v, // literal SEM version value
checked: app.versionViewing === v, checked: this.pkg.manifest.version === v,
} }
}), }),
buttons: [ buttons: [
@@ -129,17 +95,7 @@ export class AppAvailableShowPage extends Cleanup {
}, { }, {
text: 'Ok', text: 'Ok',
handler: (version: string) => { handler: (version: string) => {
const previousVersion = this.$app$.versionViewing.getValue() this.getPkg(version)
this.$app$.versionViewing.next(version)
markAsLoadingDuring$(
this.$newVersionLoading$, this.syncVersionSpecificInfo(`=${version}`),
)
.subscribe({
error: e => {
this.setError(e)
this.$app$.versionViewing.next(previousVersion)
},
})
}, },
}, },
], ],
@@ -149,15 +105,14 @@ export class AppAvailableShowPage extends Cleanup {
} }
async install () { async install () {
const app = peekProperties(this.$app$) const { id, title, version, dependencies, alerts } = this.pkg.manifest
const { cancelled } = await wizardModal( const { cancelled } = await wizardModal(
this.modalCtrl, this.modalCtrl,
this.wizardBaker.install({ this.wizardBaker.install({
id: app.id, id,
title: app.title, title,
version: app.versionViewing, version,
serviceRequirements: app.serviceRequirements, installAlert: alerts.install,
installAlert: app.installAlert,
}), }),
) )
if (cancelled) return if (cancelled) return
@@ -166,14 +121,13 @@ export class AppAvailableShowPage extends Cleanup {
} }
async update (action: 'update' | 'downgrade') { async update (action: 'update' | 'downgrade') {
const app = peekProperties(this.$app$) const { id, title, version, dependencies, alerts } = this.pkg.manifest
const value = { const value = {
id: app.id, id,
title: app.title, title,
version: app.versionViewing, version,
serviceRequirements: app.serviceRequirements, serviceRequirements: dependencies,
installAlert: app.installAlert, installAlert: alerts.install,
} }
const { cancelled } = await wizardModal( const { cancelled } = await wizardModal(
@@ -188,27 +142,7 @@ export class AppAvailableShowPage extends Cleanup {
this.navCtrl.back() this.navCtrl.back()
} }
private fetchRecommendation (): Observable<any> { dismissRec () {
this.recommendation = history.state && history.state.installationRecommendation this.showRec = false
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)
} }
} }

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 { 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 { AppConfigValuePageModule } from 'src/app/modals/app-config-value/app-config-value.module'
import { SharingModule } from 'src/app/modules/sharing.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 { RecommendationButtonComponentModule } from 'src/app/components/recommendation-button/recommendation-button.component.module'
import { InformationPopoverComponentModule } from 'src/app/components/information-popover/information-popover.component.module' import { InformationPopoverComponentModule } from 'src/app/components/information-popover/information-popover.component.module'
@@ -19,7 +17,6 @@ const routes: Routes = [
{ {
path: '', path: '',
component: AppConfigPage, component: AppConfigPage,
// canDeactivate: [CanDeactivateGuard],
}, },
] ]
@@ -35,8 +32,6 @@ const routes: Routes = [
FormsModule, FormsModule,
IonicModule, IonicModule,
RouterModule.forChild(routes), RouterModule.forChild(routes),
PwaBackComponentModule,
BadgeMenuComponentModule,
RecommendationButtonComponentModule, RecommendationButtonComponentModule,
InformationPopoverComponentModule, InformationPopoverComponentModule,
], ],

View File

@@ -5,25 +5,27 @@
<ion-icon name="arrow-back"></ion-icon> <ion-icon name="arrow-back"></ion-icon>
</ion-button> </ion-button>
</ion-buttons> </ion-buttons>
<ion-title>{{ app['title'] | async }}</ion-title> <ion-title>{{ pkg.manifest.title }}</ion-title>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<ion-content class="ion-padding-top"> <ion-content class="ion-padding-top">
<!-- loading --> <!-- loading -->
<div *ngIf="$loading$ | async" class="full-page-spinner"> <ion-grid *ngIf="loadingText$ | ngrxPush as loadingText; else loaded" style="height: 100%;">
<ion-spinner style="justify-self: center; align-self: end;" name="lines" color="warning"></ion-spinner> <ion-row class="ion-align-items-center ion-text-center" style="height: 100%;">
<ion-label style="justify-self: center;" *ngIf="($loadingText$ | async)" color="dark"> <ion-col>
{{$loadingText$ | async}} <ion-spinner name="lines" color="warning"></ion-spinner>
</ion-label> <p>{{ loadingText }}</p>
</div> </ion-col>
</ion-row>
</ion-grid>
<!-- not loading --> <!-- not loading -->
<ng-container *ngIf="!($loading$ | async)"> <ng-template #loaded>
<ion-item *ngIf="error" class="notifier-item"> <ion-item *ngIf="error" class="notifier-item">
<ion-label style="margin: 7px 5px;" class="ion-text-wrap"> <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> <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"> <ng-container *ngIf="openErrorMoreInfo">
@@ -33,44 +35,45 @@
</ng-container> </ng-container>
</ion-label> </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-icon name="close-outline"></ion-icon>
</ion-button> </ion-button>
</ion-item> </ion-item>
<ng-container *ngIf="app && (app.id | async)"> <ng-container *ngIf="pkg">
<ng-container *ngIf="([AppStatus.NEEDS_CONFIG] | includes: (app.status | async)) && !edited"> <!-- @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-item class="notifier-item">
<ion-label class="ion-text-wrap"> <ion-label class="ion-text-wrap">
<h2 style="display: flex; align-items: center; margin-bottom: 3px;"> <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-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> <ion-text style="font-size: smaller;">Initial Config</ion-text>
</h2> </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-label>
</ion-item> </ion-item>
</ng-container> </ng-container>
<ng-container *ngIf="recommendation && showRecommendation"> <ng-container *ngIf="rec && showRec">
<ion-item class="recommendation-item"> <ion-item class="rec-item">
<ion-label class="ion-text-wrap"> <ion-label class="ion-text-wrap">
<h2 style="display: flex; align-items: center;"> <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-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"> <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-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> </h2>
<div style="margin: 7px 5px;"> <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> <ion-text color="dark">To accept the changes, click “Save” below.</ion-text>
</p> </p>
<a style="font-size: small" *ngIf="!openRecommendation" (click)="openRecommendation = true">More Info</a> <a style="font-size: small" *ngIf="!openRec" (click)="openRec = true">More Info</a>
<ng-container *ngIf="openRecommendation"> <ng-container *ngIf="openRec">
<p style="margin-top: 10px; color: var(--ion-color-medium); font-size: small" [innerHTML]="recommendation.description"></p> <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)="openRecommendation = false">hide</a> <a style="font-size: x-small; font-style: italic;" (click)="openRec = false">hide</a>
</ng-container> </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-icon name="close-outline"></ion-icon>
</ion-button> </ion-button>
</div> </div>
@@ -89,18 +92,18 @@
<!-- no config --> <!-- no config -->
<ion-item *ngIf="!hasConfig"> <ion-item *ngIf="!hasConfig">
<ion-label class="ion-text-wrap"> <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-label>
</ion-item> </ion-item>
<!-- save button, always show --> <!-- save button, always show -->
<ion-button <ion-button
[disabled]="invalid || (!edited && !added && !(['NEEDS_CONFIG'] | includes: (app.status | async)))" [disabled]="invalid || (!edited && !added && !(['NEEDS_CONFIG'] | includes: (app.status | ngrxPush)))"
fill="outline" fill="outline"
expand="block" expand="block"
style="margin: 10px" style="margin: 10px"
color="primary" color="primary"
(click)="save()" (click)="save(pkg)"
> >
<ion-text color="primary" style="font-weight: bold"> <ion-text color="primary" style="font-weight: bold">
Save Save
@@ -115,5 +118,5 @@
</ion-item-group> </ion-item-group>
</ng-container> </ng-container>
</ng-container> </ng-container>
</ng-container> </ng-template>
</ion-content> </ion-content>

View File

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

View File

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

View File

@@ -8,52 +8,9 @@
</ion-header> </ion-header>
<ion-content style="position: relative"> <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)"> <div *ngIf="pkgs | empty; else list" class="ion-text-center ion-padding">
<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 style="display: flex; flex-direction: column; justify-content: center; height: 40vh"> <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> <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> <p class="ion-text-wrap">Get started by installing your first service.</p>
@@ -63,5 +20,32 @@
Marketplace Marketplace
</ion-button> </ion-button>
</div> </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> </ng-container>
</ion-content> </ion-content>

View File

@@ -1,126 +1,29 @@
import { Component } from '@angular/core' 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 { 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({ @Component({
selector: 'app-installed-list', selector: 'app-installed-list',
templateUrl: './app-installed-list.page.html', templateUrl: './app-installed-list.page.html',
styleUrls: ['./app-installed-list.page.scss'], styleUrls: ['./app-installed-list.page.scss'],
}) })
export class AppInstalledListPage extends Cleanup { export class AppInstalledListPage {
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
constructor ( constructor (
private readonly serverModel: ServerModel,
private readonly appModel: AppModel,
private readonly preload: ModelPreload,
private readonly syncDaemon: SyncDaemon,
private readonly config: ConfigService, private readonly config: ConfigService,
) { public readonly connectionService: ConnectionService,
super() public readonly patch: PatchDbModel,
} ) { }
ngOnDestroy () { launchUi (pkg: PackageDataEntry, event: Event): void {
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) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
window.open(this.config.launchableURL(pkg.installed), '_blank')
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')
} }
async doRefresh (event: any) { asIsOrder () {
await doForAtLeast([this.getServerAndApps()], 600) return 0
event.target.complete()
}
async getServerAndApps (): Promise<void> {
try {
await this.syncDaemon.sync()
this.error = ''
} catch (e) {
console.error(e)
this.error = e.message
}
} }
} }

View File

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

View File

@@ -10,182 +10,173 @@
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<ion-content *ngIf="{ <ion-content class="ion-padding-bottom">
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>
<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"> <ng-container *ngrxLet="connectionService.monitor$() as connection">
<ion-item class="no-cushion-item" lines="none"> <ng-container *ngIf="pkg | manifest as manifest">
<ion-label class="ion-text-wrap" style=" <ng-container *ngIf="pkg | status : connection as status">
display: grid; <div class="top-plate">
grid-template-columns: 80px auto; <ion-item class="no-cushion-item" lines="none">
margin: 0px; <ion-label class="ion-text-wrap" style="
margin-top: 15px;" display: grid;
> grid-template-columns: 80px auto;
<ion-avatar style="justify-self: center; height: 55px; width: 55px" slot="start"> margin: 0px;
<img [src]="vars.iconURL | iconParse" /> margin-top: 15px;"
</ion-avatar> >
<div style="display: flex; flex-direction: column;"> <ion-avatar style="justify-self: center; height: 55px; width: 55px" slot="start">
<ion-text style="font-family: 'Montserrat'; font-size: x-large; line-height: normal;" [class.less-large]="vars.title.length > 20"> <img [src]="pkg['static-files'].icon" />
{{ vars.title }} </ion-avatar>
</ion-text> <div style="display: flex; flex-direction: column;">
<ion-text style="margin-top: -5px; margin-left: 2px;"> <ion-text style="font-family: 'Montserrat'; font-size: x-large; line-height: normal;" [class.less-large]="manifest.title.length > 20">
{{ vars.versionInstalled | displayEmver }} {{ manifest.title }}
</ion-text> </ion-text>
</div> <ion-text style="margin-top: -5px; margin-left: 2px;">
</ion-label> {{ manifest.version | displayEmver }}
</ion-item> </ion-text>
</div>
<ion-item class="no-cushion-item" lines=none style="margin-bottom: 10px;"> </ion-label>
<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>
</ion-item> </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> </ng-container>
</ion-item-group> </ng-container>
</ng-container> </ng-container>
</ng-container> </ng-container>
</ion-content> </ion-content>

View File

@@ -48,3 +48,19 @@
--border-radius: 10px; --border-radius: 10px;
margin: 12px 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 { 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 { ApiService } from 'src/app/services/api/api.service'
import { ActivatedRoute } from '@angular/router' import { ActivatedRoute, NavigationExtras } from '@angular/router'
import { copyToClipboard } from 'src/app/util/web.util' import { chill } from 'src/app/util/misc.util'
import { AppModel, AppStatus } from 'src/app/models/app-model' import { LoaderService } from 'src/app/services/loader.service'
import { AppInstalledFull } from 'src/app/models/app-types' import { Observable, of, Subscription } from 'rxjs'
import { ModelPreload } from 'src/app/models/model-preload'
import { chill, pauseFor } from 'src/app/util/misc.util'
import { PropertySubject, peekProperties } from 'src/app/util/property-subject.util'
import { AppBackupPage } from 'src/app/modals/app-backup/app-backup.page'
import { LoaderService, markAsLoadingDuring$, markAsLoadingDuringP } from 'src/app/services/loader.service'
import { BehaviorSubject, Observable, of } from 'rxjs'
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component' import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards' 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 { InformationPopoverComponent } from 'src/app/components/information-popover/information-popover.component'
import { ConfigService } from 'src/app/services/config.service' 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({ @Component({
selector: 'app-installed-show', selector: 'app-installed-show',
templateUrl: './app-installed-show.page.html', templateUrl: './app-installed-show.page.html',
styleUrls: ['./app-installed-show.page.scss'], styleUrls: ['./app-installed-show.page.scss'],
}) })
export class AppInstalledShowPage extends Cleanup { export class AppInstalledShowPage {
$loading$ = new BehaviorSubject(true) error: string
$loadingDependencies$ = new BehaviorSubject(false) // when true, dependencies will render with spinners. pkgId: string
pkg: PackageDataEntry
$error$ = new BehaviorSubject<string>('') pkgSub: Subscription
app: PropertySubject<AppInstalledFull> = { } as any
appId: string
AppStatus = AppStatus
showInstructions = false
hideLAN: boolean 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 @ViewChild(IonContent) content: IonContent
@@ -44,115 +40,44 @@ export class AppInstalledShowPage extends Cleanup {
private readonly route: ActivatedRoute, private readonly route: ActivatedRoute,
private readonly navCtrl: NavController, private readonly navCtrl: NavController,
private readonly loader: LoaderService, private readonly loader: LoaderService,
private readonly toastCtrl: ToastController,
private readonly modalCtrl: ModalController, private readonly modalCtrl: ModalController,
private readonly apiService: ApiService, private readonly apiService: ApiService,
private readonly preload: ModelPreload,
private readonly wizardBaker: WizardBaker, private readonly wizardBaker: WizardBaker,
private readonly appModel: AppModel,
private readonly popoverController: PopoverController, private readonly popoverController: PopoverController,
private readonly config: ConfigService, private readonly config: ConfigService,
) { public readonly patch: PatchDbModel,
super() public readonly connectionService: ConnectionService,
} ) { }
async ngOnInit () { async ngOnInit () {
this.appId = this.route.snapshot.paramMap.get('appId') as string this.pkgId = this.route.snapshot.paramMap.get('pkgId')
this.pkgSub = this.patch.watch$('package-data', this.pkgId).subscribe(pkg => this.pkg = pkg)
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(),
)
} }
ionViewDidEnter () { async ngOnDestroy () {
markAsLoadingDuringP(this.$loadingDependencies$, this.getApp()) this.pkgSub.unsubscribe()
} }
async doRefresh (event: any) { launchUiTab (): void {
await Promise.all([ window.open(this.config.launchableURL(this.pkg.installed), '_blank')
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()
} }
async stop (): Promise<void> { async stop (): Promise<void> {
const app = peekProperties(this.app) const { id, title, version } = this.pkg.installed.manifest
await this.loader.of({ await this.loader.of({
message: `Stopping ${app.title}...`, message: `Stopping...`,
spinner: 'lines', spinner: 'lines',
cssClass: 'loader', cssClass: 'loader',
}).displayDuringAsync(async () => { }).displayDuringAsync(async () => {
const { breakages } = await this.apiService.stopApp(this.appId, true) const { breakages } = await this.apiService.dryStopPackage({ id })
if (breakages.length) { if (breakages.length) {
const { cancelled } = await wizardModal( const { cancelled } = await wizardModal(
this.modalCtrl, this.modalCtrl,
this.wizardBaker.stop({ this.wizardBaker.stop({
id: app.id, id,
title: app.title, title,
version: app.versionInstalled, version,
breakages, breakages,
}), }),
) )
@@ -160,76 +85,28 @@ export class AppInstalledShowPage extends Cleanup {
if (cancelled) return { } if (cancelled) return { }
} }
return this.apiService.stopApp(this.appId).then(chill) return this.apiService.stopPackage({ id }).then(chill)
}).catch(e => this.setError(e)) }).catch(e => this.setError(e))
} }
async tryStart (): Promise<void> { async tryStart (): Promise<void> {
const app = peekProperties(this.app) const message = this.pkg.installed.manifest.alerts.start
if (app.startAlert) { if (message) {
this.presentAlertStart(app) this.presentAlertStart(message)
} else { } 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 () { async uninstall () {
const app = peekProperties(this.app) const { id, title, version, alerts } = this.pkg.installed.manifest
const data = await wizardModal( const data = await wizardModal(
this.modalCtrl, this.modalCtrl,
this.wizardBaker.uninstall({ this.wizardBaker.uninstall({
id: app.id, id,
title: app.title, title,
version: app.versionInstalled, version,
uninstallAlert: app.uninstallAlert, uninstallAlert: alerts.uninstall,
}), }),
) )
@@ -251,10 +128,64 @@ export class AppInstalledShowPage extends Cleanup {
return await popover.present() 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({ const alert = await this.alertCtrl.create({
header: 'Warning', header: 'Warning',
message: app.startAlert, message,
buttons: [ buttons: [
{ {
text: 'Cancel', text: 'Cancel',
@@ -263,7 +194,7 @@ export class AppInstalledShowPage extends Cleanup {
{ {
text: 'Start', text: 'Start',
handler: () => { handler: () => {
this.start(app) this.start()
}, },
}, },
], ],
@@ -271,40 +202,18 @@ export class AppInstalledShowPage extends Cleanup {
await alert.present() await alert.present()
} }
private async start (app: AppInstalledFull): Promise<void> { private async start (): Promise<void> {
this.loader.of({ this.loader.of({
message: `Starting ${app.title}...`, message: `Starting...`,
spinner: 'lines', spinner: 'lines',
cssClass: 'loader', cssClass: 'loader',
}).displayDuringP( }).displayDuringP(
this.apiService.startApp(this.appId), this.apiService.startPackage({ id: this.pkgId }),
).catch(e => this.setError(e)) ).catch(e => this.setError(e))
} }
private setError (e: Error): Observable<void> { private setError (e: Error): Observable<void> {
this.$error$.next(e.message) this.error = e.message
return of() 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, PwaBackComponentModule,
SharingModule, SharingModule,
], ],
declarations: [AppInstructionsPage], declarations: [
AppInstructionsPage,
],
}) })
export class AppInstructionsPageModule { } export class AppInstructionsPageModule { }

View File

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

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