mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
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:
committed by
Aiden McClelland
parent
fd685ae32c
commit
594d93eb3b
21908
ui/package-lock.json
generated
21908
ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -21,29 +21,26 @@
|
||||
"@angular/platform-browser": "^11.0.0",
|
||||
"@angular/platform-browser-dynamic": "^11.0.0",
|
||||
"@angular/router": "^11.0.0",
|
||||
"@ionic/angular": "^5.4.0",
|
||||
"@ionic/storage": "2.2.0",
|
||||
"@start9labs/emver": "^0.1.1",
|
||||
"@ionic/angular": "^5.6.0",
|
||||
"@ionic/storage": "^3.0.0",
|
||||
"@ionic/storage-angular": "^3.0.0",
|
||||
"@ngrx/component": "^11.1.1",
|
||||
"@start9labs/emver": "^0.1.4",
|
||||
"ajv": "^6.12.6",
|
||||
"angularx-qrcode": "^10.0.11",
|
||||
"base32.js": "^0.1.0",
|
||||
"base64url": "^3.0.1",
|
||||
"bip39": "^3.0.2",
|
||||
"bitcoinjs-lib": "^5.2.0",
|
||||
"angularx-qrcode": "^11.0.0",
|
||||
"compare-versions": "^3.5.0",
|
||||
"core-js": "^3.4.0",
|
||||
"handlebars": "^4.7.6",
|
||||
"json-pointer": "^0.6.1",
|
||||
"jsonpointerx": "^1.0.30",
|
||||
"jsontokens": "^3.0.0",
|
||||
"marked": "^2.0.0",
|
||||
"rxjs": "^6.6.3",
|
||||
"uuid": "^8.3.1",
|
||||
"zone.js": "^0.11.2",
|
||||
"patch-db-client": "file: ../../../../patch-db-client"
|
||||
"patch-db-client": "file: ../../../../patch-db-client",
|
||||
"rxjs": "^6.6.0",
|
||||
"uuid": "^8.3.0",
|
||||
"zone.js": "^0.11.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^0.1100.0",
|
||||
"@angular-devkit/build-angular": "^0.1102.0",
|
||||
"@angular/cli": "^11.0.0",
|
||||
"@angular/compiler": "^11.0.0",
|
||||
"@angular/compiler-cli": "^11.0.0",
|
||||
@@ -51,12 +48,12 @@
|
||||
"@ionic/angular-toolkit": "^3.0.0",
|
||||
"@ionic/lab": "^3.2.9",
|
||||
"@types/json-pointer": "^1.0.30",
|
||||
"@types/marked": "^1.1.0",
|
||||
"@types/node": "^14.11.10",
|
||||
"@types/marked": "^2.0.0",
|
||||
"@types/node": "^15.0.0",
|
||||
"@types/uuid": "^8.0.0",
|
||||
"node-html-parser": "2.0.0",
|
||||
"node-html-parser": "^3.2.0",
|
||||
"ts-node": "^9.1.0",
|
||||
"tslint": "^6.1.0",
|
||||
"typescript": "4.0.5"
|
||||
"typescript": "4.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { NgModule } from '@angular/core'
|
||||
import { PreloadAllModules, RouterModule, Routes } from '@angular/router'
|
||||
import { AuthGuard } from './guards/auth.guard'
|
||||
import { UnauthGuard } from './guards/unauth.guard'
|
||||
import { MaintenanceGuard } from './guards/maintenance.guard'
|
||||
import { UnmaintenanceGuard } from './guards/unmaintenance.guard'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
@@ -10,34 +12,32 @@ const routes: Routes = [
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
path: 'authenticate',
|
||||
path: 'auth',
|
||||
canActivate: [UnauthGuard],
|
||||
pathMatch: 'full',
|
||||
loadChildren: () => import('./pages/authenticate/authenticate.module').then( m => m.AuthenticatePageModule),
|
||||
loadChildren: () => import('./pages/auth-routes/auth-routing.module').then(m => m.AuthRoutingModule),
|
||||
},
|
||||
{
|
||||
path: 'embassy',
|
||||
canActivate: [AuthGuard],
|
||||
canActivateChild: [AuthGuard],
|
||||
canActivate: [AuthGuard, MaintenanceGuard],
|
||||
canActivateChild: [AuthGuard, MaintenanceGuard],
|
||||
loadChildren: () => import('./pages/server-routes/server-routing.module').then(m => m.ServerRoutingModule),
|
||||
},
|
||||
{
|
||||
path: 'maintenance',
|
||||
canActivate: [AuthGuard, UnmaintenanceGuard],
|
||||
loadChildren: () => import('./pages/maintenance/maintenance.module').then(m => m.MaintenancePageModule),
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
canActivate: [AuthGuard],
|
||||
canActivateChild: [AuthGuard],
|
||||
canActivate: [AuthGuard, MaintenanceGuard],
|
||||
loadChildren: () => import('./pages/notifications/notifications.module').then(m => m.NotificationsPageModule),
|
||||
},
|
||||
{
|
||||
path: 'services',
|
||||
canActivate: [AuthGuard],
|
||||
canActivateChild: [AuthGuard],
|
||||
canActivate: [AuthGuard, MaintenanceGuard],
|
||||
canActivateChild: [AuthGuard, MaintenanceGuard],
|
||||
loadChildren: () => import('./pages/apps-routes/apps-routing.module').then(m => m.AppsRoutingModule),
|
||||
},
|
||||
// {
|
||||
// path: 'drives',
|
||||
// canActivate: [AuthGuard],
|
||||
// loadChildren: () => import('./pages/server-routes/external-drives/external-drives.module').then( m => m.ExternalDrivesPageModule),
|
||||
// },
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
<ion-app>
|
||||
<ion-split-pane (ionSplitPaneVisible)="splitPaneVisible($event)" contentId="main-content">
|
||||
<ion-app *ngIf="patch.initialized">
|
||||
<ion-split-pane [disabled]="!showMenu" (ionSplitPaneVisible)="splitPaneVisible($event)" contentId="main-content">
|
||||
<ion-menu contentId="main-content" type="overlay">
|
||||
<ion-header *ngIf="$showMenuContent$ | async" class="menu-style">
|
||||
<ion-header>
|
||||
<ion-toolbar style="--background: var(--ion-background-color);">
|
||||
<ion-title *ngIf="serverName$ | async as name">{{ name }}</ion-title>
|
||||
<ion-title *ngIf="!(serverName$ | async)"><ion-spinner name="dots" color="warning"></ion-spinner></ion-title>
|
||||
<ion-title *ngIf="patch.watch$('ui', 'server-name') | ngrxPush as name; else dots">{{ name }}</ion-title>
|
||||
<ng-template #dots>
|
||||
<ion-title><ion-spinner name="dots" color="warning"></ion-spinner></ion-title>
|
||||
</ng-template>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content scroll-y="false" class="menu-style">
|
||||
<ng-container *ngIf="$showMenuContent$ | async">
|
||||
<ng-container>
|
||||
<ion-list style="padding: 0px">
|
||||
<ion-menu-toggle auto-hide="false" *ngFor="let page of appPages; let i = index">
|
||||
<ion-item
|
||||
@@ -18,144 +20,132 @@
|
||||
[routerLink]="[page.url]"
|
||||
lines="none"
|
||||
detail="false"
|
||||
[class.selected]="selectedIndex == i"
|
||||
[class.selected]="selectedIndex === i"
|
||||
>
|
||||
<ion-icon slot="start" [name]="page.icon"></ion-icon>
|
||||
<ion-label style="font-family: 'Montserrat';">{{page.title}}</ion-label>
|
||||
<ion-badge *ngIf="page.url === '/notifications' && (serverBadge$ | async) as s" color="danger" style="margin-right: 3%;" [class.selected-badge]="selectedIndex == i">{{s}}</ion-badge>
|
||||
<ion-label style="font-family: 'Montserrat';">{{ page.title }}</ion-label>
|
||||
<ion-badge *ngIf="page.url === '/notifications' && (patch.watch$('server-info', 'unread-notification-count') | ngrxPush) as badge" color="danger" style="margin-right: 3%;" [class.selected-badge]="selectedIndex == i">{{ badge }}</ion-badge>
|
||||
</ion-item>
|
||||
</ion-menu-toggle>
|
||||
</ion-list>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="!($showMenuContent$ | async)">
|
||||
<ion-card>
|
||||
<ion-card-header>
|
||||
<ion-card-title>Welcome</ion-card-title>
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
<p>This is the private website of your Start9 Embassy device.</p>
|
||||
<br />
|
||||
<p>Please authenticate yourself to continue.</p>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ng-container>
|
||||
</ion-content>
|
||||
<ion-footer style="padding-bottom: 5%; text-align: center;" class="menu-style">
|
||||
<ion-footer style="padding-bottom: 16px; text-align: center;" class="menu-style">
|
||||
<ion-menu-toggle auto-hide="false">
|
||||
<ion-item *ngIf="$showMenuContent$ | async" button style="--background:var(--ion-background-color); margin-bottom: 10%;" fill="clear" (click)="presentAlertLogout()">
|
||||
<ion-item button style="--background:var(--ion-background-color); margin-bottom: 16px;" fill="clear" (click)="presentAlertLogout()">
|
||||
<ion-icon size="small" slot="start" color="dark" name="log-out-outline"></ion-icon>
|
||||
<ion-label><ion-text color="danger">Logout</ion-text></ion-label>
|
||||
</ion-item>
|
||||
</ion-menu-toggle>
|
||||
<img style="width: 36%;" src="assets/logo-full.png">
|
||||
<img style="width: 25%;" src="assets/img/logo.png">
|
||||
</ion-footer>
|
||||
</ion-menu>
|
||||
<ion-router-outlet id="main-content"></ion-router-outlet>
|
||||
</ion-split-pane>
|
||||
<section id="preload" style="display: none;">
|
||||
<!-- 3rd party components -->
|
||||
<qrcode qrdata="hello"></qrcode>
|
||||
<!-- 3rd party components -->
|
||||
<qrcode qrdata="hello"></qrcode>
|
||||
|
||||
<img src="assets/img/running-bulb.png"/>
|
||||
<img src="assets/img/issue-bulb.png"/>
|
||||
<img src="assets/img/warning-bulb.png"/>
|
||||
<img src="assets/img/off-bulb.png"/>
|
||||
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=">
|
||||
<img src="assets/img/running-bulb.png"/>
|
||||
<img src="assets/img/issue-bulb.png"/>
|
||||
<img src="assets/img/warning-bulb.png"/>
|
||||
<img src="assets/img/off-bulb.png"/>
|
||||
<img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=">
|
||||
|
||||
<!-- Ionicons -->
|
||||
<ion-icon name="add"></ion-icon>
|
||||
<ion-icon name="alert-outline"></ion-icon>
|
||||
<ion-icon name="alert-circle-outline"></ion-icon>
|
||||
<ion-icon name="arrow-back"></ion-icon>
|
||||
<ion-icon name="arrow-forward"></ion-icon>
|
||||
<ion-icon name="arrow-up"></ion-icon>
|
||||
<ion-icon name="bookmark-outline"></ion-icon>
|
||||
<ion-icon name="chevron-down"></ion-icon>
|
||||
<ion-icon name="chevron-up"></ion-icon>
|
||||
<ion-icon name="close"></ion-icon>
|
||||
<ion-icon name="close-outline"></ion-icon>
|
||||
<ion-icon name="code-outline"></ion-icon>
|
||||
<ion-icon name="cog-outline"></ion-icon>
|
||||
<ion-icon name="color-wand-outline"></ion-icon>
|
||||
<ion-icon name="construct-outline"></ion-icon>
|
||||
<ion-icon name="copy-outline"></ion-icon>
|
||||
<ion-icon name="cube-outline"></ion-icon>
|
||||
<ion-icon name="download-outline"></ion-icon>
|
||||
<ion-icon name="ellipse"></ion-icon>
|
||||
<ion-icon name="eye-off-outline"></ion-icon>
|
||||
<ion-icon name="eye-outline"></ion-icon>
|
||||
<ion-icon name="file-tray-stacked-outline"></ion-icon>
|
||||
<ion-icon name="flash-outline"></ion-icon>
|
||||
<ion-icon name="grid-outline"></ion-icon>
|
||||
<ion-icon name="help-circle-outline"></ion-icon>
|
||||
<ion-icon name="home-outline"></ion-icon>
|
||||
<ion-icon name="information-circle-outline"></ion-icon>
|
||||
<ion-icon name="list-outline"></ion-icon>
|
||||
<ion-icon name="newspaper-outline"></ion-icon>
|
||||
<ion-icon name="notifications-outline"></ion-icon>
|
||||
<ion-icon name="rocket-outline"></ion-icon>
|
||||
<ion-icon name="power"></ion-icon>
|
||||
<ion-icon name="pulse"></ion-icon>
|
||||
<ion-icon name="qr-code-outline"></ion-icon>
|
||||
<ion-icon name="reload-outline"></ion-icon>
|
||||
<ion-icon name="refresh-outline"></ion-icon>
|
||||
<ion-icon name="save-outline"></ion-icon>
|
||||
<ion-icon name="storefront-outline"></ion-icon>
|
||||
<ion-icon name="terminal-outline"></ion-icon>
|
||||
<ion-icon name="trash-outline"></ion-icon>
|
||||
<ion-icon name="warning-outline"></ion-icon>
|
||||
<ion-icon name="wifi"></ion-icon>
|
||||
<!-- Ionic components -->
|
||||
<ion-action-sheet></ion-action-sheet>
|
||||
<ion-alert></ion-alert>
|
||||
<ion-avatar></ion-avatar>
|
||||
<ion-badge></ion-badge>
|
||||
<ion-button></ion-button>
|
||||
<ion-buttons></ion-buttons>
|
||||
<ion-card></ion-card>
|
||||
<ion-card-content></ion-card-content>
|
||||
<ion-card-header></ion-card-header>
|
||||
<ion-checkbox></ion-checkbox>
|
||||
<ion-content></ion-content>
|
||||
<ion-fab></ion-fab>
|
||||
<ion-fab-button></ion-fab-button>
|
||||
<ion-footer></ion-footer>
|
||||
<ion-grid></ion-grid>
|
||||
<ion-header></ion-header>
|
||||
<ion-popover></ion-popover>
|
||||
<ion-content>
|
||||
<ion-refresher slot="fixed"></ion-refresher>
|
||||
<ion-refresher-content pullingContent="lines"></ion-refresher-content>
|
||||
<ion-infinite-scroll></ion-infinite-scroll>
|
||||
<ion-infinite-scroll-content loadingSpinner="lines"></ion-infinite-scroll-content>
|
||||
</ion-content>
|
||||
<ion-input></ion-input>
|
||||
<ion-input *ngIf="untilLoaded" type="password" value="getdots"></ion-input>
|
||||
<ion-item></ion-item>
|
||||
<ion-item-divider></ion-item-divider>
|
||||
<ion-item-group></ion-item-group>
|
||||
<ion-label></ion-label>
|
||||
<ion-list></ion-list>
|
||||
<ion-loading></ion-loading>
|
||||
<ion-modal></ion-modal>
|
||||
<ion-note></ion-note>
|
||||
<ion-radio></ion-radio>
|
||||
<ion-row></ion-row>
|
||||
<ion-segment></ion-segment>
|
||||
<ion-segment-button></ion-segment-button>
|
||||
<ion-select></ion-select>
|
||||
<ion-select-option></ion-select-option>
|
||||
<ion-slides></ion-slides>
|
||||
<ion-spinner name="dots"></ion-spinner>
|
||||
<ion-spinner name="lines"></ion-spinner>
|
||||
<ion-text></ion-text>
|
||||
<ion-text style="font-weight: bold">load bold</ion-text>
|
||||
<ion-textarea></ion-textarea>
|
||||
<ion-title></ion-title>
|
||||
<ion-toast></ion-toast>
|
||||
<ion-toggle></ion-toggle>
|
||||
<ion-toolbar></ion-toolbar>
|
||||
<ion-menu-button></ion-menu-button>
|
||||
<!-- Ionicons -->
|
||||
<ion-icon name="add"></ion-icon>
|
||||
<ion-icon name="alert-outline"></ion-icon>
|
||||
<ion-icon name="alert-circle-outline"></ion-icon>
|
||||
<ion-icon name="aperture-outline"></ion-icon>
|
||||
<ion-icon name="arrow-back"></ion-icon>
|
||||
<ion-icon name="arrow-up"></ion-icon>
|
||||
<ion-icon name="bookmark-outline"></ion-icon>
|
||||
<ion-icon name="chevron-down"></ion-icon>
|
||||
<ion-icon name="chevron-up"></ion-icon>
|
||||
<ion-icon name="chevron-forward"></ion-icon> <!-- needed for detail="true" on ion-item button -->
|
||||
<ion-icon name="close"></ion-icon>
|
||||
<ion-icon name="close-outline"></ion-icon>
|
||||
<ion-icon name="code-outline"></ion-icon>
|
||||
<ion-icon name="cog-outline"></ion-icon>
|
||||
<ion-icon name="color-wand-outline"></ion-icon>
|
||||
<ion-icon name="construct-outline"></ion-icon>
|
||||
<ion-icon name="copy-outline"></ion-icon>
|
||||
<ion-icon name="cube-outline"></ion-icon>
|
||||
<ion-icon name="download-outline"></ion-icon>
|
||||
<ion-icon name="ellipse"></ion-icon>
|
||||
<ion-icon name="eye-off-outline"></ion-icon>
|
||||
<ion-icon name="eye-outline"></ion-icon>
|
||||
<ion-icon name="file-tray-stacked-outline"></ion-icon>
|
||||
<ion-icon name="flash-outline"></ion-icon>
|
||||
<ion-icon name="grid-outline"></ion-icon>
|
||||
<ion-icon name="help-circle-outline"></ion-icon>
|
||||
<ion-icon name="home-outline"></ion-icon>
|
||||
<ion-icon name="information-circle-outline"></ion-icon>
|
||||
<ion-icon name="list-outline"></ion-icon>
|
||||
<ion-icon name="newspaper-outline"></ion-icon>
|
||||
<ion-icon name="notifications-outline"></ion-icon>
|
||||
<ion-icon name="rocket-outline"></ion-icon>
|
||||
<ion-icon name="power"></ion-icon>
|
||||
<ion-icon name="pulse"></ion-icon>
|
||||
<ion-icon name="qr-code-outline"></ion-icon>
|
||||
<ion-icon name="reload-outline"></ion-icon>
|
||||
<ion-icon name="refresh-outline"></ion-icon>
|
||||
<ion-icon name="save-outline"></ion-icon>
|
||||
<ion-icon name="storefront-outline"></ion-icon>
|
||||
<ion-icon name="terminal-outline"></ion-icon>
|
||||
<ion-icon name="trash-outline"></ion-icon>
|
||||
<ion-icon name="warning-outline"></ion-icon>
|
||||
<ion-icon name="wifi"></ion-icon>
|
||||
<!-- Ionic components -->
|
||||
<ion-action-sheet></ion-action-sheet>
|
||||
<ion-alert></ion-alert>
|
||||
<ion-avatar></ion-avatar>
|
||||
<ion-badge></ion-badge>
|
||||
<ion-button></ion-button>
|
||||
<ion-buttons></ion-buttons>
|
||||
<ion-card></ion-card>
|
||||
<ion-card-content></ion-card-content>
|
||||
<ion-card-header></ion-card-header>
|
||||
<ion-checkbox></ion-checkbox>
|
||||
<ion-content></ion-content>
|
||||
<ion-fab></ion-fab>
|
||||
<ion-fab-button></ion-fab-button>
|
||||
<ion-footer></ion-footer>
|
||||
<ion-grid></ion-grid>
|
||||
<ion-header></ion-header>
|
||||
<ion-popover></ion-popover>
|
||||
<ion-content>
|
||||
<ion-refresher slot="fixed"></ion-refresher>
|
||||
<ion-refresher-content pullingContent="lines"></ion-refresher-content>
|
||||
<ion-infinite-scroll></ion-infinite-scroll>
|
||||
<ion-infinite-scroll-content loadingSpinner="lines"></ion-infinite-scroll-content>
|
||||
</ion-content>
|
||||
<ion-input></ion-input>
|
||||
<ion-item></ion-item>
|
||||
<ion-item-divider></ion-item-divider>
|
||||
<ion-item-group></ion-item-group>
|
||||
<ion-label></ion-label>
|
||||
<ion-list></ion-list>
|
||||
<ion-loading></ion-loading>
|
||||
<ion-modal></ion-modal>
|
||||
<ion-note></ion-note>
|
||||
<ion-radio></ion-radio>
|
||||
<ion-row></ion-row>
|
||||
<ion-segment></ion-segment>
|
||||
<ion-segment-button></ion-segment-button>
|
||||
<ion-select></ion-select>
|
||||
<ion-select-option></ion-select-option>
|
||||
<ion-slides></ion-slides>
|
||||
<ion-spinner name="dots"></ion-spinner>
|
||||
<ion-spinner name="lines"></ion-spinner>
|
||||
<ion-text></ion-text>
|
||||
<ion-text style="font-weight: bold">load bold</ion-text>
|
||||
<ion-textarea></ion-textarea>
|
||||
<ion-title></ion-title>
|
||||
<ion-toast></ion-toast>
|
||||
<ion-toggle></ion-toggle>
|
||||
<ion-toolbar></ion-toolbar>
|
||||
<ion-menu-button></ion-menu-button>
|
||||
</section>
|
||||
</ion-app>
|
||||
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { ServerModel, ServerStatus } from './models/server-model'
|
||||
import { Storage } from '@ionic/storage'
|
||||
import { SyncDaemon } from './services/sync.service'
|
||||
import { AuthService, AuthState } from './services/auth.service'
|
||||
import { ApiService } from './services/api/api.service'
|
||||
import { Router } from '@angular/router'
|
||||
import { BehaviorSubject, Observable } from 'rxjs'
|
||||
import { AppModel } from './models/app-model'
|
||||
import { filter, take } from 'rxjs/operators'
|
||||
import { AlertController } from '@ionic/angular'
|
||||
import { Router, RoutesRecognized } from '@angular/router'
|
||||
import { distinctUntilChanged, filter, finalize, takeWhile } from 'rxjs/operators'
|
||||
import { AlertController, ToastController } from '@ionic/angular'
|
||||
import { LoaderService } from './services/loader.service'
|
||||
import { Emver } from './services/emver.service'
|
||||
import { SplitPaneTracker } from './services/split-pane.service'
|
||||
import { LoadingOptions } from '@ionic/core'
|
||||
import { pauseFor } from './util/misc.util'
|
||||
import { PatchDbModel } from './models/patch-db/patch-db-model'
|
||||
import { HttpService } from './services/http.service'
|
||||
import { ServerStatus } from './models/patch-db/data-model'
|
||||
import { ConnectionService } from './services/connection.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -21,11 +20,8 @@ import { pauseFor } from './util/misc.util'
|
||||
styleUrls: ['app.component.scss'],
|
||||
})
|
||||
export class AppComponent {
|
||||
isUpdating = false
|
||||
fullPageMenu = true
|
||||
$showMenuContent$ = new BehaviorSubject(false)
|
||||
serverName$ : Observable<string>
|
||||
serverBadge$: Observable<number>
|
||||
ServerStatus = ServerStatus
|
||||
showMenu = false
|
||||
selectedIndex = 0
|
||||
untilLoaded = true
|
||||
appPages = [
|
||||
@@ -49,92 +45,118 @@ export class AppComponent {
|
||||
url: '/notifications',
|
||||
icon: 'notifications-outline',
|
||||
},
|
||||
// {
|
||||
// title: 'Backup drives',
|
||||
// url: '/drives',
|
||||
// icon: 'albums-outline',
|
||||
// },
|
||||
]
|
||||
|
||||
constructor (
|
||||
private readonly serverModel: ServerModel,
|
||||
private readonly syncDaemon: SyncDaemon,
|
||||
private readonly storage: Storage,
|
||||
private readonly appModel: AppModel,
|
||||
private readonly authService: AuthService,
|
||||
private readonly router: Router,
|
||||
private readonly api: ApiService,
|
||||
private readonly http: HttpService,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly loader: LoaderService,
|
||||
private readonly emver: Emver,
|
||||
private readonly connectionService: ConnectionService,
|
||||
private readonly toastCtrl: ToastController,
|
||||
readonly splitPane: SplitPaneTracker,
|
||||
readonly patch: PatchDbModel,
|
||||
) {
|
||||
// set dark theme
|
||||
document.body.classList.toggle('dark', true)
|
||||
this.serverName$ = this.serverModel.watch().name
|
||||
this.serverBadge$ = this.serverModel.watch().badge
|
||||
this.init()
|
||||
}
|
||||
|
||||
ionViewDidEnter () {
|
||||
// weird bug where a browser grabbed the value 'getdots' from the app.component.html preload input field.
|
||||
// this removes that field after prleloading occurs.
|
||||
pauseFor(500).then(() => this.untilLoaded = false)
|
||||
}
|
||||
|
||||
async init () {
|
||||
let fromFresh = true
|
||||
await this.storage.ready()
|
||||
await this.authService.restoreCache()
|
||||
await this.storage.create()
|
||||
await this.patch.init()
|
||||
await this.authService.init()
|
||||
await this.emver.init()
|
||||
|
||||
this.authService.listen({
|
||||
[AuthState.VERIFIED]: async () => {
|
||||
console.log('verified')
|
||||
this.api.authenticatedRequestsEnabled = true
|
||||
await this.serverModel.restoreCache()
|
||||
await this.appModel.restoreCache()
|
||||
this.syncDaemon.start()
|
||||
this.$showMenuContent$.next(true)
|
||||
if (fromFresh) {
|
||||
this.router.initialNavigation()
|
||||
fromFresh = false
|
||||
}
|
||||
},
|
||||
[AuthState.UNVERIFIED]: () => {
|
||||
console.log('unverified')
|
||||
this.api.authenticatedRequestsEnabled = false
|
||||
this.serverModel.clear()
|
||||
this.appModel.clear()
|
||||
this.syncDaemon.stop()
|
||||
this.router.initialNavigation()
|
||||
|
||||
// watch auth
|
||||
this.authService.watch$()
|
||||
.subscribe(auth => {
|
||||
// VERIFIED
|
||||
if (auth === AuthState.VERIFIED) {
|
||||
this.http.authReqEnabled = true
|
||||
this.showMenu = true
|
||||
this.patch.start()
|
||||
// watch network
|
||||
this.watchNetwork(auth)
|
||||
// watch router to highlight selected menu item
|
||||
this.watchRouter(auth)
|
||||
// watch status to display/hide maintenance page
|
||||
this.watchStatus(auth)
|
||||
// watch unread notification count to display toast
|
||||
this.watchNotifications(auth)
|
||||
// UNVERIFIED
|
||||
} else if (auth === AuthState.UNVERIFIED) {
|
||||
this.http.authReqEnabled = false
|
||||
this.showMenu = false
|
||||
this.patch.stop()
|
||||
this.storage.clear()
|
||||
this.router.navigate(['/authenticate'], { replaceUrl: true })
|
||||
this.$showMenuContent$.next(false)
|
||||
if (fromFresh) {
|
||||
this.router.initialNavigation()
|
||||
fromFresh = false
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
this.serverModel.watch().status.subscribe(s => {
|
||||
this.isUpdating = (s === ServerStatus.UPDATING)
|
||||
})
|
||||
|
||||
this.router.events.pipe(filter(e => !!(e as any).urlAfterRedirects)).subscribe((e: any) => {
|
||||
const appPageIndex = this.appPages.findIndex(
|
||||
appPage => (e.urlAfterRedirects || e.url || '').startsWith(appPage.url),
|
||||
)
|
||||
if (appPageIndex > -1) this.selectedIndex = appPageIndex
|
||||
|
||||
// TODO: while this works, it is dangerous and impractical.
|
||||
if (e.urlAfterRedirects !== '/embassy' && e.urlAfterRedirects !== '/authenticate' && this.isUpdating) {
|
||||
this.router.navigateByUrl('/embassy')
|
||||
this.router.navigate(['/auth'], { replaceUrl: true })
|
||||
}
|
||||
})
|
||||
this.api.watch401$().subscribe(() => {
|
||||
this.authService.setAuthStateUnverified()
|
||||
return this.api.postLogout()
|
||||
|
||||
this.http.watch401$().subscribe(() => {
|
||||
this.authService.setUnverified()
|
||||
})
|
||||
}
|
||||
|
||||
private watchNetwork (auth: AuthState): void {
|
||||
this.connectionService.monitor$()
|
||||
.pipe(
|
||||
distinctUntilChanged(),
|
||||
takeWhile(() => auth === AuthState.VERIFIED),
|
||||
)
|
||||
.subscribe(c => {
|
||||
console.log('CONNECTION CHANGED', c)
|
||||
})
|
||||
}
|
||||
|
||||
private watchRouter (auth: AuthState): void {
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter((e: RoutesRecognized) => !!e.urlAfterRedirects),
|
||||
takeWhile(() => auth === AuthState.VERIFIED),
|
||||
)
|
||||
.subscribe(e => {
|
||||
const appPageIndex = this.appPages.findIndex(
|
||||
appPage => e.urlAfterRedirects.startsWith(appPage.url),
|
||||
)
|
||||
if (appPageIndex > -1) this.selectedIndex = appPageIndex
|
||||
})
|
||||
}
|
||||
|
||||
private watchStatus (auth: AuthState): void {
|
||||
this.patch.watch$('server-info', 'status')
|
||||
.pipe(
|
||||
takeWhile(() => auth === AuthState.VERIFIED),
|
||||
)
|
||||
.subscribe(status => {
|
||||
const maintenance = '/maintenance'
|
||||
const url = this.router.url
|
||||
if (status === ServerStatus.Running && url.startsWith(maintenance)) {
|
||||
this.router.navigate([''], { replaceUrl: true })
|
||||
}
|
||||
if ([ServerStatus.Updating, ServerStatus.BackingUp].includes(status) && !url.startsWith(maintenance)) {
|
||||
this.router.navigate([maintenance], { replaceUrl: true })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private watchNotifications (auth: AuthState): void {
|
||||
let previous: number
|
||||
this.patch.watch$('server-info', 'unread-notification-count')
|
||||
.pipe(
|
||||
takeWhile(() => auth === AuthState.VERIFIED),
|
||||
finalize(() => console.log('FINALIZING!!!')),
|
||||
)
|
||||
.subscribe(count => {
|
||||
if (previous !== undefined && count > previous) this.presentToastNotifications()
|
||||
previous = count
|
||||
})
|
||||
}
|
||||
|
||||
@@ -161,20 +183,45 @@ export class AppComponent {
|
||||
}
|
||||
|
||||
private async logout () {
|
||||
this.serverName$.pipe(take(1)).subscribe(name => {
|
||||
this.loader.of(LoadingSpinner(`Logging out ${name || ''}...`))
|
||||
.displayDuringP(this.api.postLogout())
|
||||
.then(() => this.authService.setAuthStateUnverified())
|
||||
.catch(e => this.setError(e))
|
||||
})
|
||||
this.loader.of(LoadingSpinner('Logging out...'))
|
||||
.displayDuringP(this.api.logout({ }))
|
||||
.then(() => this.authService.setUnverified())
|
||||
.catch(e => this.setError(e))
|
||||
}
|
||||
|
||||
async setError (e: Error) {
|
||||
private async presentToastNotifications () {
|
||||
const toast = await this.toastCtrl.create({
|
||||
header: 'Embassy',
|
||||
message: `New notifications`,
|
||||
position: 'bottom',
|
||||
duration: 4000,
|
||||
cssClass: 'notification-toast',
|
||||
buttons: [
|
||||
{
|
||||
side: 'start',
|
||||
icon: 'close',
|
||||
handler: () => {
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
side: 'end',
|
||||
text: 'View',
|
||||
handler: () => {
|
||||
this.router.navigate(['/notifications'])
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await toast.present()
|
||||
}
|
||||
|
||||
private async setError (e: Error) {
|
||||
console.error(e)
|
||||
await this.presentError(e.message)
|
||||
}
|
||||
|
||||
async presentError (e: string) {
|
||||
private async presentError (e: string) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
backdropDismiss: true,
|
||||
message: `Exception on logout: ${e}`,
|
||||
@@ -189,7 +236,7 @@ export class AppComponent {
|
||||
}
|
||||
|
||||
splitPaneVisible (e: any) {
|
||||
this.splitPane.$menuFixedOpenOnLeft$.next(e.detail.visible)
|
||||
this.splitPane.menuFixedOpenOnLeft$.next(e.detail.visible)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,20 +2,23 @@ import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'
|
||||
import { BrowserModule } from '@angular/platform-browser'
|
||||
import { RouteReuseStrategy } from '@angular/router'
|
||||
import { IonicModule, IonicRouteStrategy } from '@ionic/angular'
|
||||
import { IonicStorageModule } from '@ionic/storage'
|
||||
import { Drivers } from '@ionic/storage'
|
||||
import { IonicStorageModule } from '@ionic/storage-angular'
|
||||
import { HttpClientModule } from '@angular/common/http'
|
||||
import { AppComponent } from './app.component'
|
||||
import { AppRoutingModule } from './app-routing.module'
|
||||
import { ApiService } from './services/api/api.service'
|
||||
import { ApiServiceFactory } from './services/api/api.service.factory'
|
||||
import { AppModel } from './models/app-model'
|
||||
import { PatchDbModelFactory } from './models/patch-db/patch-db-model.factory'
|
||||
import { HttpService } from './services/http.service'
|
||||
import { ServerModel } from './models/server-model'
|
||||
import { ConfigService } from './services/config.service'
|
||||
import { QRCodeModule } from 'angularx-qrcode'
|
||||
import { APP_CONFIG_COMPONENT_MAPPING } from './modals/app-config-injectable/modal-injectable-token'
|
||||
import { appConfigComponents } from './modals/app-config-injectable/modal-injectable-value';
|
||||
import { appConfigComponents } from './modals/app-config-injectable/modal-injectable-value'
|
||||
import { OSWelcomePageModule } from './modals/os-welcome/os-welcome.module'
|
||||
import { PatchDbModel } from './models/patch-db/patch-db-model'
|
||||
import { LocalStorageBootstrap } from './models/patch-db/local-storage-bootstrap'
|
||||
import { SharingModule } from './modules/sharing.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent],
|
||||
@@ -25,13 +28,21 @@ import { OSWelcomePageModule } from './modals/os-welcome/os-welcome.module'
|
||||
BrowserModule,
|
||||
IonicModule.forRoot(),
|
||||
AppRoutingModule,
|
||||
IonicStorageModule.forRoot(),
|
||||
IonicStorageModule.forRoot({
|
||||
storeName: '_embassykv',
|
||||
dbKey: '_embassykey',
|
||||
name: '_embassystorage',
|
||||
driverOrder: [Drivers.LocalStorage, Drivers.IndexedDB],
|
||||
}),
|
||||
QRCodeModule,
|
||||
OSWelcomePageModule,
|
||||
SharingModule,
|
||||
],
|
||||
providers: [
|
||||
Storage,
|
||||
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
|
||||
{ provide: ApiService, useFactory: ApiServiceFactory, deps: [ConfigService, HttpService, AppModel, ServerModel] },
|
||||
{ provide: ApiService , useFactory: ApiServiceFactory, deps: [ConfigService, HttpService] },
|
||||
{ provide: PatchDbModel, useFactory: PatchDbModelFactory, deps: [ConfigService, LocalStorageBootstrap, ApiService] },
|
||||
{ provide: APP_CONFIG_COMPONENT_MAPPING, useValue: appConfigComponents },
|
||||
],
|
||||
bootstrap: [AppComponent],
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,4 @@
|
||||
<div style="position: relative; margin-right: 1vh;">
|
||||
<ion-icon
|
||||
*ngIf="(badge$ | async) && !(menuFixedOpen$ | async)"
|
||||
size="medium"
|
||||
color="dark"
|
||||
[class.ios-badge]="isIos"
|
||||
[class.md-badge]="!isIos"
|
||||
name="alert-outline"
|
||||
>
|
||||
</ion-icon>
|
||||
<ion-badge mode="md" class="md-badge" *ngIf="(badge$ | ngrxPush) && !(menuFixedOpen$ | ngrxPush)" color="danger">{{ badge$ | ngrxPush }}</ion-badge>
|
||||
<ion-menu-button color="dark"></ion-menu-button>
|
||||
</div>
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
.ios-badge {
|
||||
background-color: var(--ion-color-start9);
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 62%;
|
||||
border-radius: 5px;
|
||||
z-index: 1;
|
||||
}
|
||||
// .ios-badge {
|
||||
// background-color: var(--ion-color-start9);
|
||||
// position: absolute;
|
||||
// top: 1px;
|
||||
// left: 62%;
|
||||
// border-radius: 5px;
|
||||
// z-index: 1;
|
||||
// }
|
||||
|
||||
.md-badge {
|
||||
background-color: var(--ion-color-start9);
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
top: -8px;
|
||||
left: 56%;
|
||||
border-radius: 5px;
|
||||
z-index: 1;
|
||||
font-size: 80%;
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { ServerModel } from '../../models/server-model'
|
||||
import { Observable } from 'rxjs'
|
||||
import { map } from 'rxjs/operators'
|
||||
import { SplitPaneTracker } from 'src/app/services/split-pane.service'
|
||||
import { isPlatform } from '@ionic/angular'
|
||||
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
|
||||
|
||||
@Component({
|
||||
selector: 'badge-menu-button',
|
||||
@@ -12,16 +10,14 @@ import { isPlatform } from '@ionic/angular'
|
||||
})
|
||||
|
||||
export class BadgeMenuComponent {
|
||||
badge$: Observable<boolean>
|
||||
badge$: Observable<number>
|
||||
menuFixedOpen$: Observable<boolean>
|
||||
isIos: boolean
|
||||
|
||||
constructor (
|
||||
private readonly serverModel: ServerModel,
|
||||
private readonly splitPane: SplitPaneTracker,
|
||||
private readonly patch: PatchDbModel,
|
||||
) {
|
||||
this.menuFixedOpen$ = this.splitPane.$menuFixedOpenOnLeft$.asObservable()
|
||||
this.badge$ = this.serverModel.watch().badge.pipe(map(i => i > 0))
|
||||
this.isIos = isPlatform('ios')
|
||||
this.menuFixedOpen$ = this.splitPane.menuFixedOpenOnLeft$.asObservable()
|
||||
this.badge$ = this.patch.watch$('server-info', 'unread-notification-count')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ValueSpec } from 'src/app/app-config/config-types'
|
||||
import { ValueSpec } from 'src/app/pkg-config/config-types'
|
||||
|
||||
@Component({
|
||||
selector: 'config-header',
|
||||
|
||||
@@ -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>
|
||||
@@ -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 { }
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 { }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 { }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 { }
|
||||
@@ -1,10 +0,0 @@
|
||||
.error-message {
|
||||
--background: var(--ion-color-danger);
|
||||
margin: 12px;
|
||||
border-radius: 3px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.legacy-error-message {
|
||||
margin: 5px;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<div *ngIf="!($loading$ | async) && !params.skipCompletionDialogue" class="slide-content">
|
||||
<div *ngIf="!(loading$ | ngrxPush) && !params.skipCompletionDialogue" class="slide-content">
|
||||
<div style="margin-top: 25px;">
|
||||
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;">
|
||||
<ion-label [color]="$color$ | async" style="font-size: xx-large; font-weight: bold;">
|
||||
<ion-label [color]="$color$ | ngrxPush" style="font-size: xx-large; font-weight: bold;">
|
||||
{{successText}}
|
||||
</ion-label>
|
||||
</div>
|
||||
@@ -11,7 +11,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="$loading$ | async" class="center-spinner">
|
||||
<div *ngIf="loading$ | ngrxPush" class="center-spinner">
|
||||
<ion-spinner color="warning" name="lines"></ion-spinner>
|
||||
<ion-label class="long-message">{{label}}</ion-label>
|
||||
</div>
|
||||
@@ -28,17 +28,18 @@ export class CompleteComponent implements OnInit, Loadable {
|
||||
}
|
||||
|
||||
|
||||
$loading$ = new BehaviorSubject(false)
|
||||
$color$ = new BehaviorSubject('medium')
|
||||
$cancel$ = new Subject<void>()
|
||||
loading$ = new BehaviorSubject(false)
|
||||
color$ = new BehaviorSubject('medium')
|
||||
cancel$ = new Subject<void>()
|
||||
|
||||
label: string
|
||||
summary: string
|
||||
successText: string
|
||||
|
||||
load () {
|
||||
markAsLoadingDuring$(this.$loading$, from(this.params.executeAction())).pipe(takeUntil(this.$cancel$)).subscribe(
|
||||
{ error: e => this.transitions.error(new Error(`${this.params.action} failed: ${e.message || e}`)),
|
||||
markAsLoadingDuring$(this.loading$, from(this.params.executeAction())).pipe(takeUntil(this.cancel$)).subscribe(
|
||||
{
|
||||
error: e => this.transitions.error(new Error(`${this.params.action} failed: ${e.message || e}`)),
|
||||
complete: () => this.params.skipCompletionDialogue && this.transitions.final(),
|
||||
},
|
||||
)
|
||||
@@ -50,37 +51,37 @@ export class CompleteComponent implements OnInit, Loadable {
|
||||
case 'install':
|
||||
this.summary = `Installation of ${this.params.title} is now in progress. You will receive a notification when the installation has completed.`
|
||||
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
|
||||
this.$color$.next('primary')
|
||||
this.color$.next('primary')
|
||||
this.successText = 'In Progress'
|
||||
break
|
||||
case 'downgrade':
|
||||
this.summary = `Downgrade for ${this.params.title} is now in progress. You will receive a notification when the downgrade has completed.`
|
||||
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
|
||||
this.$color$.next('primary')
|
||||
this.color$.next('primary')
|
||||
this.successText = 'In Progress'
|
||||
break
|
||||
case 'update':
|
||||
this.summary = `Update for ${this.params.title} is now in progress. You will receive a notification when the update has completed.`
|
||||
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
|
||||
this.$color$.next('primary')
|
||||
this.color$.next('primary')
|
||||
this.successText = 'In Progress'
|
||||
break
|
||||
case 'uninstall':
|
||||
this.summary = `${capitalizeFirstLetter(this.params.title)} has been successfully uninstalled.`
|
||||
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
|
||||
this.$color$.next('success')
|
||||
this.color$.next('success')
|
||||
this.successText = 'Success'
|
||||
break
|
||||
case 'stop':
|
||||
this.summary = `${capitalizeFirstLetter(this.params.title)} has been successfully stopped.`
|
||||
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
|
||||
this.$color$.next('success')
|
||||
this.color$.next('success')
|
||||
this.successText = 'Success'
|
||||
break
|
||||
case 'configure':
|
||||
this.summary = `New config for ${this.params.title} has been successfully saved.`
|
||||
this.label = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
|
||||
this.$color$.next('success')
|
||||
this.color$.next('success')
|
||||
this.successText = 'Success'
|
||||
break
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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 { }
|
||||
@@ -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
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
<div>
|
||||
<div *ngIf="!($loading$ | async)" class="slide-content">
|
||||
<div *ngIf="!(loading$ | ngrxPush)" class="slide-content">
|
||||
<div style="margin-top: 25px;">
|
||||
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;">
|
||||
<ion-label color="warning" style="font-size: xx-large; font-weight: bold;"
|
||||
@@ -26,7 +26,7 @@
|
||||
*ngFor="let dep of dependentBreakages"
|
||||
>
|
||||
<ion-avatar style="position: relative; height: 4vh; width: 4vh" slot="start">
|
||||
<img [src]="dep.iconURL | iconParse" />
|
||||
<img [src]="dep.iconURL" />
|
||||
</ion-avatar>
|
||||
<ion-label>
|
||||
<h5>{{dep.title}}</h5>
|
||||
@@ -35,7 +35,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div *ngIf="$loading$ | async" class="center-spinner">
|
||||
<div *ngIf="loading$ | ngrxPush" class="center-spinner">
|
||||
<ion-spinner color="warning" name="lines"></ion-spinner>
|
||||
<ion-label class="long-message">Checking for installed services which depend on {{params.title}}...</ion-label>
|
||||
</div>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Component, Input, OnInit } from '@angular/core'
|
||||
import { BehaviorSubject, from, Subject } from 'rxjs'
|
||||
import { takeUntil, tap } from 'rxjs/operators'
|
||||
import { DependentBreakage } from 'src/app/models/app-types'
|
||||
import { Breakages } from 'src/app/services/api/api-types'
|
||||
import { markAsLoadingDuring$ } from 'src/app/services/loader.service'
|
||||
import { capitalizeFirstLetter } from 'src/app/util/misc.util'
|
||||
import { capitalizeFirstLetter, isEmptyObject } from 'src/app/util/misc.util'
|
||||
import { Loadable } from '../loadable'
|
||||
import { WizardAction } from '../wizard-types'
|
||||
|
||||
@@ -17,7 +17,7 @@ export class DependentsComponent implements OnInit, Loadable {
|
||||
title: string,
|
||||
action: WizardAction, //Are you sure you want to *uninstall*...,
|
||||
verb: string, // *Uninstalling* will cause problems...
|
||||
fetchBreakages: () => Promise<DependentBreakage[]>,
|
||||
fetchBreakages: () => Promise<Breakages>,
|
||||
skipConfirmationDialogue?: boolean
|
||||
}
|
||||
@Input() transitions: {
|
||||
@@ -27,34 +27,33 @@ export class DependentsComponent implements OnInit, Loadable {
|
||||
error: (e: Error) => void
|
||||
}
|
||||
|
||||
|
||||
dependentBreakages: DependentBreakage[]
|
||||
dependentBreakages: Breakages
|
||||
hasDependentViolation: boolean
|
||||
longMessage: string | null = null
|
||||
$color$ = new BehaviorSubject('medium') // this will display disabled while loading
|
||||
$loading$ = new BehaviorSubject(false)
|
||||
$cancel$ = new Subject<void>()
|
||||
color$ = new BehaviorSubject('medium') // this will display disabled while loading
|
||||
loading$ = new BehaviorSubject(false)
|
||||
cancel$ = new Subject<void>()
|
||||
|
||||
constructor () { }
|
||||
ngOnInit () { }
|
||||
|
||||
load () {
|
||||
this.$color$.next('medium')
|
||||
markAsLoadingDuring$(this.$loading$, from(this.params.fetchBreakages())).pipe(
|
||||
takeUntil(this.$cancel$),
|
||||
tap(breakages => this.dependentBreakages = breakages || []),
|
||||
this.color$.next('medium')
|
||||
markAsLoadingDuring$(this.loading$, from(this.params.fetchBreakages())).pipe(
|
||||
takeUntil(this.cancel$),
|
||||
tap(breakages => this.dependentBreakages = breakages),
|
||||
).subscribe(
|
||||
{
|
||||
complete: () => {
|
||||
this.hasDependentViolation = this.dependentBreakages && this.dependentBreakages.length > 0
|
||||
this.hasDependentViolation = this.dependentBreakages && !isEmptyObject(this.dependentBreakages)
|
||||
if (this.hasDependentViolation) {
|
||||
this.longMessage = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title} will cause the following services to STOP running. Starting them again will require additional actions.`
|
||||
this.$color$.next('warning')
|
||||
this.color$.next('warning')
|
||||
} else if (this.params.skipConfirmationDialogue) {
|
||||
this.transitions.next()
|
||||
} else {
|
||||
this.longMessage = `No other services installed on your Embassy will be affected by this action.`
|
||||
this.$color$.next('success')
|
||||
this.color$.next('success')
|
||||
}
|
||||
},
|
||||
error: (e: Error) => this.transitions.error(new Error(`Fetching dependent service information failed: ${e.message || e}`)),
|
||||
|
||||
@@ -8,18 +8,18 @@
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<ion-slides *ngIf="!($error$ | async)" id="slide-show" style="--bullet-background: white" pager="false">
|
||||
<ion-slides *ngIf="!(error$ | ngrxPush)" id="slide-show" style="--bullet-background: white" pager="false">
|
||||
<ion-slide *ngFor="let def of params.slideDefinitions">
|
||||
<!-- We can pass [transitions]="transitions" into the component if logic within the component needs to trigger a transition (not just bottom bar) -->
|
||||
<dependencies #components *ngIf="def.slide.selector === 'dependencies'" [params]="def.slide.params"></dependencies>
|
||||
<notes #components *ngIf="def.slide.selector === 'notes'" [params]="def.slide.params"></notes>
|
||||
<notes #components *ngIf="def.slide.selector === 'notes'" [params]="def.slide.params" style="width: 100%;"></notes>
|
||||
<dependents #components *ngIf="def.slide.selector === 'dependents'" [params]="def.slide.params" [transitions]="transitions"></dependents>
|
||||
<complete #components *ngIf="def.slide.selector === 'complete'" [params]="def.slide.params" [transitions]="transitions"></complete>
|
||||
</ion-slide>
|
||||
</ion-slides>
|
||||
|
||||
|
||||
<div *ngIf="$error$ | async as error" class="slide-content">
|
||||
<div *ngIf="error$ | ngrxPush as error" class="slide-content">
|
||||
<div style="margin-top: 25px;">
|
||||
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;">
|
||||
<ion-label color="danger" style="font-size: xx-large; font-weight: bold;">
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar style="padding: 8px;">
|
||||
<ng-container *ngIf="!($initializing$ | async) && !($error$ | async) && { loading: currentSlide.$loading$ | async, bar: currentBottomBar} as v">
|
||||
<ng-container *ngIf="!(initializing$ | ngrxPush) && !(error$ | ngrxPush) && { loading: currentSlideloading$ | ngrxPush, bar: currentBottomBar} as v">
|
||||
|
||||
<!-- cancel button if loading/not loading -->
|
||||
<ion-button slot="start" *ngIf="v.loading && v.bar.cancel.whileLoading as cancel" (click)="transitions.cancel()" class="toolbar-button" fill="outline" color="medium">
|
||||
@@ -58,7 +58,7 @@
|
||||
</ion-button>
|
||||
|
||||
</ng-container>
|
||||
<ng-container *ngIf="$error$ | async">
|
||||
<ng-container *ngIf="error$ | ngrxPush">
|
||||
<ion-button slot="start" (click)="transitions.final()" style="text-transform: capitalize; font-weight: bolder;" color="danger">Dismiss</ion-button>
|
||||
</ng-container>
|
||||
</ion-toolbar>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { InstallWizardComponent } from './install-wizard.component'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
import { DependenciesComponentModule } from './dependencies/dependencies.component.module'
|
||||
import { DependentsComponentModule } from './dependents/dependents.component.module'
|
||||
import { CompleteComponentModule } from './complete/complete.component.module'
|
||||
import { NotesComponentModule } from './notes/notes.component.module'
|
||||
@@ -18,7 +17,6 @@ import { NotesComponentModule } from './notes/notes.component.module'
|
||||
IonicModule,
|
||||
RouterModule.forChild([]),
|
||||
SharingModule,
|
||||
DependenciesComponentModule,
|
||||
DependentsComponentModule,
|
||||
CompleteComponentModule,
|
||||
NotesComponentModule,
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { Component, Input, NgZone, OnInit, QueryList, ViewChild, ViewChildren } from '@angular/core'
|
||||
import { Component, Input, NgZone, QueryList, ViewChild, ViewChildren } from '@angular/core'
|
||||
import { IonContent, IonSlides, ModalController } from '@ionic/angular'
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
import { Cleanup } from 'src/app/util/cleanup'
|
||||
import { capitalizeFirstLetter, pauseFor } from 'src/app/util/misc.util'
|
||||
import { CompleteComponent } from './complete/complete.component'
|
||||
import { DependenciesComponent } from './dependencies/dependencies.component'
|
||||
import { DependentsComponent } from './dependents/dependents.component'
|
||||
import { NotesComponent } from './notes/notes.component'
|
||||
import { Loadable } from './loadable'
|
||||
@@ -15,7 +13,7 @@ import { WizardAction } from './wizard-types'
|
||||
templateUrl: './install-wizard.component.html',
|
||||
styleUrls: ['./install-wizard.component.scss'],
|
||||
})
|
||||
export class InstallWizardComponent extends Cleanup implements OnInit {
|
||||
export class InstallWizardComponent {
|
||||
@Input() params: {
|
||||
// defines each slide along with bottom bar
|
||||
slideDefinitions: SlideDefinition[]
|
||||
@@ -40,11 +38,13 @@ export class InstallWizardComponent extends Cleanup implements OnInit {
|
||||
return this.params.slideDefinitions[this.slideIndex].bottomBar
|
||||
}
|
||||
|
||||
$initializing$ = new BehaviorSubject(true)
|
||||
$error$ = new BehaviorSubject(undefined)
|
||||
initializing$ = new BehaviorSubject(true)
|
||||
error$ = new BehaviorSubject(undefined)
|
||||
|
||||
constructor (private readonly modalController: ModalController, private readonly zone: NgZone) { super() }
|
||||
ngOnInit () { }
|
||||
constructor (
|
||||
private readonly modalController: ModalController,
|
||||
private readonly zone: NgZone,
|
||||
) { }
|
||||
|
||||
ngAfterViewInit () {
|
||||
this.currentSlide.load()
|
||||
@@ -53,15 +53,15 @@ export class InstallWizardComponent extends Cleanup implements OnInit {
|
||||
}
|
||||
|
||||
ionViewDidEnter () {
|
||||
this.$initializing$.next(false)
|
||||
this.initializing$.next(false)
|
||||
}
|
||||
|
||||
// process bottom bar buttons
|
||||
private transition = (info: { next: any } | { error: Error } | { cancelled: true } | { final: true }) => {
|
||||
const i = info as { next?: any, error?: Error, cancelled?: true, final?: true }
|
||||
if (i.cancelled) this.currentSlide.$cancel$.next()
|
||||
if (i.cancelled) this.currentSlide.cancel$.next()
|
||||
if (i.final || i.cancelled) return this.modalController.dismiss(i)
|
||||
if (i.error) return this.$error$.next(capitalizeFirstLetter(i.error.message))
|
||||
if (i.error) return this.error$.next(capitalizeFirstLetter(i.error.message))
|
||||
|
||||
this.moveToNextSlide(i.next)
|
||||
}
|
||||
@@ -90,7 +90,6 @@ export class InstallWizardComponent extends Cleanup implements OnInit {
|
||||
|
||||
export interface SlideDefinition {
|
||||
slide:
|
||||
{ selector: 'dependencies', params: DependenciesComponent['params'] } |
|
||||
{ selector: 'dependents', params: DependentsComponent['params'] } |
|
||||
{ selector: 'complete', params: CompleteComponent['params'] } |
|
||||
{ selector: 'notes', params: NotesComponent['params'] }
|
||||
|
||||
@@ -3,7 +3,7 @@ import { BehaviorSubject, Subject } from 'rxjs'
|
||||
export interface Loadable {
|
||||
load: (prevResult?: any) => void
|
||||
result?: any // fill this variable on slide 1 to get passed into the load on slide 2. If this variable is falsey, it will skip the next slide.
|
||||
$loading$: BehaviorSubject<boolean> // will be true during load function
|
||||
$cancel$: Subject<void> // will cancel load function
|
||||
loading$: BehaviorSubject<boolean> // will be true during load function
|
||||
cancel$: Subject<void> // will cancel load function
|
||||
}
|
||||
|
||||
|
||||
@@ -14,8 +14,8 @@ export class NotesComponent implements OnInit, Loadable {
|
||||
titleColor: string
|
||||
}
|
||||
|
||||
$loading$ = new BehaviorSubject(false)
|
||||
$cancel$ = new Subject<void>()
|
||||
loading$ = new BehaviorSubject(false)
|
||||
cancel$ = new Subject<void>()
|
||||
|
||||
load () { }
|
||||
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { AppModel, AppStatus } from 'src/app/models/app-model'
|
||||
import { OsUpdateService } from 'src/app/services/os-update.service'
|
||||
import { InstalledPackageDataEntry } from 'src/app/models/patch-db/data-model'
|
||||
import { Breakages } from 'src/app/services/api/api-types'
|
||||
import { exists } from 'src/app/util/misc.util'
|
||||
import { AppDependency, DependentBreakage, AppInstalledPreview } from '../../models/app-types'
|
||||
import { ApiService } from '../../services/api/api.service'
|
||||
import { InstallWizardComponent, SlideDefinition, TopbarParams } from './install-wizard.component'
|
||||
import { WizardAction } from './wizard-types'
|
||||
@@ -11,43 +10,34 @@ import { WizardAction } from './wizard-types'
|
||||
export class WizardBaker {
|
||||
constructor (
|
||||
private readonly apiService: ApiService,
|
||||
private readonly updateService: OsUpdateService,
|
||||
private readonly appModel: AppModel,
|
||||
) { }
|
||||
|
||||
install (values: {
|
||||
id: string, title: string, version: string, serviceRequirements: AppDependency[], installAlert?: string
|
||||
id: string, title: string, version: string, installAlert?: string
|
||||
}): InstallWizardComponent['params'] {
|
||||
const { id, title, version, serviceRequirements, installAlert } = values
|
||||
const { id, title, version, installAlert } = values
|
||||
|
||||
validate(id, exists, 'missing id')
|
||||
validate(title, exists, 'missing title')
|
||||
validate(version, exists, 'missing version')
|
||||
validate(serviceRequirements, t => !!t && Array.isArray(t), 'missing serviceRequirements')
|
||||
|
||||
const action = 'install'
|
||||
const toolbar: TopbarParams = { action, title, version }
|
||||
const toolbar: TopbarParams = { action, title, version }
|
||||
|
||||
const slideDefinitions: SlideDefinition[] = [
|
||||
installAlert ? {
|
||||
slide: {
|
||||
selector: 'notes',
|
||||
params: { notes: installAlert, title: 'Warning', titleColor: 'warning' },
|
||||
params: {
|
||||
notes: installAlert,
|
||||
title: 'Warning',
|
||||
titleColor: 'warning',
|
||||
},
|
||||
},
|
||||
bottomBar: {
|
||||
cancel: { afterLoading: { text: 'Cancel' } }, next: 'Next',
|
||||
},
|
||||
} : undefined,
|
||||
{
|
||||
slide: {
|
||||
selector: 'dependencies',
|
||||
params: { action, title, version, serviceRequirements },
|
||||
},
|
||||
bottomBar: {
|
||||
cancel: { afterLoading: { text: 'Cancel' } },
|
||||
next: 'Install',
|
||||
},
|
||||
},
|
||||
{
|
||||
slide: {
|
||||
selector: 'complete',
|
||||
@@ -55,7 +45,7 @@ export class WizardBaker {
|
||||
action,
|
||||
verb: 'beginning installation for',
|
||||
title,
|
||||
executeAction: () => this.apiService.installApp(id, version).then(app => { this.appModel.add({ ...app, status: AppStatus.INSTALLING })}),
|
||||
executeAction: () => this.apiService.installPackage({ id, version }),
|
||||
},
|
||||
},
|
||||
bottomBar: {
|
||||
@@ -68,14 +58,13 @@ export class WizardBaker {
|
||||
}
|
||||
|
||||
update (values: {
|
||||
id: string, title: string, version: string, serviceRequirements: AppDependency[], installAlert?: string
|
||||
id: string, title: string, version: string, installAlert?: string
|
||||
}): InstallWizardComponent['params'] {
|
||||
const { id, title, version, serviceRequirements, installAlert } = values
|
||||
const { id, title, version, installAlert } = values
|
||||
|
||||
validate(id, exists, 'missing id')
|
||||
validate(title, exists, 'missing title')
|
||||
validate(version, exists, 'missing version')
|
||||
validate(serviceRequirements, t => !!t && Array.isArray(t), 'missing serviceRequirements')
|
||||
|
||||
const action = 'update'
|
||||
const toolbar: TopbarParams = { action, title, version }
|
||||
@@ -84,26 +73,26 @@ export class WizardBaker {
|
||||
installAlert ? {
|
||||
slide: {
|
||||
selector: 'notes',
|
||||
params: { notes: installAlert, title: 'Warning', titleColor: 'warning'},
|
||||
params: {
|
||||
notes: installAlert,
|
||||
title: 'Warning',
|
||||
titleColor: 'warning',
|
||||
},
|
||||
},
|
||||
bottomBar: {
|
||||
cancel: { afterLoading: { text: 'Cancel' } },
|
||||
next: 'Next',
|
||||
},
|
||||
} : undefined,
|
||||
{ slide: {
|
||||
selector: 'dependencies',
|
||||
params: { action, title, version, serviceRequirements },
|
||||
},
|
||||
bottomBar: {
|
||||
cancel: { afterLoading: { text: 'Cancel' } },
|
||||
next: 'Update',
|
||||
},
|
||||
},
|
||||
{ slide: {
|
||||
{
|
||||
slide: {
|
||||
selector: 'dependents',
|
||||
params: {
|
||||
skipConfirmationDialogue: true, action, verb: 'updating', title, fetchBreakages: () => this.apiService.installApp(id, version, true).then( ({ breakages }) => breakages ),
|
||||
skipConfirmationDialogue: true,
|
||||
action,
|
||||
verb: 'updating',
|
||||
title,
|
||||
fetchBreakages: () => this.apiService.dryUpdatePackage({ id, version }).then( ({ breakages }) => breakages ),
|
||||
},
|
||||
},
|
||||
bottomBar: {
|
||||
@@ -111,12 +100,14 @@ export class WizardBaker {
|
||||
next: 'Update Anyways',
|
||||
},
|
||||
},
|
||||
{ slide: {
|
||||
{
|
||||
slide: {
|
||||
selector: 'complete',
|
||||
params: {
|
||||
action, verb: 'beginning update for', title, executeAction: () => this.apiService.installApp(id, version).then(app => {
|
||||
this.appModel.update({ id: app.id, status: AppStatus.INSTALLING })
|
||||
}),
|
||||
action,
|
||||
verb: 'beginning update for',
|
||||
title,
|
||||
executeAction: () => this.apiService.installPackage({ id, version }),
|
||||
},
|
||||
},
|
||||
bottomBar: {
|
||||
@@ -138,18 +129,27 @@ export class WizardBaker {
|
||||
const toolbar: TopbarParams = { action, title, version }
|
||||
|
||||
const slideDefinitions: SlideDefinition[] = [
|
||||
{ slide : {
|
||||
{
|
||||
slide : {
|
||||
selector: 'notes',
|
||||
params: { notes: releaseNotes, title: 'Release Notes', titleColor: 'dark' },
|
||||
params: {
|
||||
notes: releaseNotes,
|
||||
title: 'Release Notes',
|
||||
titleColor: 'dark',
|
||||
},
|
||||
},
|
||||
bottomBar: {
|
||||
cancel: { afterLoading: { text: 'Cancel' } }, next: 'Update OS',
|
||||
},
|
||||
},
|
||||
{ slide: {
|
||||
{
|
||||
slide: {
|
||||
selector: 'complete',
|
||||
params: {
|
||||
action, verb: 'beginning update for', title, executeAction: () => this.updateService.updateEmbassyOS(version),
|
||||
action,
|
||||
verb: 'beginning update for',
|
||||
title,
|
||||
executeAction: () => this.apiService.updateServer({ }),
|
||||
},
|
||||
},
|
||||
bottomBar: {
|
||||
@@ -162,14 +162,13 @@ export class WizardBaker {
|
||||
}
|
||||
|
||||
downgrade (values: {
|
||||
id: string, title: string, version: string, serviceRequirements: AppDependency[], installAlert?: string
|
||||
id: string, title: string, version: string, installAlert?: string
|
||||
}): InstallWizardComponent['params'] {
|
||||
const { id, title, version, serviceRequirements, installAlert } = values
|
||||
const { id, title, version, installAlert } = values
|
||||
|
||||
validate(id, exists, 'missing id')
|
||||
validate(title, exists, 'missing title')
|
||||
validate(version, exists, 'missing version')
|
||||
validate(serviceRequirements, t => !!t && Array.isArray(t), 'missing serviceRequirements')
|
||||
|
||||
const action = 'downgrade'
|
||||
const toolbar: TopbarParams = { action, title, version }
|
||||
@@ -178,23 +177,22 @@ export class WizardBaker {
|
||||
installAlert ? {
|
||||
slide: {
|
||||
selector: 'notes',
|
||||
params: { notes: installAlert, title: 'Warning', titleColor: 'warning' },
|
||||
params: {
|
||||
notes: installAlert,
|
||||
title: 'Warning',
|
||||
titleColor: 'warning',
|
||||
},
|
||||
},
|
||||
bottomBar: { cancel: { afterLoading: { text: 'Cancel' } }, next: 'Next' },
|
||||
} : undefined,
|
||||
{ slide: {
|
||||
selector: 'dependencies',
|
||||
params: { action, title, version, serviceRequirements },
|
||||
},
|
||||
bottomBar: {
|
||||
cancel: { afterLoading: { text: 'Cancel' } },
|
||||
next: 'Downgrade',
|
||||
},
|
||||
},
|
||||
{ slide: {
|
||||
selector: 'dependents',
|
||||
params: {
|
||||
skipConfirmationDialogue: true, action, verb: 'downgrading', title, fetchBreakages: () => this.apiService.installApp(id, version, true).then( ({ breakages }) => breakages ),
|
||||
skipConfirmationDialogue: true,
|
||||
action,
|
||||
verb: 'downgrading',
|
||||
title,
|
||||
fetchBreakages: () => this.apiService.dryUpdatePackage({ id, version }).then( ({ breakages }) => breakages ),
|
||||
},
|
||||
},
|
||||
bottomBar: {
|
||||
@@ -204,9 +202,10 @@ export class WizardBaker {
|
||||
{ slide: {
|
||||
selector: 'complete',
|
||||
params: {
|
||||
action, verb: 'beginning downgrade for', title, executeAction: () => this.apiService.installApp(id, version).then(app => {
|
||||
this.appModel.update({ id: app.id, status: AppStatus.INSTALLING })
|
||||
}),
|
||||
action,
|
||||
verb: 'beginning downgrade for',
|
||||
title,
|
||||
executeAction: () => this.apiService.installPackage({ id, version }),
|
||||
},
|
||||
},
|
||||
bottomBar: {
|
||||
@@ -231,7 +230,8 @@ export class WizardBaker {
|
||||
const toolbar: TopbarParams = { action, title, version }
|
||||
|
||||
const slideDefinitions: SlideDefinition[] = [
|
||||
{ slide: {
|
||||
{
|
||||
slide: {
|
||||
selector: 'notes',
|
||||
params: {
|
||||
notes: uninstallAlert || defaultUninstallationWarning(title),
|
||||
@@ -241,18 +241,26 @@ export class WizardBaker {
|
||||
},
|
||||
bottomBar: { cancel: { afterLoading: { text: 'Cancel' } }, next: 'Continue' },
|
||||
},
|
||||
{ slide: {
|
||||
{
|
||||
slide: {
|
||||
selector: 'dependents',
|
||||
params: {
|
||||
action, verb: 'uninstalling', title, fetchBreakages: () => this.apiService.uninstallApp(id, true).then( ({ breakages }) => breakages ),
|
||||
action,
|
||||
verb: 'uninstalling',
|
||||
title,
|
||||
fetchBreakages: () => this.apiService.dryRemovePackage({ id }).then( ({ breakages }) => breakages ),
|
||||
},
|
||||
},
|
||||
bottomBar: { cancel: { whileLoading: { }, afterLoading: { text: 'Cancel' } }, next: 'Uninstall' },
|
||||
},
|
||||
{ slide: {
|
||||
{
|
||||
slide: {
|
||||
selector: 'complete',
|
||||
params: {
|
||||
action, verb: 'uninstalling', title, executeAction: () => this.apiService.uninstallApp(id).then(() => this.appModel.delete(id)),
|
||||
action,
|
||||
verb: 'uninstalling',
|
||||
title,
|
||||
executeAction: () => this.apiService.removePackage({ id }),
|
||||
},
|
||||
},
|
||||
bottomBar: { finish: 'Dismiss', cancel: { whileLoading: { } } },
|
||||
@@ -262,7 +270,7 @@ export class WizardBaker {
|
||||
}
|
||||
|
||||
stop (values: {
|
||||
breakages: DependentBreakage[], id: string, title: string, version: string
|
||||
breakages: Breakages, id: string, title: string, version: string
|
||||
}): InstallWizardComponent['params'] {
|
||||
const { breakages, title, version } = values
|
||||
|
||||
@@ -274,10 +282,14 @@ export class WizardBaker {
|
||||
const toolbar: TopbarParams = { action, title, version }
|
||||
|
||||
const slideDefinitions: SlideDefinition[] = [
|
||||
{ slide: {
|
||||
{
|
||||
slide: {
|
||||
selector: 'dependents',
|
||||
params: {
|
||||
action, verb: 'stopping', title, fetchBreakages: () => Promise.resolve(breakages),
|
||||
action,
|
||||
verb: 'stopping',
|
||||
title,
|
||||
fetchBreakages: () => Promise.resolve(breakages),
|
||||
},
|
||||
},
|
||||
bottomBar: { cancel: { afterLoading: { text: 'Cancel' } }, next: 'Stop Anyways' },
|
||||
@@ -286,19 +298,20 @@ export class WizardBaker {
|
||||
return { toolbar, slideDefinitions }
|
||||
}
|
||||
|
||||
configure (values: {
|
||||
breakages: DependentBreakage[], app: AppInstalledPreview
|
||||
}): InstallWizardComponent['params'] {
|
||||
const { breakages, app } = values
|
||||
const { title, versionInstalled: version } = app
|
||||
configure (values: { breakages: Breakages, pkg: InstalledPackageDataEntry }): InstallWizardComponent['params'] {
|
||||
const { breakages, pkg } = values
|
||||
const { title, version } = pkg.manifest
|
||||
const action = 'configure'
|
||||
const toolbar: TopbarParams = { action, title, version }
|
||||
|
||||
const slideDefinitions: SlideDefinition[] = [
|
||||
{ slide: {
|
||||
{
|
||||
slide: {
|
||||
selector: 'dependents',
|
||||
params: {
|
||||
action, verb: 'saving config for', title, fetchBreakages: () => Promise.resolve(breakages),
|
||||
action,
|
||||
verb: 'saving config for',
|
||||
title, fetchBreakages: () => Promise.resolve(breakages),
|
||||
},
|
||||
},
|
||||
bottomBar: { cancel: { afterLoading: { text: 'Cancel' } }, next: 'Save Config Anyways' },
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||
import { Annotation, Annotations } from '../../app-config/config-utilities'
|
||||
import { Annotation, Annotations } from '../../pkg-config/config-utilities'
|
||||
import { TrackingModalController } from 'src/app/services/tracking-modal-controller.service'
|
||||
import { ConfigCursor } from 'src/app/app-config/config-cursor'
|
||||
import { ModalPresentable } from 'src/app/app-config/modal-presentable'
|
||||
import { ValueSpecOf, ValueSpec } from 'src/app/app-config/config-types'
|
||||
import { ConfigCursor } from 'src/app/pkg-config/config-cursor'
|
||||
import { ModalPresentable } from 'src/app/pkg-config/modal-presentable'
|
||||
import { ValueSpecOf, ValueSpec } from 'src/app/pkg-config/config-types'
|
||||
import { MaskPipe } from 'src/app/pipes/mask.pipe'
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { PwaBackService } from 'src/app/services/pwa-back.service'
|
||||
import { NavController } from '@ionic/angular'
|
||||
|
||||
@Component({
|
||||
selector: 'pwa-back-button',
|
||||
templateUrl: './pwa-back.component.html',
|
||||
@@ -7,10 +8,10 @@ import { PwaBackService } from 'src/app/services/pwa-back.service'
|
||||
})
|
||||
export class PwaBackComponent {
|
||||
constructor (
|
||||
private readonly pwaBack: PwaBackService,
|
||||
private readonly nav: NavController,
|
||||
) { }
|
||||
|
||||
navigateBack () {
|
||||
return this.pwaBack.back()
|
||||
return this.nav.back()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,6 @@
|
||||
border-color: #FFEB3B;
|
||||
border-width: medium;
|
||||
box-shadow: 0 0 10px white;" size="small" (click)="presentPopover($event)">
|
||||
<img [src]="rec.iconURL | iconParse" />
|
||||
<img [src]="rec.iconURL" />
|
||||
</ion-fab-button>
|
||||
</ion-fab>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Component, Input, OnInit } from '@angular/core'
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { PopoverController } from '@ionic/angular'
|
||||
import { filter, take } from 'rxjs/operators'
|
||||
import { Cleanup } from 'src/app/util/cleanup'
|
||||
import { capitalizeFirstLetter } from 'src/app/util/misc.util'
|
||||
import { InformationPopoverComponent } from '../information-popover/information-popover.component'
|
||||
|
||||
@@ -11,12 +10,13 @@ import { InformationPopoverComponent } from '../information-popover/information-
|
||||
templateUrl: './recommendation-button.component.html',
|
||||
styleUrls: ['./recommendation-button.component.scss'],
|
||||
})
|
||||
export class RecommendationButtonComponent extends Cleanup implements OnInit {
|
||||
export class RecommendationButtonComponent {
|
||||
@Input() rec: Recommendation
|
||||
@Input() raise?: { id: string }
|
||||
constructor (private readonly router: Router, private readonly popoverController: PopoverController) {
|
||||
super()
|
||||
}
|
||||
constructor (
|
||||
private readonly router: Router,
|
||||
private readonly popoverController: PopoverController,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
if (!this.raise) return
|
||||
@@ -41,7 +41,7 @@ export class RecommendationButtonComponent extends Cleanup implements OnInit {
|
||||
componentProps: {
|
||||
information: `
|
||||
<div style="font-size: medium; font-style: italic; margin: 5px 0px;">
|
||||
${capitalizeFirstLetter(this.rec.title)} Installation Recommendations
|
||||
${capitalizeFirstLetter(this.rec.dependentTitle)} Installation Recommendations
|
||||
</div>
|
||||
<div>
|
||||
${this.rec.description}
|
||||
@@ -57,10 +57,9 @@ export class RecommendationButtonComponent extends Cleanup implements OnInit {
|
||||
}
|
||||
|
||||
export type Recommendation = {
|
||||
title: string
|
||||
appId: string
|
||||
iconURL: string,
|
||||
description: string,
|
||||
versionSpec?: string
|
||||
whyDependency?: string
|
||||
dependentId: string
|
||||
dependentTitle: string
|
||||
dependentIcon: string,
|
||||
description: string
|
||||
version?: string
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<p *ngIf="size === 'small'" style="margin: 0 0 4px 0;">
|
||||
<ion-text [style]="style" [color]="color">{{ display }}</ion-text>
|
||||
<ion-text [color]="color">{{ display }}</ion-text>
|
||||
<ion-spinner *ngIf="showDots" class="dots dots-small" name="dots" [color]="color"></ion-spinner>
|
||||
</p>
|
||||
|
||||
<h3 *ngIf="size === 'italics-small'" style="margin: 0 0 4px 0;">
|
||||
<ion-text [style]="style" style="font-size: small; font-style: italic; text-transform: lowercase;" [color]="color">{{ display }}</ion-text>
|
||||
<p *ngIf="size === 'italics-small'" style="margin: 0 0 4px 0; font-style: italic;">
|
||||
<ion-text [color]="color">{{ display }}</ion-text>
|
||||
<ion-spinner *ngIf="showDots" class="dots dots-small" name="dots" [color]="color"></ion-spinner>
|
||||
</h3>
|
||||
</p>
|
||||
|
||||
<h3 *ngIf="size === 'medium'">
|
||||
<ion-text [style]="style" [color]="color">{{ display }}</ion-text>
|
||||
<ion-text [color]="color">{{ display }}</ion-text>
|
||||
<ion-spinner *ngIf="showDots" class="dots dots-medium" name="dots" [color]="color"></ion-spinner>
|
||||
</h3>
|
||||
|
||||
<h1 *ngIf="size === 'large'">
|
||||
<ion-text [style]="style" [color]="color">{{ display }}</ion-text>
|
||||
<ion-text [color]="color">{{ display }}</ion-text>
|
||||
<ion-spinner *ngIf="showDots" class="dots" name="dots" [color]="color"></ion-spinner>
|
||||
</h1>
|
||||
|
||||
<h1 style="font-size: 18px; font-weight: 500" *ngIf="size === 'bold-large'">
|
||||
<ion-text [style]="style" [color]="color">{{ display }}</ion-text>
|
||||
<h1 *ngIf="size === 'bold-large'" style="font-size: 18px; font-weight: 500">
|
||||
<ion-text [color]="color">{{ display }}</ion-text>
|
||||
<ion-spinner *ngIf="showDots" class="dots" name="dots" [color]="color"></ion-spinner>
|
||||
</h1>
|
||||
|
||||
@@ -15,18 +15,3 @@
|
||||
height: 24px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
.dots {
|
||||
vertical-align: middle;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.dots-small {
|
||||
width: 12px !important;
|
||||
height: 12px !important;
|
||||
}
|
||||
|
||||
.dots-medium {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { AppStatus } from 'src/app/models/app-model'
|
||||
import { ServerStatus } from 'src/app/models/server-model'
|
||||
import { ServerStatusRendering, AppStatusRendering } from '../../util/status-rendering'
|
||||
import { PackageDataEntry } from 'src/app/models/patch-db/data-model'
|
||||
import { ConnectionState } from 'src/app/services/connection.service'
|
||||
import { renderPkgStatus } from 'src/app/services/pkg-status-rendering.service'
|
||||
|
||||
@Component({
|
||||
selector: 'status',
|
||||
@@ -9,48 +9,18 @@ import { ServerStatusRendering, AppStatusRendering } from '../../util/status-ren
|
||||
styleUrls: ['./status.component.scss'],
|
||||
})
|
||||
export class StatusComponent {
|
||||
@Input() appStatus?: AppStatus
|
||||
@Input() serverStatus?: ServerStatus
|
||||
@Input() pkg: PackageDataEntry
|
||||
@Input() connection: ConnectionState
|
||||
@Input() size: 'small' | 'medium' | 'large' | 'italics-small' | 'bold-large' = 'large'
|
||||
@Input() text: string = ''
|
||||
color: string
|
||||
display: string
|
||||
showDots: boolean
|
||||
style = ''
|
||||
display = ''
|
||||
color = ''
|
||||
showDots = false
|
||||
|
||||
ngOnChanges () {
|
||||
if (this.serverStatus) {
|
||||
this.handleServerStatus()
|
||||
} else if (this.appStatus) {
|
||||
this.handleAppStatus()
|
||||
}
|
||||
}
|
||||
|
||||
handleServerStatus () {
|
||||
let res = ServerStatusRendering[this.serverStatus]
|
||||
if (!res) {
|
||||
console.warn(`Received invalid server status from the server: `, this.serverStatus)
|
||||
res = ServerStatusRendering[ServerStatus.UNKNOWN]
|
||||
}
|
||||
|
||||
const { display, color, showDots } = res
|
||||
const { display, color, showDots } = renderPkgStatus(this.pkg, this.connection)
|
||||
this.display = display
|
||||
this.color = color
|
||||
this.showDots = showDots
|
||||
}
|
||||
|
||||
handleAppStatus () {
|
||||
let res = AppStatusRendering[this.appStatus]
|
||||
if (!res) {
|
||||
console.warn(`Received invalid app status from the server: `, this.appStatus)
|
||||
res = AppStatusRendering[AppStatus.UNKNOWN]
|
||||
}
|
||||
|
||||
const { display, color, showDots, style } = res
|
||||
this.display = display + this.text
|
||||
this.color = color
|
||||
this.showDots = showDots
|
||||
this.style = style
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
@@ -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 { }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,41 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { CanActivate, Router, CanActivateChild } from '@angular/router'
|
||||
import { tap } from 'rxjs/operators'
|
||||
import { AuthState, AuthService } from '../services/auth.service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthGuard implements CanActivate, CanActivateChild {
|
||||
authState: AuthState
|
||||
|
||||
constructor (
|
||||
private readonly authService: AuthService,
|
||||
private readonly router: Router,
|
||||
) { }
|
||||
) {
|
||||
this.authService.watch$()
|
||||
.pipe(
|
||||
tap(auth => this.authState = auth),
|
||||
).subscribe()
|
||||
}
|
||||
|
||||
canActivate (): boolean {
|
||||
return this.runCheck()
|
||||
return this.runAuthCheck()
|
||||
}
|
||||
|
||||
canActivateChild (): boolean {
|
||||
return this.runCheck()
|
||||
return this.runAuthCheck()
|
||||
}
|
||||
|
||||
private runCheck (): boolean {
|
||||
const state = this.authService.peek()
|
||||
|
||||
switch (state){
|
||||
case AuthState.VERIFIED: return true
|
||||
case AuthState.UNVERIFIED: return this.toAuthenticate()
|
||||
case AuthState.INITIALIZING: return this.toAuthenticate()
|
||||
private runAuthCheck (): boolean {
|
||||
switch (this.authState){
|
||||
case AuthState.VERIFIED:
|
||||
return true
|
||||
case AuthState.UNVERIFIED:
|
||||
// @TODO could initializing cause a loop?
|
||||
case AuthState.INITIALIZING:
|
||||
this.router.navigate(['/auth'], { replaceUrl: true })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private toAuthenticate () {
|
||||
this.router.navigate(['/authenticate'], { replaceUrl: true })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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?')
|
||||
}
|
||||
}
|
||||
39
ui/src/app/guards/maintenance.guard.ts
Normal file
39
ui/src/app/guards/maintenance.guard.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,34 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { CanActivate, Router } from '@angular/router'
|
||||
import { tap } from 'rxjs/operators'
|
||||
import { AuthService, AuthState } from '../services/auth.service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class UnauthGuard implements CanActivate {
|
||||
authState: AuthState
|
||||
|
||||
constructor (
|
||||
private readonly authService: AuthService,
|
||||
private readonly router: Router,
|
||||
) { }
|
||||
) {
|
||||
this.authService.watch$()
|
||||
.pipe(
|
||||
tap(auth => this.authState = auth),
|
||||
).subscribe()
|
||||
}
|
||||
|
||||
canActivate (): boolean {
|
||||
const state = this.authService.peek()
|
||||
switch (state){
|
||||
|
||||
switch (this.authState){
|
||||
case AuthState.VERIFIED: {
|
||||
this.router.navigateByUrl('')
|
||||
return false
|
||||
}
|
||||
case AuthState.UNVERIFIED: return true
|
||||
case AuthState.INITIALIZING: return true
|
||||
case AuthState.UNVERIFIED:
|
||||
case AuthState.INITIALIZING:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
31
ui/src/app/guards/unmaintenance.guard.ts
Normal file
31
ui/src/app/guards/unmaintenance.guard.ts
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -1,3 +0,0 @@
|
||||
.toast-close-button {
|
||||
color: var(--ion-color-primary) !important;
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Type } from '@angular/core'
|
||||
import { ValueType } from 'src/app/app-config/config-types'
|
||||
import { ValueType } from 'src/app/pkg-config/config-types'
|
||||
|
||||
export type AppConfigComponentMapping = { [k in ValueType]: Type<any> }
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { AlertController } from '@ionic/angular'
|
||||
import { Annotations, Range } from '../../app-config/config-utilities'
|
||||
import { Annotations, Range } from '../../pkg-config/config-utilities'
|
||||
import { TrackingModalController } from 'src/app/services/tracking-modal-controller.service'
|
||||
import { ConfigCursor } from 'src/app/app-config/config-cursor'
|
||||
import { ValueSpecList, isValueSpecListOf } from 'src/app/app-config/config-types'
|
||||
import { ModalPresentable } from 'src/app/app-config/modal-presentable'
|
||||
import { ConfigCursor } from 'src/app/pkg-config/config-cursor'
|
||||
import { ValueSpecList, isValueSpecListOf } from 'src/app/pkg-config/config-types'
|
||||
import { ModalPresentable } from 'src/app/pkg-config/modal-presentable'
|
||||
|
||||
@Component({
|
||||
selector: 'app-config-list',
|
||||
@@ -106,7 +106,7 @@ export class AppConfigListPage extends ModalPresentable {
|
||||
|
||||
async presentAlertDelete (key: number, e: Event) {
|
||||
e.stopPropagation()
|
||||
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
backdropDismiss: false,
|
||||
header: 'Caution',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController, AlertController } from '@ionic/angular'
|
||||
import { ConfigCursor } from 'src/app/app-config/config-cursor'
|
||||
import { ValueSpecObject } from 'src/app/app-config/config-types'
|
||||
import { ConfigCursor } from 'src/app/pkg-config/config-cursor'
|
||||
import { ValueSpecObject } from 'src/app/pkg-config/config-types'
|
||||
|
||||
@Component({
|
||||
selector: 'app-config-object',
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Component, Input, ViewChild } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { ConfigCursor } from 'src/app/app-config/config-cursor'
|
||||
import { ValueSpecUnion } from 'src/app/app-config/config-types'
|
||||
import { ConfigCursor } from 'src/app/pkg-config/config-cursor'
|
||||
import { ValueSpecUnion } from 'src/app/pkg-config/config-types'
|
||||
import { ObjectConfigComponent } from 'src/app/components/object-config/object-config.component'
|
||||
import { mapUnionSpec } from '../../app-config/config-utilities'
|
||||
import { mapUnionSpec } from '../../pkg-config/config-utilities'
|
||||
|
||||
@Component({
|
||||
selector: 'app-config-union',
|
||||
|
||||
@@ -71,7 +71,7 @@
|
||||
<p *ngIf="rangeDescription">
|
||||
<ion-text color="medium">{{ rangeDescription }}</ion-text>
|
||||
</p>
|
||||
<p *ngIf="spec.default">
|
||||
<p *ngIf="spec.default !== undefined">
|
||||
<ion-text color="medium">
|
||||
<p>Default: {{ defaultDescription }} <ion-icon style="padding-left: 8px;" name="refresh-outline" color="primary" (click)="refreshDefault()"></ion-icon></p>
|
||||
<p *ngIf="spec.type === 'number' && spec.units">Units: {{ spec.units }}</p>
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { getDefaultConfigValue, getDefaultDescription, Range } from 'src/app/app-config/config-utilities'
|
||||
import { getDefaultConfigValue, getDefaultDescription, Range } from 'src/app/pkg-config/config-utilities'
|
||||
import { AlertController, ToastController } from '@ionic/angular'
|
||||
import { LoaderService } from 'src/app/services/loader.service'
|
||||
import { TrackingModalController } from 'src/app/services/tracking-modal-controller.service'
|
||||
import { ConfigCursor } from 'src/app/app-config/config-cursor'
|
||||
import { ValueSpecOf } from 'src/app/app-config/config-types'
|
||||
import { ConfigCursor } from 'src/app/pkg-config/config-cursor'
|
||||
import { ValueSpecOf } from 'src/app/pkg-config/config-types'
|
||||
import { copyToClipboard } from 'src/app/util/web.util'
|
||||
|
||||
@Component({
|
||||
|
||||
@@ -8,10 +8,10 @@
|
||||
<div>
|
||||
<ion-item lines="none" style="--background: var(--ion-background-color); --border-color: var(--ion-color-medium);">
|
||||
<ion-label style="font-size: small" position="floating">Master Password</ion-label>
|
||||
<ion-input style="border-style: solid; border-width: 0px 0px 1px 0px; border-color: var(--ion-color-dark);" [(ngModel)]="password" type="password" (ionChange)="$error$.next('')"></ion-input>
|
||||
<ion-input style="border-style: solid; border-width: 0px 0px 1px 0px; border-color: var(--ion-color-dark);" [(ngModel)]="password" type="password" (ionChange)="error = ''"></ion-input>
|
||||
</ion-item>
|
||||
<ion-item *ngIf="$error$ | async as e" lines="none" style="--background: var(--ion-background-color);">
|
||||
<ion-label style="font-size: small" color="danger" class="ion-text-wrap">{{e}}</ion-label>
|
||||
<ion-item *ngIf="error" lines="none" style="--background: var(--ion-background-color);">
|
||||
<ion-label style="font-size: small" color="danger" class="ion-text-wrap">{{ error }}</ion-label>
|
||||
</ion-item>
|
||||
</div>
|
||||
<div style="display: flex; justify-content: flex-end; align-items: center;">
|
||||
@@ -1,14 +1,14 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { AppBackupConfirmationComponent } from './app-backup-confirmation.component'
|
||||
import { BackupConfirmationComponent } from './backup-confirmation.component'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { FormsModule } from '@angular/forms'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppBackupConfirmationComponent,
|
||||
BackupConfirmationComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
@@ -17,6 +17,6 @@ import { FormsModule } from '@angular/forms';
|
||||
SharingModule,
|
||||
FormsModule,
|
||||
],
|
||||
exports: [AppBackupConfirmationComponent],
|
||||
exports: [BackupConfirmationComponent],
|
||||
})
|
||||
export class AppBackupConfirmationComponentModule { }
|
||||
export class BackupConfirmationComponentModule { }
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { ServerModel } from 'src/app/models/server-model'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
|
||||
@@ -19,7 +18,7 @@ export class OSWelcomePage {
|
||||
) { }
|
||||
|
||||
async dismiss () {
|
||||
this.apiService.acknowledgeOSWelcome(this.config.version).catch(console.error)
|
||||
this.apiService.setDbValue({ pointer: '/welcome-ack', value: this.config.version }).catch(console.error)
|
||||
|
||||
// return false to skip subsequent alert modals (e.g. check for updates modals)
|
||||
// return true to show subsequent alert modals
|
||||
|
||||
@@ -1,177 +1,153 @@
|
||||
import { MapSubject, Delta, Update } from '../util/map-subject.util'
|
||||
import { diff, partitionArray } from '../util/misc.util'
|
||||
import { PropertySubject, complete } from '../util/property-subject.util'
|
||||
import { Injectable } from '@angular/core'
|
||||
import { merge, Observable, of } from 'rxjs'
|
||||
import { filter, throttleTime, delay, pairwise, mapTo, take } from 'rxjs/operators'
|
||||
import { Storage } from '@ionic/storage'
|
||||
import { StorageKeys } from './storage-keys'
|
||||
import { AppInstalledFull, AppInstalledPreview } from './app-types'
|
||||
// import { MapSubject, Delta, Update } from '../util/map-subject.util'
|
||||
// import { diff, partitionArray } from '../util/misc.util'
|
||||
// import { Injectable } from '@angular/core'
|
||||
// import { merge, Observable, of } from 'rxjs'
|
||||
// import { filter, throttleTime, delay, pairwise, mapTo, take } from 'rxjs/operators'
|
||||
// import { Storage } from '@ionic/storage'
|
||||
// import { StorageKeys } from './storage-keys'
|
||||
// import { AppInstalledFull, AppInstalledPreview } from './app-types'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AppModel extends MapSubject<AppInstalledFull> {
|
||||
// hasLoaded tells us if we've successfully queried apps from api or storage, even if there are none.
|
||||
hasLoaded = false
|
||||
lastUpdatedAt: { [id: string]: Date } = { }
|
||||
constructor (private readonly storage: Storage) {
|
||||
super()
|
||||
// 500ms after first delta, will save to db. Subsequent deltas are ignored for those 500ms.
|
||||
// Process continues as long as deltas fire.
|
||||
this.watchDelta().pipe(throttleTime(200), delay(200)).subscribe(() => {
|
||||
this.commitCache()
|
||||
})
|
||||
}
|
||||
// @Injectable({
|
||||
// providedIn: 'root',
|
||||
// })
|
||||
// export class AppModel extends MapSubject<AppInstalledFull> {
|
||||
// // hasLoaded tells us if we've successfully queried apps from api or storage, even if there are none.
|
||||
// hasLoaded = false
|
||||
// lastUpdatedAt: { [id: string]: Date } = { }
|
||||
// constructor (private readonly storage: Storage) {
|
||||
// super()
|
||||
// // 500ms after first delta, will save to db. Subsequent deltas are ignored for those 500ms.
|
||||
// // Process continues as long as deltas fire.
|
||||
// this.watchDelta().pipe(throttleTime(200), delay(200)).subscribe(() => {
|
||||
// this.commitCache()
|
||||
// })
|
||||
// }
|
||||
|
||||
update (newValues: Update<AppInstalledFull>, timestamp: Date = new Date()): void {
|
||||
this.lastUpdatedAt[newValues.id] = this.lastUpdatedAt[newValues.id] || timestamp
|
||||
if (this.lastUpdatedAt[newValues.id] > timestamp) {
|
||||
return
|
||||
} else {
|
||||
super.update(newValues)
|
||||
this.lastUpdatedAt[newValues.id] = timestamp
|
||||
}
|
||||
}
|
||||
// update (newValues: Update<AppInstalledFull>, timestamp: Date = new Date()): void {
|
||||
// this.lastUpdatedAt[newValues.id] = this.lastUpdatedAt[newValues.id] || timestamp
|
||||
// if (this.lastUpdatedAt[newValues.id] > timestamp) {
|
||||
// return
|
||||
// } else {
|
||||
// super.update(newValues)
|
||||
// this.lastUpdatedAt[newValues.id] = timestamp
|
||||
// }
|
||||
// }
|
||||
|
||||
// client fxns
|
||||
watchDelta (filterFor?: Delta<AppInstalledFull>['action']): Observable<Delta<AppInstalledFull>> {
|
||||
return filterFor
|
||||
? this.$delta$.pipe(filter(d => d.action === filterFor))
|
||||
: this.$delta$.asObservable()
|
||||
}
|
||||
// // client fxns
|
||||
// watchDelta (filterFor?: Delta<AppInstalledFull>['action']): Observable<Delta<AppInstalledFull>> {
|
||||
// return filterFor
|
||||
// ? this.$delta$.pipe(filter(d => d.action === filterFor))
|
||||
// : this.$delta$.asObservable()
|
||||
// }
|
||||
|
||||
watch (appId: string) : PropertySubject<AppInstalledFull> {
|
||||
const toReturn = super.watch(appId)
|
||||
if (!toReturn) throw new Error(`Expected Service ${appId} but not found.`)
|
||||
return toReturn
|
||||
}
|
||||
// watch (appId: string) : PropertySubject<AppInstalledFull> {
|
||||
// const toReturn = super.watch(appId)
|
||||
// if (!toReturn) throw new Error(`Expected Service ${appId} but not found.`)
|
||||
// return toReturn
|
||||
// }
|
||||
|
||||
// when an app is installing
|
||||
watchForInstallation (appId: string): Observable<string | undefined> {
|
||||
const toWatch = super.watch(appId)
|
||||
if (!toWatch) return of(undefined)
|
||||
// // when an app is installing
|
||||
// watchForInstallation (appId: string): Observable<string | undefined> {
|
||||
// const toWatch = super.watch(appId)
|
||||
// if (!toWatch) return of(undefined)
|
||||
|
||||
return toWatch.status.pipe(
|
||||
filter(s => s !== AppStatus.UNREACHABLE && s !== AppStatus.UNKNOWN),
|
||||
pairwise(),
|
||||
filter( ([old, _]) => old === AppStatus.INSTALLING ),
|
||||
take(1),
|
||||
mapTo(appId),
|
||||
)
|
||||
}
|
||||
// return toWatch.status.pipe(
|
||||
// filter(s => s !== AppStatus.UNREACHABLE && s !== AppStatus.UNKNOWN),
|
||||
// pairwise(),
|
||||
// filter( ([old, _]) => old === AppStatus.INSTALLING ),
|
||||
// take(1),
|
||||
// mapTo(appId),
|
||||
// )
|
||||
// }
|
||||
|
||||
// TODO: EJECT-DISKS: we can use this to watch for an app completing its backup process.
|
||||
watchForBackup (appId: string): Observable<string | undefined> {
|
||||
const toWatch = super.watch(appId)
|
||||
if (!toWatch) return of(undefined)
|
||||
// // TODO: EJECT-DISKS: we can use this to watch for an app completing its backup process.
|
||||
// watchForBackup (appId: string): Observable<string | undefined> {
|
||||
// const toWatch = super.watch(appId)
|
||||
// if (!toWatch) return of(undefined)
|
||||
|
||||
return toWatch.status.pipe(
|
||||
filter(s => s !== AppStatus.UNREACHABLE && s !== AppStatus.UNKNOWN),
|
||||
pairwise(),
|
||||
filter( ([old, _]) => old === AppStatus.CREATING_BACKUP),
|
||||
take(1),
|
||||
mapTo(appId),
|
||||
)
|
||||
}
|
||||
// return toWatch.status.pipe(
|
||||
// filter(s => s !== AppStatus.UNREACHABLE && s !== AppStatus.UNKNOWN),
|
||||
// pairwise(),
|
||||
// filter( ([old, _]) => old === AppStatus.CREATING_BACKUP),
|
||||
// take(1),
|
||||
// mapTo(appId),
|
||||
// )
|
||||
// }
|
||||
|
||||
watchForInstallations (appIds: { id: string }[]): Observable<string> {
|
||||
return merge(...appIds.map(({ id }) => this.watchForInstallation(id))).pipe(
|
||||
filter(t => !!t),
|
||||
)
|
||||
}
|
||||
// watchForInstallations (appIds: { id: string }[]): Observable<string> {
|
||||
// return merge(...appIds.map(({ id }) => this.watchForInstallation(id))).pipe(
|
||||
// filter(t => !!t),
|
||||
// )
|
||||
// }
|
||||
|
||||
// cache mgmt
|
||||
clear (): void {
|
||||
this.ids.forEach(id => {
|
||||
complete(this.contents[id] || { } as PropertySubject<any>)
|
||||
delete this.contents[id]
|
||||
})
|
||||
this.hasLoaded = false
|
||||
this.contents = { }
|
||||
this.lastUpdatedAt = { }
|
||||
}
|
||||
// // cache mgmt
|
||||
// clear (): void {
|
||||
// this.ids.forEach(id => {
|
||||
// complete(this.contents[id] || { } as PropertySubject<any>)
|
||||
// delete this.contents[id]
|
||||
// })
|
||||
// this.hasLoaded = false
|
||||
// this.contents = { }
|
||||
// this.lastUpdatedAt = { }
|
||||
// }
|
||||
|
||||
private commitCache (): Promise<void> {
|
||||
return this.storage.set(StorageKeys.APPS_CACHE_KEY, this.all || [])
|
||||
}
|
||||
// private commitCache (): Promise<void> {
|
||||
// return this.storage.set(StorageKeys.APPS_CACHE_KEY, this.all || [])
|
||||
// }
|
||||
|
||||
async restoreCache (): Promise<void> {
|
||||
const stored = await this.storage.get(StorageKeys.APPS_CACHE_KEY)
|
||||
console.log(`restored app cache`, stored)
|
||||
if (stored) this.hasLoaded = true
|
||||
return (stored || []).map(c => this.add({ ...emptyAppInstalledFull(), ...c, status: AppStatus.UNKNOWN }))
|
||||
}
|
||||
// async restoreCache (): Promise<void> {
|
||||
// const stored = await this.storage.get(StorageKeys.APPS_CACHE_KEY)
|
||||
// console.log(`restored app cache`, stored)
|
||||
// if (stored) this.hasLoaded = true
|
||||
// return (stored || []).map(c => this.add({ ...emptyAppInstalledFull(), ...c, status: AppStatus.UNKNOWN }))
|
||||
// }
|
||||
|
||||
upsertAppFull (app: AppInstalledFull): void {
|
||||
this.update(app)
|
||||
}
|
||||
// upsertAppFull (app: AppInstalledFull): void {
|
||||
// this.update(app)
|
||||
// }
|
||||
|
||||
// synchronizers
|
||||
upsertApps (apps: AppInstalledPreview[], timestamp: Date): void {
|
||||
const [updates, creates] = partitionArray(apps, a => !!this.contents[a.id])
|
||||
updates.map(u => this.update(u, timestamp))
|
||||
creates.map(c => this.add({ ...emptyAppInstalledFull(), ...c }))
|
||||
}
|
||||
// // synchronizers
|
||||
// upsertApps (apps: AppInstalledPreview[], timestamp: Date): void {
|
||||
// const [updates, creates] = partitionArray(apps, a => !!this.contents[a.id])
|
||||
// updates.map(u => this.update(u, timestamp))
|
||||
// creates.map(c => this.add({ ...emptyAppInstalledFull(), ...c }))
|
||||
// }
|
||||
|
||||
syncCache (upToDateApps : AppInstalledPreview[], timestamp: Date) {
|
||||
this.hasLoaded = true
|
||||
this.deleteNonexistentApps(upToDateApps)
|
||||
this.upsertApps(upToDateApps, timestamp)
|
||||
}
|
||||
// syncCache (upToDateApps : AppInstalledPreview[], timestamp: Date) {
|
||||
// this.hasLoaded = true
|
||||
// this.deleteNonexistentApps(upToDateApps)
|
||||
// this.upsertApps(upToDateApps, timestamp)
|
||||
// }
|
||||
|
||||
private deleteNonexistentApps (apps: AppInstalledPreview[]): void {
|
||||
const currentAppIds = apps.map(a => a.id)
|
||||
const previousAppIds = Object.keys(this.contents)
|
||||
const appsToDelete = diff(previousAppIds, currentAppIds)
|
||||
appsToDelete.map(appId => this.delete(appId))
|
||||
}
|
||||
// private deleteNonexistentApps (apps: AppInstalledPreview[]): void {
|
||||
// const currentAppIds = apps.map(a => a.id)
|
||||
// const previousAppIds = Object.keys(this.contents)
|
||||
// const appsToDelete = diff(previousAppIds, currentAppIds)
|
||||
// appsToDelete.map(appId => this.delete(appId))
|
||||
// }
|
||||
|
||||
// server state change
|
||||
markAppsUnreachable (): void {
|
||||
this.updateAllApps({ status: AppStatus.UNREACHABLE })
|
||||
}
|
||||
// // server state change
|
||||
// markAppsUnreachable (): void {
|
||||
// this.updateAllApps({ status: AppStatus.UNREACHABLE })
|
||||
// }
|
||||
|
||||
markAppsUnknown (): void {
|
||||
this.updateAllApps({ status: AppStatus.UNKNOWN })
|
||||
}
|
||||
// markAppsUnknown (): void {
|
||||
// this.updateAllApps({ status: AppStatus.UNKNOWN })
|
||||
// }
|
||||
|
||||
private updateAllApps (uniformUpdate: Partial<AppInstalledFull>) {
|
||||
this.ids.map(id => {
|
||||
this.update(Object.assign(uniformUpdate, { id }))
|
||||
})
|
||||
}
|
||||
}
|
||||
// private updateAllApps (uniformUpdate: Partial<AppInstalledFull>) {
|
||||
// this.ids.map(id => {
|
||||
// this.update(Object.assign(uniformUpdate, { id }))
|
||||
// })
|
||||
// }
|
||||
// }
|
||||
|
||||
function emptyAppInstalledFull (): Omit<AppInstalledFull, keyof AppInstalledPreview> {
|
||||
return {
|
||||
instructions: null,
|
||||
lastBackup: null,
|
||||
configuredRequirements: null,
|
||||
hasFetchedFull: false,
|
||||
actions: [],
|
||||
}
|
||||
}
|
||||
|
||||
export interface Rules {
|
||||
rule: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export enum AppStatus {
|
||||
// shared
|
||||
UNKNOWN = 'UNKNOWN',
|
||||
UNREACHABLE = 'UNREACHABLE',
|
||||
INSTALLING = 'INSTALLING',
|
||||
NEEDS_CONFIG = 'NEEDS_CONFIG',
|
||||
RUNNING = 'RUNNING',
|
||||
STOPPED = 'STOPPED',
|
||||
CREATING_BACKUP = 'CREATING_BACKUP',
|
||||
RESTORING_BACKUP = 'RESTORING_BACKUP',
|
||||
CRASHED = 'CRASHED',
|
||||
REMOVING = 'REMOVING',
|
||||
DEAD = 'DEAD',
|
||||
BROKEN_DEPENDENCIES = 'BROKEN_DEPENDENCIES',
|
||||
STOPPING = 'STOPPING',
|
||||
RESTARTING = 'RESTARTING',
|
||||
}
|
||||
// function emptyAppInstalledFull (): Omit<AppInstalledFull, keyof AppInstalledPreview> {
|
||||
// return {
|
||||
// instructions: null,
|
||||
// lastBackup: null,
|
||||
// configuredRequirements: null,
|
||||
// hasFetchedFull: false,
|
||||
// actions: [],
|
||||
// }
|
||||
// }
|
||||
|
||||
@@ -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; }
|
||||
@@ -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()
|
||||
}))
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
364
ui/src/app/models/patch-db/data-model.ts
Normal file
364
ui/src/app/models/patch-db/data-model.ts
Normal 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
|
||||
29
ui/src/app/models/patch-db/local-storage-bootstrap.ts
Normal file
29
ui/src/app/models/patch-db/local-storage-bootstrap.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
24
ui/src/app/models/patch-db/patch-db-model.factory.ts
Normal file
24
ui/src/app/models/patch-db/patch-db-model.factory.ts
Normal 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 })
|
||||
}
|
||||
52
ui/src/app/models/patch-db/patch-db-model.ts
Normal file
52
ui/src/app/models/patch-db/patch-db-model.ts
Normal 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')),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -1,52 +1,65 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { EmverComparesPipe, EmverSatisfiesPipe, EmverDisplayPipe, EmverIsValidPipe } from '../pipes/emver.pipe'
|
||||
import { EmverComparesPipe, EmverSatisfiesPipe, EmverDisplayPipe } from '../pipes/emver.pipe'
|
||||
import { IncludesPipe } from '../pipes/includes.pipe'
|
||||
import { IconPipe } from '../pipes/icon.pipe'
|
||||
import { TypeofPipe } from '../pipes/typeof.pipe'
|
||||
import { MarkdownPipe } from '../pipes/markdown.pipe'
|
||||
import { PeekPropertiesPipe } from '../pipes/peek-properties.pipe'
|
||||
import { InstalledLatestComparisonPipe, InstalledViewingComparisonPipe } from '../pipes/installed-latest-comparison.pipe'
|
||||
// import { InstalledLatestComparisonPipe, InstalledViewingComparisonPipe } from '../pipes/installed-latest-comparison.pipe'
|
||||
import { AnnotationStatusPipe } from '../pipes/annotation-status.pipe'
|
||||
import { TruncateCenterPipe, TruncateEndPipe } from '../pipes/truncate.pipe'
|
||||
import { MaskPipe } from '../pipes/mask.pipe'
|
||||
import { DisplayBulbPipe } from '../pipes/display-bulb.pipe'
|
||||
import { HasUiPipe, LaunchablePipe, ManifestPipe } from '../pipes/ui.pipe'
|
||||
import { EmptyPipe } from '../pipes/empty.pipe'
|
||||
import { StatusPipe } from '../pipes/status.pipe'
|
||||
import { NotificationColorPipe } from '../pipes/notification-color.pipe'
|
||||
import { ReactiveComponentModule } from '@ngrx/component'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
EmverComparesPipe,
|
||||
EmverSatisfiesPipe,
|
||||
TypeofPipe,
|
||||
IconPipe,
|
||||
IncludesPipe,
|
||||
MarkdownPipe,
|
||||
PeekPropertiesPipe,
|
||||
InstalledLatestComparisonPipe,
|
||||
InstalledViewingComparisonPipe,
|
||||
// InstalledLatestComparisonPipe,
|
||||
// InstalledViewingComparisonPipe,
|
||||
AnnotationStatusPipe,
|
||||
TruncateCenterPipe,
|
||||
TruncateEndPipe,
|
||||
MaskPipe,
|
||||
DisplayBulbPipe,
|
||||
EmverDisplayPipe,
|
||||
EmverIsValidPipe,
|
||||
HasUiPipe,
|
||||
LaunchablePipe,
|
||||
ManifestPipe,
|
||||
EmptyPipe,
|
||||
StatusPipe,
|
||||
NotificationColorPipe,
|
||||
],
|
||||
imports: [
|
||||
ReactiveComponentModule,
|
||||
],
|
||||
exports: [
|
||||
EmverComparesPipe,
|
||||
EmverSatisfiesPipe,
|
||||
TypeofPipe,
|
||||
IconPipe,
|
||||
IncludesPipe,
|
||||
MarkdownPipe,
|
||||
PeekPropertiesPipe,
|
||||
InstalledLatestComparisonPipe,
|
||||
// InstalledLatestComparisonPipe,
|
||||
// InstalledViewingComparisonPipe,
|
||||
AnnotationStatusPipe,
|
||||
InstalledViewingComparisonPipe,
|
||||
TruncateEndPipe,
|
||||
TruncateCenterPipe,
|
||||
MaskPipe,
|
||||
DisplayBulbPipe,
|
||||
EmverDisplayPipe,
|
||||
EmverIsValidPipe,
|
||||
HasUiPipe,
|
||||
LaunchablePipe,
|
||||
ManifestPipe,
|
||||
EmptyPipe,
|
||||
StatusPipe,
|
||||
NotificationColorPipe,
|
||||
ReactiveComponentModule,
|
||||
],
|
||||
})
|
||||
export class SharingModule { }
|
||||
@@ -8,34 +8,25 @@
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding-top">
|
||||
<ion-spinner *ngIf="$loading$ | async" class="center" name="lines" color="warning"></ion-spinner>
|
||||
<ng-container *ngIf="patch.watch$('package-data', pkgId, 'installed') | ngrxPush as installed">
|
||||
<ng-container *ngIf="installed.manifest as manifest">
|
||||
|
||||
<ng-container *ngIf="!($loading$ | async) && {
|
||||
title: app.title | async,
|
||||
versionInstalled: app.versionInstalled | async,
|
||||
status: app.status | async,
|
||||
actions: app.actions | async
|
||||
} as vars">
|
||||
<ion-item *ngIf="error" style="margin-bottom: 16px;">
|
||||
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
|
||||
</ion-item>
|
||||
|
||||
<!-- no metrics -->
|
||||
<ion-item *ngIf="!vars.actions.length">
|
||||
<ion-label class="ion-text-wrap">
|
||||
<p>No Actions for {{ vars.title }} {{ vars.versionInstalled }}.</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- actions -->
|
||||
<ion-item-group>
|
||||
<ion-item button *ngFor="let action of vars.actions" (click)="handleAction(action)" >
|
||||
<ion-item *ngIf="manifest.actions | empty; else actions">
|
||||
<ion-label class="ion-text-wrap">
|
||||
<h2><ion-text color="primary">{{ action.name }}</ion-text><ion-icon *ngIf="!(action.allowedStatuses | includes: vars.status)" color="danger" name="close-outline"></ion-icon></h2>
|
||||
<p><ion-text color="dark">{{ action.description }}</ion-text></p>
|
||||
<p>No Actions for {{ manifest.title }} {{ manifest.versionInstalled }}.</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
|
||||
|
||||
<ng-template #actions>
|
||||
<ion-item-group>
|
||||
<ion-item button *ngFor="let action of manifest.actions | keyvalue: asIsOrder" (click)="handleAction(installed, action)" >
|
||||
<ion-label class="ion-text-wrap">
|
||||
<h2><ion-text color="primary">{{ action.value.name }}</ion-text><ion-icon *ngIf="!(action.value['allowed-statuses'] | includes: installed.status.main.status)" color="danger" name="close-outline"></ion-icon></h2>
|
||||
<p><ion-text color="dark">{{ action.value.description }}</ion-text></p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ion-content>
|
||||
@@ -1,49 +1,37 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { ApiService, isRpcFailure, isRpcSuccess } from 'src/app/services/api/api.service'
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { AlertController } from '@ionic/angular'
|
||||
import { ModelPreload } from 'src/app/models/model-preload'
|
||||
import { LoaderService, markAsLoadingDuring$ } from 'src/app/services/loader.service'
|
||||
import { ServiceAction, AppInstalledFull } from 'src/app/models/app-types'
|
||||
import { PropertySubject } from 'src/app/util/property-subject.util'
|
||||
import { map } from 'rxjs/operators'
|
||||
import { Cleanup } from 'src/app/util/cleanup'
|
||||
import { AppStatus } from 'src/app/models/app-model'
|
||||
import { LoaderService } from 'src/app/services/loader.service'
|
||||
import { HttpErrorResponse } from '@angular/common/http'
|
||||
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
|
||||
import { Action, InstalledPackageDataEntry, PackageMainStatus } from 'src/app/models/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'app-actions',
|
||||
templateUrl: './app-actions.page.html',
|
||||
styleUrls: ['./app-actions.page.scss'],
|
||||
})
|
||||
export class AppActionsPage extends Cleanup {
|
||||
error = ''
|
||||
$loading$ = new BehaviorSubject(true)
|
||||
appId: string
|
||||
app: PropertySubject<AppInstalledFull>
|
||||
export class AppActionsPage {
|
||||
pkgId: string
|
||||
|
||||
constructor(
|
||||
constructor (
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly preload: ModelPreload,
|
||||
private readonly loaderService: LoaderService,
|
||||
) { super() }
|
||||
public readonly patch: PatchDbModel,
|
||||
) { }
|
||||
|
||||
ngOnInit() {
|
||||
this.appId = this.route.snapshot.paramMap.get('appId')
|
||||
|
||||
markAsLoadingDuring$(this.$loading$, this.preload.appFull(this.appId)).pipe(
|
||||
map(app => this.app = app),
|
||||
).subscribe({ error: e => this.error = e.message })
|
||||
ngOnInit () {
|
||||
this.pkgId = this.route.snapshot.paramMap.get('pkgId')
|
||||
}
|
||||
|
||||
async handleAction(action: ServiceAction) {
|
||||
if (action.allowedStatuses.includes(this.app.status.getValue())) {
|
||||
async handleAction (pkg: InstalledPackageDataEntry, action: { key: string, value: Action }) {
|
||||
if ((action.value['allowed-statuses'] as PackageMainStatus[]).includes(pkg.status.main.status)) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Confirm',
|
||||
message: `Are you sure you want to execute action "${action.name}"? ${action.warning ? action.warning : ""}`,
|
||||
message: `Are you sure you want to execute action "${action.value.name}"? ${action.value.warning || ''}`,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
@@ -52,7 +40,7 @@ export class AppActionsPage extends Cleanup {
|
||||
{
|
||||
text: 'Execute',
|
||||
handler: () => {
|
||||
this.executeAction(action)
|
||||
this.executeAction(pkg.manifest.id, action.key)
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -83,25 +71,19 @@ export class AppActionsPage extends Cleanup {
|
||||
}
|
||||
}
|
||||
|
||||
private async executeAction(action: ServiceAction) {
|
||||
private async executeAction (pkgId: string, actionId: string) {
|
||||
try {
|
||||
const res = await this.loaderService.displayDuringP(
|
||||
this.apiService.serviceAction(this.appId, action),
|
||||
this.apiService.executePackageAction({ id: pkgId, 'action-id': actionId }),
|
||||
)
|
||||
|
||||
if (isRpcFailure(res)) {
|
||||
this.presentAlertActionFail(res.error.code, res.error.message)
|
||||
}
|
||||
|
||||
if (isRpcSuccess(res)) {
|
||||
const successAlert = await this.alertCtrl.create({
|
||||
header: 'Execution Complete',
|
||||
message: res.result.split('\n').join('</br ></br />'),
|
||||
buttons: ['OK'],
|
||||
cssClass: 'alert-success-message',
|
||||
})
|
||||
return await successAlert.present()
|
||||
}
|
||||
const successAlert = await this.alertCtrl.create({
|
||||
header: 'Execution Complete',
|
||||
message: res.message.split('\n').join('</br ></br />'),
|
||||
buttons: ['OK'],
|
||||
cssClass: 'alert-success-message',
|
||||
})
|
||||
return await successAlert.present()
|
||||
} catch (e) {
|
||||
if (e instanceof HttpErrorResponse) {
|
||||
this.presentAlertActionFail(e.status, e.message)
|
||||
@@ -111,7 +93,7 @@ export class AppActionsPage extends Cleanup {
|
||||
}
|
||||
}
|
||||
|
||||
private async presentAlertActionFail(code: number, message: string): Promise<void> {
|
||||
private async presentAlertActionFail (code: number, message: string): Promise<void> {
|
||||
const failureAlert = await this.alertCtrl.create({
|
||||
header: 'Execution Failed',
|
||||
message: `Error code ${code}. ${message}`,
|
||||
|
||||
@@ -4,10 +4,8 @@ import { Routes, RouterModule } from '@angular/router'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { AppAvailableListPage } from './app-available-list.page'
|
||||
import { SharingModule } from '../../../modules/sharing.module'
|
||||
import { PwaBackComponentModule } from 'src/app/components/pwa-back-button/pwa-back.component.module'
|
||||
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
|
||||
import { StatusComponentModule } from 'src/app/components/status/status.component.module'
|
||||
import { UpdateOsBannerComponentModule } from 'src/app/components/update-os-banner/update-os-banner.component.module'
|
||||
|
||||
|
||||
const routes: Routes = [
|
||||
@@ -24,9 +22,7 @@ const routes: Routes = [
|
||||
RouterModule.forChild(routes),
|
||||
StatusComponentModule,
|
||||
SharingModule,
|
||||
PwaBackComponentModule,
|
||||
BadgeMenuComponentModule,
|
||||
UpdateOsBannerComponentModule,
|
||||
],
|
||||
declarations: [AppAvailableListPage],
|
||||
})
|
||||
|
||||
@@ -1,74 +1,87 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Service Marketplace</ion-title>
|
||||
<ion-title>Embassy Marketplace</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<badge-menu-button></badge-menu-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
<update-os-banner></update-os-banner>
|
||||
<ion-toolbar *ngIf="!pageLoading">
|
||||
<ion-searchbar (ionChange)="search($event)" debounce="400"></ion-searchbar>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding-bottom">
|
||||
<ion-refresher slot="fixed" (ionRefresh)="doRefresh($event)">
|
||||
<ion-refresher-content pullingIcon="lines" refreshingSpinner="lines"></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
<ion-content class="ion-padding-top" *ngrxLet="patch.watch$('package-data') as installedPkgs">
|
||||
<ion-spinner *ngIf="pageLoading; else pageLoaded" class="center" name="lines" color="warning"></ion-spinner>
|
||||
|
||||
<ion-spinner *ngIf="$loading$ | async" class="center" name="lines" color="warning"></ion-spinner>
|
||||
|
||||
<ng-container *ngIf="!($loading$ | async)">
|
||||
<ng-template #pageLoaded>
|
||||
|
||||
<ion-item *ngIf="error" style="margin-bottom: 16px;">
|
||||
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
|
||||
</ion-item>
|
||||
|
||||
<ion-card *ngIf="v1Status.status === 'instructions'" [href]="'https://start9.com/eos-' + v1Status.version" target="_blank" class="instructions-card">
|
||||
<ion-card-header>
|
||||
<ion-card-subtitle>Coming Soon...</ion-card-subtitle>
|
||||
<ion-card-title>EmbassyOS Version {{ v1Status.version }}</ion-card-title>
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
<b>Get ready. View the update instructions.</b>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
<div class="scrollable">
|
||||
<ion-button
|
||||
*ngFor="let cat of data.categories"
|
||||
size="small"
|
||||
fill="clear"
|
||||
[color]="cat === category ? 'success' : 'dark'"
|
||||
(click)="switchCategory(cat)"
|
||||
>
|
||||
{{ cat }}
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<ion-card *ngIf="v1Status.status === 'available'" [href]="'https://start9.com/eos-' + v1Status.version" target="_blank" class="available-card">
|
||||
<ion-card *ngIf="eos && category === 'featured'" class="eos-card" (click)="updateEos()">
|
||||
<ion-card-header>
|
||||
<ion-card-subtitle>Now Available...</ion-card-subtitle>
|
||||
<ion-card-title>EmbassyOS Version {{ v1Status.version }}</ion-card-title>
|
||||
<ion-card-title>EmbassyOS Version {{ eos.version }}</ion-card-title>
|
||||
</ion-card-header>
|
||||
<ion-card-content>
|
||||
<b>View the update instructions.</b>
|
||||
{{ eos.headline }}
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
|
||||
<ion-card *ngFor="let app of apps" style="margin: 10px 10px;" [routerLink]="[app.id]">
|
||||
<ion-item style="--inner-border-width: 0 0 .4px 0; --border-color: #525252;" *ngIf="{ installing: (app.subject.status | async) === 'INSTALLING', installComparison: app.subject | compareInstalledAndLatest | async } as l">
|
||||
<ion-avatar style="margin-top: 8px;" slot="start">
|
||||
<img [src]="app.subject.iconURL | async | iconParse" />
|
||||
</ion-avatar>
|
||||
<ion-label style="margin-top: 6px; margin-bottom: 3px">
|
||||
<h1 style="font-family: 'Montserrat'; font-size: 20px; margin-bottom: 0px;">
|
||||
{{app.subject.title | async}}
|
||||
</h1>
|
||||
<div *ngIf="!l.installing && l.installComparison === 'installed-equal'" class="beneath-title">
|
||||
<ion-text style="font-size: 12px;" color="success">Installed</ion-text>
|
||||
</div>
|
||||
<div *ngIf="!l.installing && l.installComparison === 'installed-below'" class="beneath-title">
|
||||
<ion-text style="font-size: 12px;" color="warning">Update Available</ion-text>
|
||||
</div>
|
||||
<div *ngIf="l.installing" class="beneath-title" style="display: flex; flex-direction: row; align-items: center;">
|
||||
<ion-text style="font-size: 12px;" color="primary">Installing</ion-text>
|
||||
<ion-spinner name="crescent" style="height: 10px; width: 15px; margin-left: 3px; margin-right: -4px;" color="primary"></ion-spinner>
|
||||
</div>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-card-content style="
|
||||
font-size: small !important;
|
||||
padding-bottom: 10px;
|
||||
padding-top: 6px;
|
||||
">
|
||||
{{ app.subject.descriptionShort | async }}
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ng-container>
|
||||
<ion-spinner *ngIf="pkgsLoading; else pkgsLoaded" class="center" name="lines" color="warning"></ion-spinner>
|
||||
|
||||
<ng-template #pkgsLoaded>
|
||||
<ion-card *ngFor="let pkg of pkgs" style="margin: 10px 10px;" [routerLink]="[pkg.id]">
|
||||
<ion-item style="--inner-border-width: 0 0 .4px 0; --border-color: #525252;">
|
||||
<ion-avatar style="margin-top: 8px;" slot="start">
|
||||
<img [src]="pkg.icon" />
|
||||
</ion-avatar>
|
||||
<ion-label style="margin-top: 6px; margin-bottom: 3px">
|
||||
<h1 style="font-family: 'Montserrat'; font-size: 20px; margin-bottom: 0px;">
|
||||
{{ pkg.title }}
|
||||
</h1>
|
||||
<p>{{ pkg.version }}</p>
|
||||
<div class="beneath-title" *ngIf="installedPkgs[pkg.id] as pkgI">
|
||||
<ng-container *ngIf="pkgI.state === PackageState.Installed">
|
||||
<ion-text *ngIf="(pkg.version | compareEmver : pkgI.installed.manifest.version) === 0" style="font-size: 12px;" color="success">Installed</ion-text>
|
||||
<ion-text *ngIf="(pkg.version | compareEmver : pkgI.installed.manifest.version) === 1" style="font-size: 12px;" color="warning">Update Available</ion-text>
|
||||
</ng-container>
|
||||
<div class="beneath-title" *ngIf="pkgI.state === PackageState.Installing" style="display: flex; flex-direction: row; align-items: center;">
|
||||
<ion-text style="font-size: 12px;" color="primary">Installing</ion-text>
|
||||
<ion-spinner name="crescent" style="height: 10px; width: 15px; margin-left: 3px; margin-right: -4px;" color="primary"></ion-spinner>
|
||||
</div>
|
||||
<div class="beneath-title" *ngIf="pkgI.state === PackageState.Updating" style="display: flex; flex-direction: row; align-items: center;">
|
||||
<ion-text style="font-size: 12px;" color="primary">Updating</ion-text>
|
||||
<ion-spinner name="crescent" style="height: 10px; width: 15px; margin-left: 3px; margin-right: -4px;" color="primary"></ion-spinner>
|
||||
</div>
|
||||
<div class="beneath-title" *ngIf="pkgI.state === PackageState.Removing" style="display: flex; flex-direction: row; align-items: center;">
|
||||
<ion-text style="font-size: 12px;" color="danger">Removing</ion-text>
|
||||
<ion-spinner name="crescent" style="height: 10px; width: 15px; margin-left: 3px; margin-right: -4px;" color="danger"></ion-spinner>
|
||||
</div>
|
||||
</div>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-card-content style="
|
||||
font-size: small !important;
|
||||
padding-bottom: 10px;
|
||||
padding-top: 6px;
|
||||
">
|
||||
{{ pkg.descriptionShort }}
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
</ion-content>
|
||||
|
||||
@@ -5,12 +5,24 @@
|
||||
padding: 1px 0px 1.5px 0px;
|
||||
}
|
||||
|
||||
.instructions-card {
|
||||
--background: linear-gradient(45deg, #101010 16%, var(--ion-color-medium) 150%);
|
||||
margin: 16px 10px;
|
||||
.scrollable {
|
||||
overflow: auto;
|
||||
white-space: nowrap;
|
||||
background-color: var(--ion-color-light);
|
||||
margin-bottom: 16px;
|
||||
|
||||
/* Hide scrollbar for Chrome, Safari and Opera */
|
||||
::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Hide scrollbar for IE, Edge and Firefox */
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
|
||||
.available-card {
|
||||
.eos-card {
|
||||
--background: linear-gradient(45deg, #101010 16%, var(--ion-color-danger) 150%);
|
||||
margin: 16px 10px;
|
||||
}
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,11 @@
|
||||
import { Component, NgZone } from '@angular/core'
|
||||
import { Component } from '@angular/core'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { AppModel } from 'src/app/models/app-model'
|
||||
import { AppAvailablePreview, AppInstalledPreview } from 'src/app/models/app-types'
|
||||
import { pauseFor } from 'src/app/util/misc.util'
|
||||
import { PropertySubjectId, initPropertySubject } from 'src/app/util/property-subject.util'
|
||||
import { Subscription, BehaviorSubject, combineLatest } from 'rxjs'
|
||||
import { take } from 'rxjs/operators'
|
||||
import { markAsLoadingDuringP } from 'src/app/services/loader.service'
|
||||
import { OsUpdateService } from 'src/app/services/os-update.service'
|
||||
import { V1Status } from 'src/app/services/api/api-types'
|
||||
import { MarketplaceData, MarketplaceEOS, AvailablePreview } from 'src/app/services/api/api-types'
|
||||
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
|
||||
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
|
||||
import { PackageState } from 'src/app/models/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'app-available-list',
|
||||
@@ -16,83 +13,93 @@ import { V1Status } from 'src/app/services/api/api-types'
|
||||
styleUrls: ['./app-available-list.page.scss'],
|
||||
})
|
||||
export class AppAvailableListPage {
|
||||
$loading$ = new BehaviorSubject(true)
|
||||
pageLoading = true
|
||||
pkgsLoading = true
|
||||
error = ''
|
||||
installedAppDeltaSubscription: Subscription
|
||||
apps: PropertySubjectId<AppAvailablePreview>[] = []
|
||||
appsInstalled: PropertySubjectId<AppInstalledPreview>[] = []
|
||||
v1Status: V1Status = { status: 'nothing', version: '' }
|
||||
|
||||
category = 'featured'
|
||||
query: string
|
||||
|
||||
data: MarketplaceData
|
||||
eos: MarketplaceEOS
|
||||
pkgs: AvailablePreview[] = []
|
||||
|
||||
PackageState = PackageState
|
||||
|
||||
page = 1
|
||||
needInfinite = false
|
||||
readonly perPage = 20
|
||||
|
||||
constructor (
|
||||
private readonly apiService: ApiService,
|
||||
private readonly appModel: AppModel,
|
||||
private readonly zone: NgZone,
|
||||
private readonly osUpdateService: OsUpdateService,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly wizardBaker: WizardBaker,
|
||||
public patch: PatchDbModel,
|
||||
) { }
|
||||
|
||||
async ngOnInit () {
|
||||
this.installedAppDeltaSubscription = this.appModel
|
||||
.watchDelta('update')
|
||||
.subscribe(({ id }) => this.mergeInstalledProps(id))
|
||||
|
||||
markAsLoadingDuringP(this.$loading$, Promise.all([
|
||||
this.getApps(),
|
||||
this.checkV1Status(),
|
||||
this.osUpdateService.checkWhenNotAvailable$().toPromise(), // checks for an os update, banner component renders conditionally
|
||||
pauseFor(600),
|
||||
]))
|
||||
}
|
||||
|
||||
ionViewDidEnter () {
|
||||
this.appModel.getContents().forEach(appInstalled => this.mergeInstalledProps(appInstalled.id))
|
||||
}
|
||||
|
||||
async checkV1Status () {
|
||||
try {
|
||||
this.v1Status = await this.apiService.checkV1Status()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
mergeInstalledProps (appInstalledId: string) {
|
||||
const appAvailable = this.apps.find(app => app.id === appInstalledId)
|
||||
if (!appAvailable) return
|
||||
|
||||
const app = this.appModel.watch(appInstalledId)
|
||||
combineLatest([app.status, app.versionInstalled])
|
||||
.pipe(take(1))
|
||||
.subscribe(([status, versionInstalled]) => {
|
||||
this.zone.run(() => {
|
||||
appAvailable.subject.status.next(status)
|
||||
appAvailable.subject.versionInstalled.next(versionInstalled)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy () {
|
||||
this.installedAppDeltaSubscription.unsubscribe()
|
||||
}
|
||||
|
||||
async doRefresh (e: any) {
|
||||
await Promise.all([
|
||||
this.getApps(),
|
||||
pauseFor(600),
|
||||
])
|
||||
e.target.complete()
|
||||
}
|
||||
|
||||
async getApps (): Promise<void> {
|
||||
try {
|
||||
this.apps = await this.apiService.getAvailableApps().then(apps =>
|
||||
apps
|
||||
.sort( (a1, a2) => a2.latestVersionTimestamp.getTime() - a1.latestVersionTimestamp.getTime())
|
||||
.map(a => ({ id: a.id, subject: initPropertySubject(a) })),
|
||||
)
|
||||
this.appModel.getContents().forEach(appInstalled => this.mergeInstalledProps(appInstalled.id))
|
||||
const [data, eos, pkgs] = await Promise.all([
|
||||
this.apiService.getMarketplaceData({ }),
|
||||
this.apiService.getEos({ }),
|
||||
this.getPkgs(),
|
||||
])
|
||||
this.data = data
|
||||
this.eos = eos
|
||||
this.pkgs = pkgs
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
this.error = e.message
|
||||
} finally {
|
||||
this.pageLoading = false
|
||||
this.pkgsLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
async doInfinite (e: any): Promise<void> {
|
||||
const pkgs = await this.getPkgs()
|
||||
this.pkgs = this.pkgs.concat(pkgs)
|
||||
e.target.complete()
|
||||
}
|
||||
|
||||
async search (e?: any): Promise<void> {
|
||||
this.query = e.target.value || undefined
|
||||
this.page = 1
|
||||
this.pkgs = await this.getPkgs()
|
||||
}
|
||||
|
||||
async updateEos (): Promise<void> {
|
||||
await wizardModal(
|
||||
this.modalCtrl,
|
||||
this.wizardBaker.updateOS({
|
||||
version: this.eos.version,
|
||||
releaseNotes: this.eos.notes,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
private async getPkgs (): Promise<AvailablePreview[]> {
|
||||
this.pkgsLoading = true
|
||||
try {
|
||||
const pkgs = await this.apiService.getAvailableList({
|
||||
category: this.category,
|
||||
query: this.query,
|
||||
page: this.page,
|
||||
'per-page': this.perPage,
|
||||
})
|
||||
this.needInfinite = pkgs.length >= this.perPage
|
||||
this.page++
|
||||
return pkgs
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
this.error = e.message
|
||||
} finally {
|
||||
this.pkgsLoading = false
|
||||
}
|
||||
}
|
||||
|
||||
async switchCategory (category: string): Promise<void> {
|
||||
this.category = category
|
||||
this.pkgs = await this.getPkgs()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Routes, RouterModule } from '@angular/router'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { DependencyListComponentModule } from '../../../components/dependency-list/dependency-list.component.module'
|
||||
import { AppAvailableShowPage } from './app-available-show.page'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
import { PwaBackComponentModule } from 'src/app/components/pwa-back-button/pwa-back.component.module'
|
||||
@@ -10,7 +9,6 @@ import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/b
|
||||
import { StatusComponentModule } from 'src/app/components/status/status.component.module'
|
||||
import { RecommendationButtonComponentModule } from 'src/app/components/recommendation-button/recommendation-button.component.module'
|
||||
import { InstallWizardComponentModule } from 'src/app/components/install-wizard/install-wizard.component.module'
|
||||
import { ErrorMessageComponentModule } from 'src/app/components/error-message/error-message.component.module'
|
||||
import { InformationPopoverComponentModule } from 'src/app/components/information-popover/information-popover.component.module'
|
||||
|
||||
const routes: Routes = [
|
||||
@@ -25,14 +23,12 @@ const routes: Routes = [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
StatusComponentModule,
|
||||
DependencyListComponentModule,
|
||||
RouterModule.forChild(routes),
|
||||
SharingModule,
|
||||
PwaBackComponentModule,
|
||||
RecommendationButtonComponentModule,
|
||||
BadgeMenuComponentModule,
|
||||
InstallWizardComponentModule,
|
||||
ErrorMessageComponentModule,
|
||||
InformationPopoverComponentModule,
|
||||
],
|
||||
declarations: [AppAvailableShowPage],
|
||||
|
||||
@@ -10,109 +10,140 @@
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding-bottom" *ngIf="{
|
||||
id: $app$.id | async,
|
||||
status: $app$.status | async,
|
||||
title: $app$.title | async,
|
||||
versionInstalled: $app$.versionInstalled | async,
|
||||
versionViewing: $app$.versionViewing | async,
|
||||
descriptionLong: $app$.descriptionLong | async,
|
||||
licenseName: $app$.licenseName | async,
|
||||
licenseLink: $app$.licenseLink | async,
|
||||
serviceRequirements: $app$.serviceRequirements | async,
|
||||
iconURL: $app$.iconURL | async,
|
||||
releaseNotes: $app$.releaseNotes | async
|
||||
} as vars"
|
||||
>
|
||||
<ion-spinner *ngIf="($loading$ | async)" class="center" name="lines" color="warning"></ion-spinner>
|
||||
<ion-content class="ion-padding-bottom">
|
||||
|
||||
<error-message [$error$]="$error$" [dismissable]="vars.id"></error-message>
|
||||
<ion-spinner *ngIf="loading; else loaded" class="center" name="lines" color="warning"></ion-spinner>
|
||||
|
||||
<ng-container *ngIf="!($loading$ | async) && vars.id && ($app$ | compareInstalledAndViewing | async) as installedStatus">
|
||||
<ion-item-group>
|
||||
<ion-item lines="none">
|
||||
<ion-avatar slot="start">
|
||||
<img [src]="vars.iconURL | iconParse" />
|
||||
</ion-avatar>
|
||||
<ion-label class="ion-text-wrap">
|
||||
<h1 style="font-family: 'Montserrat'">{{ vars.title }}</h1>
|
||||
<h3>{{ vars.versionViewing | displayEmver }}</h3>
|
||||
<ng-container *ngIf="vars.status !== 'INSTALLING'">
|
||||
<h3 *ngIf="installedStatus === 'installed-equal'"><ion-text color="medium">Installed</ion-text></h3>
|
||||
<h3 *ngIf="installedStatus === 'installed-below' || installedStatus === 'installed-above'"><ion-text color="medium">Installed </ion-text><ion-text style="font-size: small" color="medium"> at {{vars.versionInstalled | displayEmver}}</ion-text></h3>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="vars.status === 'INSTALLING'">
|
||||
<h3>
|
||||
<status appStatus="INSTALLING" [text]="' (' + (vars.versionInstalled | displayEmver) + ')'" size="medium"></status>
|
||||
</h3>
|
||||
</ng-container>
|
||||
</ion-label>
|
||||
<ng-template #loaded>
|
||||
<ion-item *ngIf="error" style="margin-bottom: 16px;">
|
||||
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
|
||||
</ion-item>
|
||||
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
|
||||
<ion-button *ngIf="!vars.versionInstalled" class="main-action-button" expand="block" fill="outline" color="success" (click)="install()">
|
||||
Install
|
||||
</ion-button>
|
||||
|
||||
<div *ngIf="vars.versionInstalled">
|
||||
<ion-button class="main-action-button" expand="block" fill="outline" [routerLink]="['/services', 'installed', vars.id]">
|
||||
Go to Service
|
||||
</ion-button>
|
||||
<div *ngIf="vars.status !== 'INSTALLING' ">
|
||||
<ion-button *ngIf="installedStatus === 'installed-below'" class="main-action-button" expand="block" fill="outline" color="success" (click)="update('update')">
|
||||
Update to {{ vars.versionViewing | displayEmver }}
|
||||
</ion-button>
|
||||
<ion-button *ngIf="installedStatus === 'installed-above'" class="main-action-button" expand="block" fill="outline" color="warning" (click)="update('downgrade')">
|
||||
Downgrade to {{ vars.versionViewing | displayEmver }}
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ion-item-group>
|
||||
<ng-container *ngIf="recommendation">
|
||||
<ion-item class="recommendation-item">
|
||||
<ng-container *ngrxLet="patch.watch$('package-data', pkgId) as localPkg">
|
||||
<ion-item-group>
|
||||
<ion-item lines="none">
|
||||
<ion-avatar slot="start">
|
||||
<img [src]="pkg.icon" />
|
||||
</ion-avatar>
|
||||
<ion-label class="ion-text-wrap">
|
||||
<h2 style="display: flex; align-items: center;">
|
||||
<ion-avatar style="height: 3vh; width: 3vh; margin: 5px" slot="start">
|
||||
<img [src]="recommendation.iconURL | iconParse" [alt]="recommendation.title"/>
|
||||
</ion-avatar>
|
||||
<ion-text style="margin: 5px; font-family: 'Montserrat'; font-size: smaller;">{{recommendation.title}}</ion-text>
|
||||
</h2>
|
||||
<div style="margin: 2px 5px">
|
||||
<p style="color: var(--ion-color-medium); font-size: small">{{recommendation.description}}</p>
|
||||
<p *ngIf="vars.versionViewing | satisfiesEmver: recommendation.versionSpec" class="recommendation-text">{{vars.title}} version {{vars.versionViewing | displayEmver}} is compatible.</p>
|
||||
<p *ngIf="!(vars.versionViewing | satisfiesEmver: recommendation.versionSpec)" class="recommendation-text recommendation-error">{{vars.title}} version {{vars.versionViewing | displayEmver}} is NOT compatible.</p>
|
||||
</div>
|
||||
<h1 style="font-family: 'Montserrat'">{{ pkg.manifest.title }}</h1>
|
||||
<h3>{{ pkg.manifest.version | displayEmver }}</h3>
|
||||
<!-- no localPkg -->
|
||||
<h3 *ngIf="!localPkg; else local">
|
||||
<ion-text color="medium">Not Installed</ion-text>
|
||||
</h3>
|
||||
<!-- localPkg -->
|
||||
<ng-template #local>
|
||||
<h3 *ngIf="localPkg.state !== PackageState.Installed; else installed">
|
||||
<!-- installing, updating, removing -->
|
||||
<ion-text [color]="localPkg.state === PackageState.Removing ? 'danger' : 'primary'">{{ localPkg.state }}</ion-text>
|
||||
<ion-spinner class="dots dots-medium" name="dots" [color]="localPkg.state === PackageState.Removing ? 'danger' : 'primary'"></ion-spinner>
|
||||
</h3>
|
||||
<!-- installed -->
|
||||
<ng-template #installed>
|
||||
<h3>
|
||||
<ion-text color="medium">Installed at {{ localPkg.installed.manifest.version | displayEmver }}</ion-text>
|
||||
</h3>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
</ion-item-group>
|
||||
|
||||
<!-- no localPkg -->
|
||||
<ion-button *ngIf="!localPkg; else localPkg2" class="main-action-button" expand="block" fill="outline" color="success" (click)="install()">
|
||||
Install
|
||||
</ion-button>
|
||||
|
||||
<!-- localPkg -->
|
||||
<ng-template #localPkg2>
|
||||
<!-- not removing -->
|
||||
<ng-container *ngIf="localPkg.state !== PackageState.Removing">
|
||||
<ion-button class="main-action-button" expand="block" fill="outline" [routerLink]="['/services', 'installed', pkgId]">
|
||||
Go to Service
|
||||
</ion-button>
|
||||
<!-- not installing or updating -->
|
||||
<ng-container *ngIf="localPkg.state === PackageState.Installed">
|
||||
<ion-button *ngIf="(localPkg.installed.manifest.version | compareEmver : pkg.manifest.version) === -1" class="main-action-button" expand="block" fill="outline" color="success" (click)="update('update')">
|
||||
Update to {{ pkg.manifest.version | displayEmver }}
|
||||
</ion-button>
|
||||
<ion-button *ngIf="(localPkg.installed.manifest.version | compareEmver : pkg.manifest.version) === 1" class="main-action-button" expand="block" fill="outline" color="warning" (click)="update('downgrade')">
|
||||
Downgrade to {{ pkg.manifest.version | displayEmver }}
|
||||
</ion-button>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
<ion-item-divider style="color: var(--ion-color-dark); font-weight: bold;">New in {{ vars.versionViewing | displayEmver }}</ion-item-divider>
|
||||
<!-- recommendation -->
|
||||
<ion-item *ngIf="rec && showRec" class="rec-item">
|
||||
<ion-label class="ion-text-wrap">
|
||||
<h2 style="display: flex; align-items: center;">
|
||||
<ion-avatar style="height: 3vh; width: 3vh; margin: 5px" slot="start">
|
||||
<img [src]="rec.dependentIcon" [alt]="rec.dependentTitle"/>
|
||||
</ion-avatar>
|
||||
<ion-text style="margin: 5px; font-family: 'Montserrat'; font-size: smaller;">{{ rec.dependentTitle }}</ion-text>
|
||||
</h2>
|
||||
<div style="margin: 7px 5px;">
|
||||
<p style="color: var(--ion-color-dark); font-size: small">{{ rec.description }}</p>
|
||||
<p *ngIf="pkg.manifest.version | satisfiesEmver: rec.version" class="recommendation-text">{{ pkg.manifest.title }} version {{ pkg.manifest.version | displayEmver }} is compatible.</p>
|
||||
<p *ngIf="!(pkg.manifest.version | satisfiesEmver: rec.version)" class="recommendation-text recommendation-error">{{ pkg.manifest.title }} version {{ pkg.manifest.version | displayEmver }} is NOT compatible.</p>
|
||||
<ion-button style="position: absolute; right: 0; top: 0" color="primary" fill="clear" (click)="dismissRec()">
|
||||
<ion-icon name="close-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item-group>
|
||||
<!-- release notes -->
|
||||
<ion-item-divider style="color: var(--ion-color-dark); font-weight: bold;">New in {{ pkg.manifest.version | displayEmver }}</ion-item-divider>
|
||||
<ion-item lines="none">
|
||||
<ion-label *ngIf="!($newVersionLoading$ | async)" style="display: flex; align-items: center; justify-content: space-between;" class="ion-text-wrap" >
|
||||
<div id='release-notes'color="dark" [innerHTML]="vars.releaseNotes | markdown"></div>
|
||||
<ion-label style="display: flex; align-items: center; justify-content: space-between;" class="ion-text-wrap" >
|
||||
<div id='release-notes' color="dark" [innerHTML]="pkg.manifest['release-notes'] | markdown"></div>
|
||||
</ion-label>
|
||||
<ion-spinner style="display: block; margin: auto;" name="lines" color="dark" *ngIf="$newVersionLoading$ | async"></ion-spinner>
|
||||
</ion-item>
|
||||
|
||||
<ion-item-divider class="divider">Description</ion-item-divider>
|
||||
<!-- description -->
|
||||
<ion-item-divider class="divider">
|
||||
<ion-text color="dark">Description</ion-text>
|
||||
</ion-item-divider>
|
||||
<ion-item lines="none">
|
||||
<ion-label class="ion-text-wrap">
|
||||
<ion-text color="medium">
|
||||
<h5>{{ vars.descriptionLong }}</h5>
|
||||
<ion-text color="dark">
|
||||
<h5>{{ pkg.manifest.description.long }}</h5>
|
||||
</ion-text>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ng-container *ngIf="(vars.serviceRequirements)?.length">
|
||||
<ion-item-divider class="divider">Service Dependencies
|
||||
<ion-button style="position: relative; right: 10px;" size="small" fill="clear" color="medium" (click)="presentPopover(serviceDependencyDefintion, $event)">
|
||||
<!-- dependencies -->
|
||||
<ng-container *ngIf="!(pkg.manifest.dependencies | empty)">
|
||||
<ion-item-divider class="divider">
|
||||
<ion-text color="dark">Service Dependencies</ion-text>
|
||||
<ion-button style="position: relative; right: 10px;" size="small" fill="clear" color="dark" (click)="presentPopover(depDefintion, $event)">
|
||||
<ion-icon name="help-circle-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item-divider>
|
||||
<dependency-list [$loading$]="$dependenciesLoading$" [hostApp]="$app$ | peekProperties" [dependencies]="vars.serviceRequirements"></dependency-list>
|
||||
|
||||
<div *ngFor="let dep of pkg.manifest.dependencies | keyvalue">
|
||||
<ion-item *ngIf="!dep.value.optional" class="dependency-item">
|
||||
<ion-avatar slot="start">
|
||||
<img [src]="pkg['dependency-metadata'][dep.key].icon" />
|
||||
</ion-avatar>
|
||||
<ion-label class="ion-text-wrap" style="padding: 1vh; padding-left: 2vh">
|
||||
<h4 style="font-family: 'Montserrat'">
|
||||
{{ pkg['dependency-metadata'][dep.key].title }}
|
||||
<span *ngIf="dep.value.recommended" style="font-family: 'Open Sans'; font-size: small; color: var(--ion-color-dark)"> (recommended)</span>
|
||||
</h4>
|
||||
<p style="font-size: small">{{ dep.value.version | displayEmver }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item style="margin-bottom: 10px" *ngIf="dep.value.description" lines="none">
|
||||
<div style="font-size: small; color: var(--ion-color-dark)" [innerHtml]="dep.value.description"></div>
|
||||
</ion-item>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- versions -->
|
||||
<ion-item-divider></ion-item-divider>
|
||||
<ion-item *ngIf="vars.licenseLink" lines="none" button [href]="vars.licenseLink" target="_blank">
|
||||
<ion-icon slot="start" name="newspaper-outline"></ion-icon>
|
||||
@@ -120,9 +151,10 @@
|
||||
<ion-note slot="end">{{ vars.licenseName }}</ion-note>
|
||||
</ion-item>
|
||||
<ion-item lines="none" button (click)="presentAlertVersions()">
|
||||
<ion-icon slot="start" name="file-tray-stacked-outline"></ion-icon>
|
||||
<ion-label>Other versions</ion-label>
|
||||
<ion-icon color="dark" slot="start" name="file-tray-stacked-outline"></ion-icon>
|
||||
<ion-label color="dark">Other versions</ion-label>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ng-container>
|
||||
|
||||
</ng-template>
|
||||
</ion-content>
|
||||
@@ -1,81 +1,64 @@
|
||||
import { Component, NgZone } from '@angular/core'
|
||||
import { Component } from '@angular/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { AppAvailableFull, AppAvailableVersionSpecificInfo } from 'src/app/models/app-types'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { AlertController, ModalController, NavController, PopoverController } from '@ionic/angular'
|
||||
import { markAsLoadingDuring$ } from 'src/app/services/loader.service'
|
||||
import { BehaviorSubject, from, Observable, of } from 'rxjs'
|
||||
import { catchError, concatMap, filter, switchMap, tap } from 'rxjs/operators'
|
||||
import { Recommendation } from 'src/app/components/recommendation-button/recommendation-button.component'
|
||||
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
|
||||
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
|
||||
import { AppModel } from 'src/app/models/app-model'
|
||||
import { initPropertySubject, peekProperties, PropertySubject } from 'src/app/util/property-subject.util'
|
||||
import { Cleanup } from 'src/app/util/cleanup'
|
||||
import { InformationPopoverComponent } from 'src/app/components/information-popover/information-popover.component'
|
||||
import { Emver } from 'src/app/services/emver.service'
|
||||
import { displayEmver } from 'src/app/pipes/emver.pipe'
|
||||
import { pauseFor } from 'src/app/util/misc.util'
|
||||
import { AvailableShow } from 'src/app/services/api/api-types'
|
||||
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
|
||||
import { PackageState } from 'src/app/models/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'app-available-show',
|
||||
templateUrl: './app-available-show.page.html',
|
||||
styleUrls: ['./app-available-show.page.scss'],
|
||||
})
|
||||
export class AppAvailableShowPage extends Cleanup {
|
||||
$loading$ = new BehaviorSubject(true)
|
||||
export class AppAvailableShowPage {
|
||||
loading = true
|
||||
error = ''
|
||||
pkg: AvailableShow
|
||||
pkgId: string
|
||||
|
||||
// When a new version is selected
|
||||
$newVersionLoading$ = new BehaviorSubject(false)
|
||||
// When dependencies are refreshing
|
||||
$dependenciesLoading$ = new BehaviorSubject(false)
|
||||
PackageState = PackageState
|
||||
|
||||
$error$ = new BehaviorSubject(undefined)
|
||||
$app$: PropertySubject<AppAvailableFull> = { } as any
|
||||
appId: string
|
||||
rec: Recommendation | null = null
|
||||
showRec = true
|
||||
|
||||
openRecommendation = false
|
||||
recommendation: Recommendation | null = null
|
||||
|
||||
serviceDependencyDefintion = '<span style="font-style: italic">Service Dependencies</span> are other services that this service recommends or requires in order to run.'
|
||||
depDefinition = '<span style="font-style: italic">Service Dependencies</span> are other services that this service recommends or requires in order to run.'
|
||||
|
||||
constructor (
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly zone: NgZone,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly wizardBaker: WizardBaker,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly appModel: AppModel,
|
||||
private readonly popoverController: PopoverController,
|
||||
private readonly emver: Emver,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
public readonly patch: PatchDbModel,
|
||||
) { }
|
||||
|
||||
async ngOnInit () {
|
||||
this.appId = this.route.snapshot.paramMap.get('appId') as string
|
||||
|
||||
this.cleanup(
|
||||
// new version always includes dependencies, but not vice versa
|
||||
this.$newVersionLoading$.subscribe(this.$dependenciesLoading$),
|
||||
markAsLoadingDuring$(this.$loading$,
|
||||
from(this.apiService.getAvailableApp(this.appId)).pipe(
|
||||
tap(app => this.$app$ = initPropertySubject(app)),
|
||||
concatMap(() => this.fetchRecommendation()),
|
||||
),
|
||||
).pipe(
|
||||
concatMap(() => this.syncWhenDependencyInstalls()), //must be final in stack
|
||||
catchError(e => of(this.setError(e))),
|
||||
).subscribe(),
|
||||
)
|
||||
this.pkgId = this.route.snapshot.paramMap.get('pkgId') as string
|
||||
this.rec = history.state && history.state.installRec as Recommendation
|
||||
this.getPkg()
|
||||
}
|
||||
|
||||
ionViewDidEnter () {
|
||||
markAsLoadingDuring$(this.$dependenciesLoading$, this.syncVersionSpecificInfo()).subscribe({
|
||||
error: e => this.setError(e),
|
||||
})
|
||||
async getPkg (version?: string): Promise<void> {
|
||||
this.loading = true
|
||||
try {
|
||||
this.pkg = await this.apiService.getAvailableShow({ id: this.pkgId, version })
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
this.error = e.message
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async presentPopover (information: string, ev: any) {
|
||||
@@ -92,34 +75,17 @@ export class AppAvailableShowPage extends Cleanup {
|
||||
return await popover.present()
|
||||
}
|
||||
|
||||
syncVersionSpecificInfo (versionSpec?: string): Observable<any> {
|
||||
if (!this.$app$.versionViewing) return of({ })
|
||||
const specToFetch = versionSpec || `=${this.$app$.versionViewing.getValue()}`
|
||||
return from(this.apiService.getAvailableAppVersionSpecificInfo(this.appId, specToFetch)).pipe(
|
||||
tap(versionInfo => this.mergeInfo(versionInfo)),
|
||||
)
|
||||
}
|
||||
|
||||
private mergeInfo (versionSpecificInfo: AppAvailableVersionSpecificInfo) {
|
||||
this.zone.run(() => {
|
||||
Object.entries(versionSpecificInfo).forEach( ([k, v]) => {
|
||||
if (!this.$app$[k]) this.$app$[k] = new BehaviorSubject(undefined)
|
||||
if (v !== this.$app$[k].getValue()) this.$app$[k].next(v)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async presentAlertVersions () {
|
||||
const app = peekProperties(this.$app$)
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Versions',
|
||||
backdropDismiss: false,
|
||||
inputs: app.versions.sort((a, b) => -1 * this.emver.compare(a, b)).map(v => {
|
||||
return { name: v, // for CSS
|
||||
inputs: this.pkg.versions.sort((a, b) => -1 * this.emver.compare(a, b)).map(v => {
|
||||
return {
|
||||
name: v, // for CSS
|
||||
type: 'radio',
|
||||
label: displayEmver(v), // appearance on screen
|
||||
value: v, // literal SEM version value
|
||||
checked: app.versionViewing === v,
|
||||
checked: this.pkg.manifest.version === v,
|
||||
}
|
||||
}),
|
||||
buttons: [
|
||||
@@ -129,17 +95,7 @@ export class AppAvailableShowPage extends Cleanup {
|
||||
}, {
|
||||
text: 'Ok',
|
||||
handler: (version: string) => {
|
||||
const previousVersion = this.$app$.versionViewing.getValue()
|
||||
this.$app$.versionViewing.next(version)
|
||||
markAsLoadingDuring$(
|
||||
this.$newVersionLoading$, this.syncVersionSpecificInfo(`=${version}`),
|
||||
)
|
||||
.subscribe({
|
||||
error: e => {
|
||||
this.setError(e)
|
||||
this.$app$.versionViewing.next(previousVersion)
|
||||
},
|
||||
})
|
||||
this.getPkg(version)
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -149,15 +105,14 @@ export class AppAvailableShowPage extends Cleanup {
|
||||
}
|
||||
|
||||
async install () {
|
||||
const app = peekProperties(this.$app$)
|
||||
const { id, title, version, dependencies, alerts } = this.pkg.manifest
|
||||
const { cancelled } = await wizardModal(
|
||||
this.modalCtrl,
|
||||
this.wizardBaker.install({
|
||||
id: app.id,
|
||||
title: app.title,
|
||||
version: app.versionViewing,
|
||||
serviceRequirements: app.serviceRequirements,
|
||||
installAlert: app.installAlert,
|
||||
id,
|
||||
title,
|
||||
version,
|
||||
installAlert: alerts.install,
|
||||
}),
|
||||
)
|
||||
if (cancelled) return
|
||||
@@ -166,14 +121,13 @@ export class AppAvailableShowPage extends Cleanup {
|
||||
}
|
||||
|
||||
async update (action: 'update' | 'downgrade') {
|
||||
const app = peekProperties(this.$app$)
|
||||
|
||||
const { id, title, version, dependencies, alerts } = this.pkg.manifest
|
||||
const value = {
|
||||
id: app.id,
|
||||
title: app.title,
|
||||
version: app.versionViewing,
|
||||
serviceRequirements: app.serviceRequirements,
|
||||
installAlert: app.installAlert,
|
||||
id,
|
||||
title,
|
||||
version,
|
||||
serviceRequirements: dependencies,
|
||||
installAlert: alerts.install,
|
||||
}
|
||||
|
||||
const { cancelled } = await wizardModal(
|
||||
@@ -188,27 +142,7 @@ export class AppAvailableShowPage extends Cleanup {
|
||||
this.navCtrl.back()
|
||||
}
|
||||
|
||||
private fetchRecommendation (): Observable<any> {
|
||||
this.recommendation = history.state && history.state.installationRecommendation
|
||||
|
||||
if (this.recommendation) {
|
||||
return from(this.syncVersionSpecificInfo(this.recommendation.versionSpec))
|
||||
} else {
|
||||
return of({ })
|
||||
}
|
||||
}
|
||||
|
||||
private syncWhenDependencyInstalls (): Observable<void> {
|
||||
return this.$app$.serviceRequirements.pipe(
|
||||
filter(deps => !!deps),
|
||||
switchMap(deps => this.appModel.watchForInstallations(deps)),
|
||||
concatMap(() => markAsLoadingDuring$(this.$dependenciesLoading$, this.syncVersionSpecificInfo())),
|
||||
catchError(e => of(console.error(e))),
|
||||
)
|
||||
}
|
||||
|
||||
private setError (e: Error) {
|
||||
console.error(e)
|
||||
this.$error$.next(e.message)
|
||||
dismissRec () {
|
||||
this.showRec = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,8 +10,6 @@ import { AppConfigObjectPageModule } from 'src/app/modals/app-config-object/app-
|
||||
import { AppConfigUnionPageModule } from 'src/app/modals/app-config-union/app-config-union.module'
|
||||
import { AppConfigValuePageModule } from 'src/app/modals/app-config-value/app-config-value.module'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
import { PwaBackComponentModule } from 'src/app/components/pwa-back-button/pwa-back.component.module'
|
||||
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
|
||||
import { RecommendationButtonComponentModule } from 'src/app/components/recommendation-button/recommendation-button.component.module'
|
||||
import { InformationPopoverComponentModule } from 'src/app/components/information-popover/information-popover.component.module'
|
||||
|
||||
@@ -19,7 +17,6 @@ const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AppConfigPage,
|
||||
// canDeactivate: [CanDeactivateGuard],
|
||||
},
|
||||
]
|
||||
|
||||
@@ -35,8 +32,6 @@ const routes: Routes = [
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
PwaBackComponentModule,
|
||||
BadgeMenuComponentModule,
|
||||
RecommendationButtonComponentModule,
|
||||
InformationPopoverComponentModule,
|
||||
],
|
||||
|
||||
@@ -5,25 +5,27 @@
|
||||
<ion-icon name="arrow-back"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{ app['title'] | async }}</ion-title>
|
||||
<ion-title>{{ pkg.manifest.title }}</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding-top">
|
||||
|
||||
<!-- loading -->
|
||||
<div *ngIf="$loading$ | async" class="full-page-spinner">
|
||||
<ion-spinner style="justify-self: center; align-self: end;" name="lines" color="warning"></ion-spinner>
|
||||
<ion-label style="justify-self: center;" *ngIf="($loadingText$ | async)" color="dark">
|
||||
{{$loadingText$ | async}}
|
||||
</ion-label>
|
||||
</div>
|
||||
<ion-grid *ngIf="loadingText$ | ngrxPush as loadingText; else loaded" style="height: 100%;">
|
||||
<ion-row class="ion-align-items-center ion-text-center" style="height: 100%;">
|
||||
<ion-col>
|
||||
<ion-spinner name="lines" color="warning"></ion-spinner>
|
||||
<p>{{ loadingText }}</p>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
|
||||
<!-- not loading -->
|
||||
<ng-container *ngIf="!($loading$ | async)">
|
||||
<ng-template #loaded>
|
||||
<ion-item *ngIf="error" class="notifier-item">
|
||||
<ion-label style="margin: 7px 5px;" class="ion-text-wrap">
|
||||
<p style="color: var(--ion-color-danger)">{{error.text}}</p>
|
||||
<p style="color: var(--ion-color-danger)">{{ error.text }}</p>
|
||||
<p><a style="color: var(--ion-color-danger); text-decoration: underline; font-weight: bold;" *ngIf="error.moreInfo && !openErrorMoreInfo" (click)="openErrorMoreInfo = true">{{error.moreInfo.buttonText}}</a></p>
|
||||
|
||||
<ng-container *ngIf="openErrorMoreInfo">
|
||||
@@ -33,44 +35,45 @@
|
||||
</ng-container>
|
||||
|
||||
</ion-label>
|
||||
<ion-button style="position: absolute; right: 0; top: 0" *ngIf="app && (app.id | async)" color="danger" fill="clear" (click)="dismissError()">
|
||||
<ion-button style="position: absolute; right: 0; top: 0" *ngIf="pkg" color="danger" fill="clear" (click)="dismissError()">
|
||||
<ion-icon name="close-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
|
||||
<ng-container *ngIf="app && (app.id | async)">
|
||||
<ng-container *ngIf="([AppStatus.NEEDS_CONFIG] | includes: (app.status | async)) && !edited">
|
||||
<ng-container *ngIf="pkg">
|
||||
<!-- @TODO make sure this is how to determine if pkg is in needs_config -->
|
||||
<ng-container *ngIf="pkg.manifest.config && !pkg.status.configured && !edited">
|
||||
<ion-item class="notifier-item">
|
||||
<ion-label class="ion-text-wrap">
|
||||
<h2 style="display: flex; align-items: center; margin-bottom: 3px;">
|
||||
<ion-icon size="small" style="margin-right: 5px" slot="start" color="dark" slot="start" name="alert-circle-outline"></ion-icon>
|
||||
<ion-text style="font-size: smaller;">Initial Config</ion-text>
|
||||
</h2>
|
||||
<p style="font-size: small">To use the default config for {{ app.title | async }}, click "Save" below.</p>
|
||||
<p style="font-size: small">To use the default config for {{ app.title | ngrxPush }}, click "Save" below.</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="recommendation && showRecommendation">
|
||||
<ion-item class="recommendation-item">
|
||||
<ng-container *ngIf="rec && showRec">
|
||||
<ion-item class="rec-item">
|
||||
<ion-label class="ion-text-wrap">
|
||||
<h2 style="display: flex; align-items: center;">
|
||||
<ion-icon size="small" style="margin: 4px" slot="start" color="primary" slot="start" name="ellipse"></ion-icon>
|
||||
<ion-avatar style="width: 3vh; height: 3vh; margin: 0px 2px 0px 5px;" slot="start">
|
||||
<img [src]="recommendation.iconURL | iconParse" [alt]="recommendation.title"/>
|
||||
<img [src]="rec.dependentIcon" [alt]="rec.dependentTitle"/>
|
||||
</ion-avatar>
|
||||
<ion-text style="margin: 5px; font-family: 'Montserrat'; font-size: smaller;">{{recommendation.title}}</ion-text>
|
||||
<ion-text style="margin: 5px; font-family: 'Montserrat'; font-size: smaller;">{{ rec.dependentTitle }}</ion-text>
|
||||
</h2>
|
||||
<div style="margin: 7px 5px;">
|
||||
<p style="font-size: small; color: var(--ion-color-medium)"> {{app.title | async}} config has been modified to satisfy {{recommendation.title}}.
|
||||
<p style="font-size: small; color: var(--ion-color-medium)"> {{app.title | ngrxPush}} config has been modified to satisfy {{ rec.dependentTitle }}.
|
||||
<ion-text color="dark">To accept the changes, click “Save” below.</ion-text>
|
||||
</p>
|
||||
<a style="font-size: small" *ngIf="!openRecommendation" (click)="openRecommendation = true">More Info</a>
|
||||
<ng-container *ngIf="openRecommendation">
|
||||
<p style="margin-top: 10px; color: var(--ion-color-medium); font-size: small" [innerHTML]="recommendation.description"></p>
|
||||
<a style="font-size: x-small; font-style: italic;" (click)="openRecommendation = false">hide</a>
|
||||
<a style="font-size: small" *ngIf="!openRec" (click)="openRec = true">More Info</a>
|
||||
<ng-container *ngIf="openRec">
|
||||
<p style="margin-top: 10px; color: var(--ion-color-medium); font-size: small" [innerHTML]="rec.description"></p>
|
||||
<a style="font-size: x-small; font-style: italic;" (click)="openRec = false">hide</a>
|
||||
</ng-container>
|
||||
<ion-button style="position: absolute; right: 0; top: 0" color="primary" fill="clear" (click)="dismissRecommendation()">
|
||||
<ion-button style="position: absolute; right: 0; top: 0" color="primary" fill="clear" (click)="dismissRec()">
|
||||
<ion-icon name="close-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
@@ -89,18 +92,18 @@
|
||||
<!-- no config -->
|
||||
<ion-item *ngIf="!hasConfig">
|
||||
<ion-label class="ion-text-wrap">
|
||||
<p>No config options for {{ app.title | async }} {{ app.versionInstalled | async }}.</p>
|
||||
<p>No config options for {{ app.title | ngrxPush }} {{ app.versionInstalled | ngrxPush }}.</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- save button, always show -->
|
||||
<ion-button
|
||||
[disabled]="invalid || (!edited && !added && !(['NEEDS_CONFIG'] | includes: (app.status | async)))"
|
||||
[disabled]="invalid || (!edited && !added && !(['NEEDS_CONFIG'] | includes: (app.status | ngrxPush)))"
|
||||
fill="outline"
|
||||
expand="block"
|
||||
style="margin: 10px"
|
||||
color="primary"
|
||||
(click)="save()"
|
||||
(click)="save(pkg)"
|
||||
>
|
||||
<ion-text color="primary" style="font-weight: bold">
|
||||
Save
|
||||
@@ -115,5 +118,5 @@
|
||||
</ion-item-group>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</ion-content>
|
||||
|
||||
@@ -1,80 +1,73 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { NavController, AlertController, ModalController, PopoverController } from '@ionic/angular'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { AppModel, AppStatus } from 'src/app/models/app-model'
|
||||
import { AppInstalledFull } from 'src/app/models/app-types'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { pauseFor, isEmptyObject, modulateTime } from 'src/app/util/misc.util'
|
||||
import { PropertySubject, peekProperties } from 'src/app/util/property-subject.util'
|
||||
import { LoaderService, markAsLoadingDuring$ } from 'src/app/services/loader.service'
|
||||
import { isEmptyObject } from 'src/app/util/misc.util'
|
||||
import { LoaderService } from 'src/app/services/loader.service'
|
||||
import { TrackingModalController } from 'src/app/services/tracking-modal-controller.service'
|
||||
import { ModelPreload } from 'src/app/models/model-preload'
|
||||
import { BehaviorSubject, forkJoin, from, fromEvent, of } from 'rxjs'
|
||||
import { BehaviorSubject, from, fromEvent, of, Subscription } from 'rxjs'
|
||||
import { catchError, concatMap, map, take, tap } from 'rxjs/operators'
|
||||
import { Recommendation } from 'src/app/components/recommendation-button/recommendation-button.component'
|
||||
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
|
||||
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
|
||||
import { Cleanup } from 'src/app/util/cleanup'
|
||||
import { InformationPopoverComponent } from 'src/app/components/information-popover/information-popover.component'
|
||||
import { ConfigSpec } from 'src/app/app-config/config-types'
|
||||
import { ConfigCursor } from 'src/app/app-config/config-cursor'
|
||||
import { ConfigSpec } from 'src/app/pkg-config/config-types'
|
||||
import { ConfigCursor } from 'src/app/pkg-config/config-cursor'
|
||||
import { InstalledPackageDataEntry } from 'src/app/models/patch-db/data-model'
|
||||
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
|
||||
|
||||
@Component({
|
||||
selector: 'app-config',
|
||||
templateUrl: './app-config.page.html',
|
||||
styleUrls: ['./app-config.page.scss'],
|
||||
})
|
||||
export class AppConfigPage extends Cleanup {
|
||||
export class AppConfigPage {
|
||||
error: { text: string, moreInfo?:
|
||||
{ title: string, description: string, buttonText: string }
|
||||
}
|
||||
|
||||
invalid: string
|
||||
$loading$ = new BehaviorSubject(true)
|
||||
$loadingText$ = new BehaviorSubject(undefined)
|
||||
loadingText$ = new BehaviorSubject(undefined)
|
||||
|
||||
app: PropertySubject<AppInstalledFull> = { } as any
|
||||
appId: string
|
||||
pkg: InstalledPackageDataEntry
|
||||
hasConfig = false
|
||||
|
||||
recommendation: Recommendation | null = null
|
||||
showRecommendation = true
|
||||
openRecommendation = false
|
||||
backButtonDefense = false
|
||||
|
||||
rec: Recommendation | null = null
|
||||
showRec = true
|
||||
openRec = false
|
||||
|
||||
invalid: string
|
||||
edited: boolean
|
||||
added: boolean
|
||||
rootCursor: ConfigCursor<'object'>
|
||||
spec: ConfigSpec
|
||||
config: object
|
||||
|
||||
AppStatus = AppStatus
|
||||
subs: Subscription[]
|
||||
|
||||
constructor (
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly wizardBaker: WizardBaker,
|
||||
private readonly preload: ModelPreload,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly loader: LoaderService,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly modalController: ModalController,
|
||||
private readonly trackingModalCtrl: TrackingModalController,
|
||||
private readonly popoverController: PopoverController,
|
||||
private readonly appModel: AppModel,
|
||||
) { super() }
|
||||
|
||||
backButtonDefense = false
|
||||
private readonly patch: PatchDbModel,
|
||||
) { }
|
||||
|
||||
async ngOnInit () {
|
||||
this.appId = this.route.snapshot.paramMap.get('appId') as string
|
||||
const pkgId = this.route.snapshot.paramMap.get('pkgId') as string
|
||||
|
||||
this.route.params.pipe(take(1)).subscribe(params => {
|
||||
if (params.edit) {
|
||||
window.history.back()
|
||||
}
|
||||
})
|
||||
|
||||
this.cleanup(
|
||||
this.subs = [
|
||||
this.route.params.pipe(take(1)).subscribe(params => {
|
||||
if (params.edit) {
|
||||
window.history.back()
|
||||
}
|
||||
}),
|
||||
fromEvent(window, 'popstate').subscribe(() => {
|
||||
this.backButtonDefense = false
|
||||
this.trackingModalCtrl.dismissAll()
|
||||
@@ -90,49 +83,51 @@ export class AppConfigPage extends Cleanup {
|
||||
this.navCtrl.back()
|
||||
}
|
||||
}),
|
||||
)
|
||||
]
|
||||
|
||||
markAsLoadingDuring$(this.$loading$,
|
||||
from(this.preload.appFull(this.appId))
|
||||
.pipe(
|
||||
tap(app => this.app = app),
|
||||
tap(() => this.$loadingText$.next(`Fetching config spec...`)),
|
||||
concatMap(() => forkJoin([this.apiService.getAppConfig(this.appId), pauseFor(600)])),
|
||||
concatMap(([{ spec, config }]) => {
|
||||
const rec = history.state && history.state.configRecommendation as Recommendation
|
||||
if (rec) {
|
||||
this.$loadingText$.next(`Setting properties to accomodate ${rec.title}...`)
|
||||
return from(this.apiService.postConfigureDependency(this.appId, rec.appId, true))
|
||||
.pipe(
|
||||
map(res => ({
|
||||
spec,
|
||||
config,
|
||||
dependencyConfig: res.config,
|
||||
})),
|
||||
tap(() => this.recommendation = rec),
|
||||
catchError(e => {
|
||||
this.error = { text: `Could not set properties to accomodate ${rec.title}: ${e.message}`, moreInfo: {
|
||||
title: `${rec.title} requires the following:`,
|
||||
description: rec.description,
|
||||
buttonText: 'Configure Manually',
|
||||
} }
|
||||
return of({ spec, config, dependencyConfig: null })
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
this.patch.watch$('package-data', pkgId, 'installed')
|
||||
.pipe(
|
||||
tap(pkg => this.pkg = pkg),
|
||||
tap(() => this.loadingText$.next(`Fetching config spec...`)),
|
||||
concatMap(() => this.apiService.getPackageConfig({ id: pkgId })),
|
||||
concatMap(({ spec, config }) => {
|
||||
const rec = history.state && history.state.configRecommendation as Recommendation
|
||||
if (rec) {
|
||||
this.loadingText$.next(`Setting properties to accommodate ${rec.dependentTitle}...`)
|
||||
return from(this.apiService.dryConfigureDependency({ 'dependency-id': pkgId, 'dependent-id': rec.dependentId }))
|
||||
.pipe(
|
||||
map(res => ({
|
||||
spec,
|
||||
config,
|
||||
dependencyConfig: res,
|
||||
})),
|
||||
tap(() => this.rec = rec),
|
||||
catchError(e => {
|
||||
this.error = { text: `Could not set properties to accommodate ${rec.dependentTitle}: ${e.message}`, moreInfo: {
|
||||
title: `${rec.dependentTitle} requires the following:`,
|
||||
description: rec.description,
|
||||
buttonText: 'Configure Manually',
|
||||
} }
|
||||
return of({ spec, config, dependencyConfig: null })
|
||||
}
|
||||
}),
|
||||
map(({ spec, config, dependencyConfig }) => this.setConfig(spec, config, dependencyConfig)),
|
||||
tap(() => this.$loadingText$.next(undefined)),
|
||||
),
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
return of({ spec, config, dependencyConfig: null })
|
||||
}
|
||||
}),
|
||||
map(({ spec, config, dependencyConfig }) => this.setConfig(spec, config, dependencyConfig)),
|
||||
tap(() => this.loadingText$.next(undefined)),
|
||||
take(1),
|
||||
).subscribe({
|
||||
error: e => {
|
||||
console.error(e)
|
||||
this.error = { text: e.message }
|
||||
},
|
||||
error: e => {
|
||||
console.error(e.message)
|
||||
this.error = { text: e.message }
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy () {
|
||||
this.subs.forEach(sub => sub.unsubscribe())
|
||||
}
|
||||
|
||||
async presentPopover (title: string, description: string, ev: any) {
|
||||
@@ -165,8 +160,8 @@ export class AppConfigPage extends Cleanup {
|
||||
this.hasConfig = !isEmptyObject(this.spec)
|
||||
}
|
||||
|
||||
dismissRecommendation () {
|
||||
this.showRecommendation = false
|
||||
dismissRec () {
|
||||
this.showRec = false
|
||||
}
|
||||
|
||||
dismissError () {
|
||||
@@ -181,38 +176,30 @@ export class AppConfigPage extends Cleanup {
|
||||
}
|
||||
}
|
||||
|
||||
async save () {
|
||||
const app = peekProperties(this.app)
|
||||
const ogAppStatus = app.status
|
||||
|
||||
async save (pkg: InstalledPackageDataEntry) {
|
||||
return this.loader.of({
|
||||
message: `Saving config...`,
|
||||
spinner: 'lines',
|
||||
cssClass: 'loader',
|
||||
}).displayDuringAsync(async () => {
|
||||
const config = this.config
|
||||
const { breakages } = await this.apiService.patchAppConfig(app, config, true)
|
||||
const { breakages } = await this.apiService.drySetPackageConfig({ id: pkg.manifest.id, config: this.config })
|
||||
|
||||
if (breakages.length) {
|
||||
const { cancelled } = await wizardModal(
|
||||
this.modalController,
|
||||
this.wizardBaker.configure({
|
||||
app,
|
||||
pkg,
|
||||
breakages,
|
||||
}),
|
||||
)
|
||||
if (cancelled) return { skip: true }
|
||||
}
|
||||
|
||||
return this.apiService.patchAppConfig(app, config).then(
|
||||
() => this.preload.loadInstalledApp(this.appId).then(() => ({ skip: false })),
|
||||
)
|
||||
return this.apiService.setPackageConfig({ id: pkg.manifest.id, config: this.config })
|
||||
.then(() => ({ skip: false }))
|
||||
})
|
||||
.then(({ skip }) => {
|
||||
if (skip) return
|
||||
if (ogAppStatus === AppStatus.RUNNING) {
|
||||
this.appModel.update({ id: this.appId, status: AppStatus.RESTARTING }, modulateTime(new Date(), 3, 'seconds'))
|
||||
}
|
||||
this.navCtrl.back()
|
||||
})
|
||||
.catch(e => this.error = { text: e.message })
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Routes, RouterModule } from '@angular/router'
|
||||
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
|
||||
import { DependencyListComponentModule } from 'src/app/components/dependency-list/dependency-list.component.module'
|
||||
import { AppInstalledListPage } from './app-installed-list.page'
|
||||
import { StatusComponentModule } from 'src/app/components/status/status.component.module'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
import { AppBackupPageModule } from 'src/app/modals/app-backup/app-backup.module'
|
||||
import { PwaBackComponentModule } from 'src/app/components/pwa-back-button/pwa-back.component.module'
|
||||
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
|
||||
|
||||
const routes: Routes = [
|
||||
@@ -23,14 +18,13 @@ const routes: Routes = [
|
||||
imports: [
|
||||
CommonModule,
|
||||
StatusComponentModule,
|
||||
DependencyListComponentModule,
|
||||
AppBackupPageModule,
|
||||
SharingModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
PwaBackComponentModule,
|
||||
BadgeMenuComponentModule,
|
||||
],
|
||||
declarations: [AppInstalledListPage],
|
||||
declarations: [
|
||||
AppInstalledListPage,
|
||||
],
|
||||
})
|
||||
export class AppInstalledListPageModule { }
|
||||
|
||||
@@ -8,52 +8,9 @@
|
||||
</ion-header>
|
||||
|
||||
<ion-content style="position: relative">
|
||||
<ion-spinner *ngIf="$loading$ | async" class="center" name="lines" color="warning"></ion-spinner>
|
||||
<ng-container *ngrxLet="patch.watch$('package-data') as pkgs">
|
||||
|
||||
<ng-container *ngIf="!($loading$ | async)">
|
||||
<ion-refresher slot="fixed" (ionRefresh)="doRefresh($event)">
|
||||
<ion-refresher-content pullingIcon="lines" refreshingSpinner="lines"></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
|
||||
<ion-item *ngIf="error">
|
||||
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
|
||||
</ion-item>
|
||||
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col *ngFor="let app of apps" sizeXs="4" sizeSm="3" sizeMd="2" sizeLg="2">
|
||||
<ng-container *ngIf="{
|
||||
status: app.subject.status | async,
|
||||
hasUI: app.subject.hasUI | async,
|
||||
launchable: app.subject.launchable | async,
|
||||
iconURL: app.subject.iconURL | async | iconParse,
|
||||
title: app.subject.title | async
|
||||
} as vars">
|
||||
|
||||
<ion-card class="installed-card" style="position:relative" [routerLink]="['/services', 'installed', app.id]">
|
||||
<div class="launch-container" *ngIf="vars.hasUI">
|
||||
<div class="launch-button-triangle" (click)="launchUiTab(app.id, $event)" [class.disabled]="!vars.launchable">
|
||||
<ion-icon name="rocket-outline"></ion-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img style="position: absolute" class="main-img" [src]="vars.iconURL" [alt]="vars.title" />
|
||||
<img class="main-img" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=">
|
||||
<img class="bulb-on" *ngIf="vars.status | displayBulb: 'green'" src="assets/img/running-bulb.png"/>
|
||||
<img class="bulb-on" *ngIf="vars.status | displayBulb: 'red'" src="assets/img/issue-bulb.png"/>
|
||||
<img class="bulb-on" *ngIf="vars.status | displayBulb: 'yellow'" src="assets/img/warning-bulb.png"/>
|
||||
<img class="bulb-off" *ngIf="vars.status | displayBulb: 'off'" src="assets/img/off-bulb.png"/>
|
||||
<ion-card-header>
|
||||
<status [appStatus]="vars.status" size="small"></status>
|
||||
<p>{{ vars.title }}</p>
|
||||
</ion-card-header>
|
||||
</ion-card>
|
||||
</ng-container>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
|
||||
<div *ngIf="!apps || !apps.length" class="ion-text-center ion-padding">
|
||||
<div *ngIf="pkgs | empty; else list" class="ion-text-center ion-padding">
|
||||
<div style="display: flex; flex-direction: column; justify-content: center; height: 40vh">
|
||||
<h2>Welcome to your <span style="font-style: italic; color: var(--ion-color-start9)">Embassy</span></h2>
|
||||
<p class="ion-text-wrap">Get started by installing your first service.</p>
|
||||
@@ -63,5 +20,32 @@
|
||||
Marketplace
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
<ng-template #list>
|
||||
<ion-grid>
|
||||
<ion-row *ngrxLet="connectionService.monitor$() as connection">
|
||||
<ion-col *ngFor="let pkg of pkgs | keyvalue : asIsOrder" sizeXs="4" sizeSm="3" sizeMd="2" sizeLg="2">
|
||||
<ion-card class="installed-card" style="position:relative" [routerLink]="['/services', 'installed', (pkg.value | manifest).id]">
|
||||
<div class="launch-container" *ngIf="pkg.value | hasUi">
|
||||
<div class="launch-button-triangle" (click)="launchUi(pkg.value, $event)" [class.disabled]="!(pkg.value | isLaunchable)">
|
||||
<ion-icon name="rocket-outline"></ion-icon>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<img style="position: absolute" class="main-img" [src]="pkg.value['static-files'].icon" [alt]="icon" />
|
||||
<img class="main-img" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=">
|
||||
<img class="bulb-on" *ngIf="pkg.value | displayBulb: 'green' : connection" src="assets/img/running-bulb.png"/>
|
||||
<img class="bulb-on" *ngIf="pkg.value | displayBulb: 'red' : connection" src="assets/img/issue-bulb.png"/>
|
||||
<img class="bulb-on" *ngIf="pkg.value | displayBulb: 'yellow' : connection" src="assets/img/warning-bulb.png"/>
|
||||
<img class="bulb-off" *ngIf="pkg.value | displayBulb: 'off' : connection" src="assets/img/off-bulb.png"/>
|
||||
<ion-card-header>
|
||||
<status [pkg]="pkg.value" [connection]="connection" size="small"></status>
|
||||
<p>{{ (pkg.value | manifest).title }}</p>
|
||||
</ion-card-header>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</ion-content>
|
||||
|
||||
@@ -1,126 +1,29 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { AppModel, AppStatus } from 'src/app/models/app-model'
|
||||
import { AppInstalledPreview } from 'src/app/models/app-types'
|
||||
import { ModelPreload } from 'src/app/models/model-preload'
|
||||
import { doForAtLeast } from 'src/app/util/misc.util'
|
||||
import { PropertySubject, PropertySubjectId, toObservable } from 'src/app/util/property-subject.util'
|
||||
import { markAsLoadingDuring$ } from 'src/app/services/loader.service'
|
||||
import { BehaviorSubject, Observable, Subscription } from 'rxjs'
|
||||
import { S9Server, ServerModel, ServerStatus } from 'src/app/models/server-model'
|
||||
import { SyncDaemon } from 'src/app/services/sync.service'
|
||||
import { Cleanup } from 'src/app/util/cleanup'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { ConnectionService } from 'src/app/services/connection.service'
|
||||
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
|
||||
import { PackageDataEntry } from 'src/app/models/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'app-installed-list',
|
||||
templateUrl: './app-installed-list.page.html',
|
||||
styleUrls: ['./app-installed-list.page.scss'],
|
||||
})
|
||||
export class AppInstalledListPage extends Cleanup {
|
||||
error = ''
|
||||
initError = ''
|
||||
$loading$ = new BehaviorSubject(true)
|
||||
s9Host$: Observable<string>
|
||||
|
||||
AppStatus = AppStatus
|
||||
|
||||
server: PropertySubject<S9Server>
|
||||
currentServer: S9Server
|
||||
apps: PropertySubjectId<AppInstalledPreview>[] = []
|
||||
|
||||
subsToTearDown: Subscription[] = []
|
||||
|
||||
updatingFreeze = false
|
||||
updating = false
|
||||
segmentValue: 'services' | 'embassy' = 'services'
|
||||
|
||||
showCertDownload : boolean
|
||||
export class AppInstalledListPage {
|
||||
|
||||
constructor (
|
||||
private readonly serverModel: ServerModel,
|
||||
private readonly appModel: AppModel,
|
||||
private readonly preload: ModelPreload,
|
||||
private readonly syncDaemon: SyncDaemon,
|
||||
private readonly config: ConfigService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
public readonly connectionService: ConnectionService,
|
||||
public readonly patch: PatchDbModel,
|
||||
) { }
|
||||
|
||||
ngOnDestroy () {
|
||||
this.subsToTearDown.forEach(s => s.unsubscribe())
|
||||
}
|
||||
|
||||
async ngOnInit () {
|
||||
this.server = this.serverModel.watch()
|
||||
this.apps = []
|
||||
this.cleanup(
|
||||
|
||||
// serverUpdateSubscription
|
||||
this.server.status.subscribe(status => {
|
||||
if (status === ServerStatus.UPDATING) {
|
||||
this.updating = true
|
||||
} else {
|
||||
if (!this.updatingFreeze) { this.updating = false }
|
||||
}
|
||||
}),
|
||||
|
||||
// newAppsSubscription
|
||||
this.appModel.watchDelta('add').subscribe(({ id }) => {
|
||||
if (this.apps.find(a => a.id === id)) return
|
||||
this.apps.push({ id, subject: this.appModel.watch(id) })
|
||||
},
|
||||
),
|
||||
|
||||
// appsDeletedSubscription
|
||||
this.appModel.watchDelta('delete').subscribe(({ id }) => {
|
||||
const i = this.apps.findIndex(a => a.id === id)
|
||||
this.apps.splice(i, 1)
|
||||
}),
|
||||
|
||||
// currentServerSubscription
|
||||
toObservable(this.server).subscribe(currentServerProperties => {
|
||||
this.currentServer = currentServerProperties
|
||||
}),
|
||||
)
|
||||
|
||||
markAsLoadingDuring$(this.$loading$, this.preload.apps()).subscribe({
|
||||
next: apps => {
|
||||
this.apps = apps
|
||||
},
|
||||
error: e => {
|
||||
console.error(e)
|
||||
this.error = e.message
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async launchUiTab (id: string, event: Event) {
|
||||
launchUi (pkg: PackageDataEntry, event: Event): void {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const app = this.apps.find(app => app.id === id).subject
|
||||
|
||||
let uiAddress: string
|
||||
if (this.config.isTor()) {
|
||||
uiAddress = `http://${app.torAddress.getValue()}`
|
||||
} else {
|
||||
uiAddress = `https://${app.lanAddress.getValue()}`
|
||||
}
|
||||
return window.open(uiAddress, '_blank')
|
||||
window.open(this.config.launchableURL(pkg.installed), '_blank')
|
||||
}
|
||||
|
||||
async doRefresh (event: any) {
|
||||
await doForAtLeast([this.getServerAndApps()], 600)
|
||||
event.target.complete()
|
||||
}
|
||||
|
||||
async getServerAndApps (): Promise<void> {
|
||||
try {
|
||||
await this.syncDaemon.sync()
|
||||
this.error = ''
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
this.error = e.message
|
||||
}
|
||||
asIsOrder () {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Routes, RouterModule } from '@angular/router'
|
||||
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
|
||||
import { DependencyListComponentModule } from 'src/app/components/dependency-list/dependency-list.component.module'
|
||||
import { AppInstalledShowPage } from './app-installed-show.page'
|
||||
import { StatusComponentModule } from 'src/app/components/status/status.component.module'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
import { AppBackupPageModule } from 'src/app/modals/app-backup/app-backup.module'
|
||||
import { PwaBackComponentModule } from 'src/app/components/pwa-back-button/pwa-back.component.module'
|
||||
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
|
||||
import { InstallWizardComponentModule } from 'src/app/components/install-wizard/install-wizard.component.module'
|
||||
import { ErrorMessageComponentModule } from 'src/app/components/error-message/error-message.component.module'
|
||||
import { InformationPopoverComponentModule } from 'src/app/components/information-popover/information-popover.component.module'
|
||||
|
||||
const routes: Routes = [
|
||||
@@ -26,15 +21,12 @@ const routes: Routes = [
|
||||
imports: [
|
||||
CommonModule,
|
||||
StatusComponentModule,
|
||||
DependencyListComponentModule,
|
||||
AppBackupPageModule,
|
||||
SharingModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
PwaBackComponentModule,
|
||||
BadgeMenuComponentModule,
|
||||
InstallWizardComponentModule,
|
||||
ErrorMessageComponentModule,
|
||||
InformationPopoverComponentModule,
|
||||
],
|
||||
declarations: [AppInstalledShowPage],
|
||||
|
||||
@@ -10,182 +10,173 @@
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content *ngIf="{
|
||||
id: app.id | async,
|
||||
torAddress: app.torAddress | async,
|
||||
status: app.status | async,
|
||||
versionInstalled: app.versionInstalled | async,
|
||||
licenseName: app.licenseName | async,
|
||||
licenseLink: app.licenseLink | async,
|
||||
configuredRequirements: app.configuredRequirements | async,
|
||||
lastBackup: app.lastBackup | async,
|
||||
hasFetchedFull: app.hasFetchedFull | async,
|
||||
iconURL: app.iconURL | async,
|
||||
title: app.title | async,
|
||||
hasUI: app.hasUI | async,
|
||||
launchable: app.launchable | async,
|
||||
lanAddress: app.lanAddress | async
|
||||
} as vars" class="ion-padding-bottom">
|
||||
<ion-spinner *ngIf="$loading$ | async" class="center" name="lines" color="warning"></ion-spinner>
|
||||
<ng-container *ngIf="!($loading$ | async)">
|
||||
<ion-refresher *ngIf="app && app.id" slot="fixed" (ionRefresh)="doRefresh($event)">
|
||||
<ion-refresher-content pullingIcon="lines" refreshingSpinner="lines"></ion-refresher-content>
|
||||
</ion-refresher>
|
||||
<ion-content class="ion-padding-bottom">
|
||||
|
||||
<error-message [$error$]="$error$" [dismissable]="!!(app && app.id)"></error-message>
|
||||
<ion-item *ngIf="error" style="margin-bottom: 16px;">
|
||||
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
|
||||
</ion-item>
|
||||
|
||||
<div class="top-plate" *ngIf="app && app.id">
|
||||
<ion-item class="no-cushion-item" lines="none">
|
||||
<ion-label class="ion-text-wrap" style="
|
||||
display: grid;
|
||||
grid-template-columns: 80px auto;
|
||||
margin: 0px;
|
||||
margin-top: 15px;"
|
||||
>
|
||||
<ion-avatar style="justify-self: center; height: 55px; width: 55px" slot="start">
|
||||
<img [src]="vars.iconURL | iconParse" />
|
||||
</ion-avatar>
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
<ion-text style="font-family: 'Montserrat'; font-size: x-large; line-height: normal;" [class.less-large]="vars.title.length > 20">
|
||||
{{ vars.title }}
|
||||
</ion-text>
|
||||
<ion-text style="margin-top: -5px; margin-left: 2px;">
|
||||
{{ vars.versionInstalled | displayEmver }}
|
||||
</ion-text>
|
||||
</div>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item class="no-cushion-item" lines=none style="margin-bottom: 10px;">
|
||||
<ion-label class="status-readout">
|
||||
<status size="bold-large" [appStatus]="vars.status"></status>
|
||||
<ion-button *ngIf="vars.status === AppStatus.NEEDS_CONFIG" expand="block" fill="outline" [routerLink]="['config']">
|
||||
Configure
|
||||
</ion-button>
|
||||
<ion-button *ngIf="[AppStatus.RUNNING, AppStatus.CRASHED, AppStatus.PAUSED, AppStatus.RESTARTING] | includes: vars.status" expand="block" fill="outline" color="danger" (click)="stop()">
|
||||
Stop
|
||||
</ion-button>
|
||||
<ion-button *ngIf="vars.status === AppStatus.CREATING_BACKUP" expand="block" fill="outline" (click)="presentAlertStopBackup()">
|
||||
Stop Backup
|
||||
</ion-button>
|
||||
<ion-button *ngIf="vars.status === AppStatus.DEAD" expand="block" fill="outline" (click)="uninstall()">
|
||||
Force Uninstall
|
||||
</ion-button>
|
||||
<ion-button *ngIf="vars.status === AppStatus.BROKEN_DEPENDENCIES" expand="block" fill="outline" (click)="scrollToRequirements()">
|
||||
Fix
|
||||
</ion-button>
|
||||
<ion-button *ngIf="vars.status === AppStatus.STOPPED" expand="block" fill="outline" color="success" (click)="tryStart()">
|
||||
Start
|
||||
</ion-button>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-button size="small" *ngIf="vars.hasUI" [disabled]="!vars.launchable" class="launch-button" expand="block" (click)="launchUiTab()">
|
||||
Launch Web Interface
|
||||
<ion-icon slot="end" name="rocket-outline"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="app && app.id && vars.status !== 'INSTALLING'">
|
||||
<ion-item-group class="ion-padding-bottom">
|
||||
<!-- addresses -->
|
||||
<ion-item>
|
||||
<ion-label class="ion-text-wrap">
|
||||
<h2>Tor Address</h2>
|
||||
<p>{{ vars.torAddress }}</p>
|
||||
</ion-label>
|
||||
<ion-button slot="end" fill="clear" (click)="copyTor()">
|
||||
<ion-icon slot="icon-only" name="copy-outline" color="primary"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
<ion-item lines="none">
|
||||
<ion-label class="ion-text-wrap">
|
||||
<h2>LAN Address</h2>
|
||||
<p *ngIf="!hideLAN">{{ vars.lanAddress }}</p>
|
||||
<p *ngIf="hideLAN"><ion-text color="warning">No LAN address for {{ vars.title }} {{ vars.versionInstalled }}</ion-text></p>
|
||||
</ion-label>
|
||||
<ion-button *ngIf="!hideLAN" slot="end" fill="clear" (click)="copyLAN()">
|
||||
<ion-icon slot="icon-only" name="copy-outline" color="primary"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
|
||||
<!-- backups -->
|
||||
<ion-item-divider></ion-item-divider>
|
||||
<!-- create backup -->
|
||||
<ion-item button [disabled]="[AppStatus.RESTORING_BACKUP, AppStatus.CREATING_BACKUP, AppStatus.INSTALLING, AppStatus.RESTARTING, AppStatus.STOPPING] | includes: vars.status" (click)="presentModalBackup('create')">
|
||||
<ion-icon slot="start" name="save-outline" color="primary"></ion-icon>
|
||||
<ion-label style="display: flex; flex-direction: column;">
|
||||
<ion-text color="primary">Create Backup</ion-text>
|
||||
<ion-text color="medium" style="font-size: x-small">
|
||||
Last Backup: {{vars.lastBackup ? (vars.lastBackup | date: 'short') : 'never'}}
|
||||
</ion-text>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<!-- restore backup -->
|
||||
<ion-item lines="none" button [disabled]="[AppStatus.RESTORING_BACKUP, AppStatus.CREATING_BACKUP, AppStatus.INSTALLING, AppStatus.RESTARTING, AppStatus.STOPPING] | includes: vars.status" (click)="presentModalBackup('restore')">
|
||||
<ion-icon slot="start" name="color-wand-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Restore from Backup</ion-text></ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item-divider></ion-item-divider>
|
||||
<!-- instructions -->
|
||||
<ion-item [routerLink]="['instructions']">
|
||||
<ion-icon slot="start" name="list-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Instructions</ion-text></ion-label>
|
||||
</ion-item>
|
||||
<!-- config -->
|
||||
<ion-item [disabled]="[AppStatus.CREATING_BACKUP, AppStatus.RESTORING_BACKUP, AppStatus.INSTALLING, AppStatus.DEAD] | includes: vars.status" [routerLink]="['config']">
|
||||
<ion-icon slot="start" name="construct-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Config</ion-text></ion-label>
|
||||
</ion-item>
|
||||
<!-- metrics -->
|
||||
<ion-item [routerLink]="['metrics']">
|
||||
<ion-icon slot="start" name="information-circle-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Properties</ion-text></ion-label>
|
||||
</ion-item>
|
||||
<!-- actions -->
|
||||
<ion-item [routerLink]="['actions']">
|
||||
<ion-icon slot="start" name="flash-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Actions</ion-text></ion-label>
|
||||
</ion-item>
|
||||
<!-- logs -->
|
||||
<ion-item [disabled]="vars.status === AppStatus.INSTALLING" [routerLink]="['logs']">
|
||||
<ion-icon slot="start" name="newspaper-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Logs</ion-text></ion-label>
|
||||
</ion-item>
|
||||
<!-- marketplace -->
|
||||
<ion-item lines="none" [routerLink]="['/services', 'marketplace', appId]">
|
||||
<ion-icon slot="start" name="storefront-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Marketplace Listing</ion-text></ion-label>
|
||||
</ion-item>
|
||||
<!-- license -->
|
||||
<ion-item *ngIf="vars.licenseLink" lines="none" button [href]="vars.licenseLink" target="_blank">
|
||||
<ion-icon slot="start" name="newspaper-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">License</ion-text></ion-label>
|
||||
<ion-note slot="end">{{ vars.licenseName }}</ion-note>
|
||||
</ion-item>
|
||||
|
||||
<!-- dependencies -->
|
||||
<ng-container *ngIf="vars.configuredRequirements && vars.configuredRequirements.length">
|
||||
<ion-item-divider [id]="'service-requirements-' + vars.id">Dependencies
|
||||
<ion-button style="position: relative; right: 10px;" size="small" fill="clear" color="medium" (click)="presentPopover(dependencyDefintion(), $event)">
|
||||
<ion-icon name="help-circle-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item-divider>
|
||||
<dependency-list [$loading$]="$loadingDependencies$" depType="installed" [hostApp]="app | peekProperties" [dependencies]="vars.configuredRequirements"></dependency-list>
|
||||
</ng-container>
|
||||
|
||||
<ion-item-divider></ion-item-divider>
|
||||
|
||||
<ng-container *ngIf="vars.status !== AppStatus.INSTALLING && vars.status !== 'CREATING_BACKUP'">
|
||||
<!-- uninstall -->
|
||||
<ion-item style="--background: transparent" button (click)="uninstall()">
|
||||
<ion-icon slot="start" name="trash-outline" color="medium"></ion-icon>
|
||||
<ion-label><ion-text color="danger">Uninstall</ion-text></ion-label>
|
||||
<ng-container *ngrxLet="connectionService.monitor$() as connection">
|
||||
<ng-container *ngIf="pkg | manifest as manifest">
|
||||
<ng-container *ngIf="pkg | status : connection as status">
|
||||
<div class="top-plate">
|
||||
<ion-item class="no-cushion-item" lines="none">
|
||||
<ion-label class="ion-text-wrap" style="
|
||||
display: grid;
|
||||
grid-template-columns: 80px auto;
|
||||
margin: 0px;
|
||||
margin-top: 15px;"
|
||||
>
|
||||
<ion-avatar style="justify-self: center; height: 55px; width: 55px" slot="start">
|
||||
<img [src]="pkg['static-files'].icon" />
|
||||
</ion-avatar>
|
||||
<div style="display: flex; flex-direction: column;">
|
||||
<ion-text style="font-family: 'Montserrat'; font-size: x-large; line-height: normal;" [class.less-large]="manifest.title.length > 20">
|
||||
{{ manifest.title }}
|
||||
</ion-text>
|
||||
<ion-text style="margin-top: -5px; margin-left: 2px;">
|
||||
{{ manifest.version | displayEmver }}
|
||||
</ion-text>
|
||||
</div>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item class="no-cushion-item" lines=none style="margin-bottom: 10px;">
|
||||
<ion-label class="status-readout">
|
||||
<status size="bold-large" [pkg]="pkg" [connection]="connection"></status>
|
||||
<ion-button *ngIf="status === FeStatus.NeedsConfig" expand="block" fill="outline" [routerLink]="['config']">
|
||||
Configure
|
||||
</ion-button>
|
||||
<ion-button *ngIf="status === FeStatus.Running" expand="block" fill="outline" color="danger" (click)="stop()">
|
||||
Stop
|
||||
</ion-button>
|
||||
<ion-button *ngIf="status === FeStatus.DependencyIssue" expand="block" fill="outline" (click)="scrollToRequirements()">
|
||||
Fix
|
||||
</ion-button>
|
||||
<ion-button *ngIf="status === FeStatus.Stopped" expand="block" fill="outline" color="success" (click)="tryStart()">
|
||||
Start
|
||||
</ion-button>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-button size="small" *ngIf="pkg | hasUi" [disabled]="!(pkg | isLaunchable)" class="launch-button" expand="block" (click)="launchUiTab()">
|
||||
Launch Web Interface
|
||||
<ion-icon slot="end" name="rocket-outline"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
</div>
|
||||
|
||||
<ng-container *ngIf="!([FeStatus.Installing, FeStatus.Updating, FeStatus.Removing] | includes : status)">
|
||||
<ion-item-group class="ion-padding-bottom">
|
||||
<!-- interfaces -->
|
||||
<ion-item [routerLink]="['interfaces']">
|
||||
<ion-icon slot="start" name="aperture-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Interfaces</ion-text></ion-label>
|
||||
</ion-item>
|
||||
<!-- instructions -->
|
||||
<ion-item [routerLink]="['instructions']">
|
||||
<ion-icon slot="start" name="list-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Instructions</ion-text></ion-label>
|
||||
</ion-item>
|
||||
<!-- config -->
|
||||
<ion-item [disabled]="[FeStatus.Installing, FeStatus.Updating, FeStatus.Removing, FeStatus.BackingUp, FeStatus.Restoring] | includes : status" [routerLink]="['config']">
|
||||
<ion-icon slot="start" name="construct-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Config</ion-text></ion-label>
|
||||
</ion-item>
|
||||
<!-- properties -->
|
||||
<ion-item [routerLink]="['properties']">
|
||||
<ion-icon slot="start" name="information-circle-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Properties</ion-text></ion-label>
|
||||
</ion-item>
|
||||
<!-- actions -->
|
||||
<ion-item [routerLink]="['actions']">
|
||||
<ion-icon slot="start" name="flash-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Actions</ion-text></ion-label>
|
||||
</ion-item>
|
||||
<!-- logs -->
|
||||
<ion-item [routerLink]="['logs']">
|
||||
<ion-icon slot="start" name="newspaper-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Logs</ion-text></ion-label>
|
||||
</ion-item>
|
||||
<!-- restore -->
|
||||
<ion-item button [disabled]="[FeStatus.Connecting, FeStatus.Installing, FeStatus.Updating, FeStatus.Stopping, FeStatus.Removing, FeStatus.BackingUp, FeStatus.Restoring] | includes : status" [routerLink]="['restore']">
|
||||
<ion-icon slot="start" name="color-wand-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Restore from Backup</ion-text></ion-label>
|
||||
</ion-item>
|
||||
<!-- donate -->
|
||||
<ion-item button [href]="manifest['donation-url']" target="_blank">
|
||||
<ion-icon slot="start" name="shapes-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Donate</ion-text></ion-label>
|
||||
</ion-item>
|
||||
<!-- marketplace -->
|
||||
<ion-item [routerLink]="['/services', 'marketplace', manifest.id]">
|
||||
<ion-icon slot="start" name="storefront-outline" color="primary"></ion-icon>
|
||||
<ion-label><ion-text color="primary">Marketplace Listing</ion-text></ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- dependencies -->
|
||||
<ng-container *ngIf="!(manifest.dependencies | empty)">
|
||||
<ion-item-divider id="dependencies">
|
||||
Dependencies
|
||||
<ion-button style="position: relative; right: 10px;" size="small" fill="clear" color="medium" (click)="presentPopover(depDefinition, $event)">
|
||||
<ion-icon name="help-circle-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item-divider>
|
||||
|
||||
<div *ngFor="let dep of pkg.installed['current-dependencies'] | keyvalue">
|
||||
<ion-item *ngrxLet="patch.watch$('package-data', dep.key) as localDep" class="dependency-item" lines="none">
|
||||
<ion-avatar slot="start" style="position: relative; height: 5vh; width: 5vh; margin: 0px;">
|
||||
<div class="dep-badge" [class]="pkg.installed.status['dependency-errors'][dep.key] ? 'dep-issue' : 'dep-sat'"></div>
|
||||
<img [src]="localDep ? localDep['static-files'].icon : pkg.installed.status['dependency-errors'][dep.key]?.icon" />
|
||||
</ion-avatar>
|
||||
<ion-label class="ion-text-wrap" style="padding: 1vh; padding-left: 2vh">
|
||||
<h4 style="font-family: 'Montserrat'">{{ localDep ? (localDep | manifest).title : pkg.installed.status['dependency-errors'][dep.key]?.title }}</h4>
|
||||
<p style="font-size: small">{{ manifest.dependencies[dep.key].version | displayEmver }}</p>
|
||||
<p style="padding-top: 2px; position: relative; font-style: italic; font-size: smaller"><ion-text [color]="pkg.installed.status['dependency-errors'][dep.key] ? 'warning' : 'success'">{{ pkg.installed.status['dependency-errors'][dep.key] ? pkg.installed.status['dependency-errors'][dep.key].type : 'satisfied' }}</ion-text></p>
|
||||
</ion-label>
|
||||
|
||||
<ion-button *ngIf="!pkg.installed.status['dependency-errors'][dep.key] || (pkg.installed.status['dependency-errors'][dep.key] && [DependencyErrorType.InterfaceHealthChecksFailed, DependencyErrorType.HealthChecksFailed] | includes : pkg.installed.status['dependency-errors'][dep.key].type)" slot="end" size="small" [routerLink]="['/services', 'installed', dep.key]" color="primary" fill="outline" style="font-size: x-small">
|
||||
View
|
||||
</ion-button>
|
||||
|
||||
<ng-container *ngIf="pkg.installed.status['dependency-errors'][dep.key]">
|
||||
<ion-button *ngIf="!localDep" slot="end" size="small" (click)="fixDep('install', dep.key)" color="primary" fill="outline" style="font-size: x-small">
|
||||
Install
|
||||
</ion-button>
|
||||
|
||||
<ng-container *ngIf="localDep && localDep.state === PackageState.Installed">
|
||||
<ion-button *ngIf="pkg.installed.status['dependency-errors'][dep.key].type === DependencyErrorType.NotRunning" slot="end" size="small" [routerLink]="['/services', 'installed', dep.key]" color="primary" fill="outline" style="font-size: x-small">
|
||||
Start
|
||||
</ion-button>
|
||||
<ion-button *ngIf="pkg.installed.status['dependency-errors'][dep.key].type === DependencyErrorType.IncorrectVersion" slot="end" size="small" (click)="fixDep('update', dep.key)" color="primary" fill="outline" style="font-size: x-small">
|
||||
Update
|
||||
</ion-button>
|
||||
<ion-button *ngIf="pkg.installed.status['dependency-errors'][dep.key].type === DependencyErrorType.ConfigUnsatisfied" slot="end" size="small" (click)="fixDep('configure', dep.key)" color="primary" fill="outline" style="font-size: x-small">
|
||||
Configure
|
||||
</ion-button>
|
||||
</ng-container>
|
||||
|
||||
<div *ngIf="localDep && localDep.state !== PackageState.Installed" slot="end" class="spinner">
|
||||
<ion-spinner [color]="localDep.state === PackageState.Removing ? 'danger' : 'primary'" style="height: 3vh; width: 3vh" name="dots"></ion-spinner>
|
||||
</div>
|
||||
|
||||
</ng-container>
|
||||
</ion-item>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<ion-item-divider></ion-item-divider>
|
||||
|
||||
<ng-container *ngIf="!([FeStatus.Installing, FeStatus.Updating, FeStatus.BackingUp, FeStatus.Restoring] | includes : status)">
|
||||
<!-- uninstall -->
|
||||
<ion-item button (click)="uninstall()">
|
||||
<ion-icon slot="start" name="trash-outline" color="danger"></ion-icon>
|
||||
<ion-label><ion-text color="danger">Uninstall</ion-text></ion-label>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
</ion-item-group>
|
||||
</ng-container>
|
||||
</ion-item-group>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ion-content>
|
||||
|
||||
@@ -48,3 +48,19 @@
|
||||
--border-radius: 10px;
|
||||
margin: 12px 10px;
|
||||
}
|
||||
|
||||
.dep-badge {
|
||||
position: absolute; width: 2.5vh;
|
||||
height: 2.5vh;
|
||||
border-radius: 50px;
|
||||
left: -1vh;
|
||||
top: -1vh;
|
||||
}
|
||||
|
||||
.dep-issue {
|
||||
background: radial-gradient(var(--ion-color-warning) 40%, transparent)
|
||||
}
|
||||
|
||||
.dep-sat {
|
||||
background: radial-gradient(var(--ion-color-success) 40%, transparent)
|
||||
}
|
||||
|
||||
@@ -1,41 +1,37 @@
|
||||
import { Component, ViewChild } from '@angular/core'
|
||||
import { AlertController, NavController, ToastController, ModalController, IonContent, PopoverController } from '@ionic/angular'
|
||||
import { AlertController, NavController, ModalController, IonContent, PopoverController } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { copyToClipboard } from 'src/app/util/web.util'
|
||||
import { AppModel, AppStatus } from 'src/app/models/app-model'
|
||||
import { AppInstalledFull } from 'src/app/models/app-types'
|
||||
import { ModelPreload } from 'src/app/models/model-preload'
|
||||
import { chill, pauseFor } from 'src/app/util/misc.util'
|
||||
import { PropertySubject, peekProperties } from 'src/app/util/property-subject.util'
|
||||
import { AppBackupPage } from 'src/app/modals/app-backup/app-backup.page'
|
||||
import { LoaderService, markAsLoadingDuring$, markAsLoadingDuringP } from 'src/app/services/loader.service'
|
||||
import { BehaviorSubject, Observable, of } from 'rxjs'
|
||||
import { ActivatedRoute, NavigationExtras } from '@angular/router'
|
||||
import { chill } from 'src/app/util/misc.util'
|
||||
import { LoaderService } from 'src/app/services/loader.service'
|
||||
import { Observable, of, Subscription } from 'rxjs'
|
||||
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
|
||||
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
|
||||
import { catchError, concatMap, filter, switchMap, tap } from 'rxjs/operators'
|
||||
import { Cleanup } from 'src/app/util/cleanup'
|
||||
import { InformationPopoverComponent } from 'src/app/components/information-popover/information-popover.component'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { PatchDbModel } from 'src/app/models/patch-db/patch-db-model'
|
||||
import { DependencyErrorConfigUnsatisfied, DependencyErrorNotInstalled, DependencyErrorType, PackageDataEntry, PackageState } from 'src/app/models/patch-db/data-model'
|
||||
import { FEStatus } from 'src/app/services/pkg-status-rendering.service'
|
||||
import { ConnectionService } from 'src/app/services/connection.service'
|
||||
import { Recommendation } from 'src/app/components/recommendation-button/recommendation-button.component'
|
||||
|
||||
@Component({
|
||||
selector: 'app-installed-show',
|
||||
templateUrl: './app-installed-show.page.html',
|
||||
styleUrls: ['./app-installed-show.page.scss'],
|
||||
})
|
||||
export class AppInstalledShowPage extends Cleanup {
|
||||
$loading$ = new BehaviorSubject(true)
|
||||
$loadingDependencies$ = new BehaviorSubject(false) // when true, dependencies will render with spinners.
|
||||
|
||||
$error$ = new BehaviorSubject<string>('')
|
||||
app: PropertySubject<AppInstalledFull> = { } as any
|
||||
appId: string
|
||||
AppStatus = AppStatus
|
||||
showInstructions = false
|
||||
|
||||
export class AppInstalledShowPage {
|
||||
error: string
|
||||
pkgId: string
|
||||
pkg: PackageDataEntry
|
||||
pkgSub: Subscription
|
||||
hideLAN: boolean
|
||||
|
||||
dependencyDefintion = () => `<span style="font-style: italic">Dependencies</span> are other services which must be installed, configured appropriately, and started in order to start ${this.app.title.getValue()}`
|
||||
FeStatus = FEStatus
|
||||
PackageState = PackageState
|
||||
DependencyErrorType = DependencyErrorType
|
||||
|
||||
depDefinition = '<span style="font-style: italic">Service Dependencies</span> are other services that this service recommends or requires in order to run.'
|
||||
|
||||
@ViewChild(IonContent) content: IonContent
|
||||
|
||||
@@ -44,115 +40,44 @@ export class AppInstalledShowPage extends Cleanup {
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly loader: LoaderService,
|
||||
private readonly toastCtrl: ToastController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly preload: ModelPreload,
|
||||
private readonly wizardBaker: WizardBaker,
|
||||
private readonly appModel: AppModel,
|
||||
private readonly popoverController: PopoverController,
|
||||
private readonly config: ConfigService,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
public readonly patch: PatchDbModel,
|
||||
public readonly connectionService: ConnectionService,
|
||||
) { }
|
||||
|
||||
async ngOnInit () {
|
||||
this.appId = this.route.snapshot.paramMap.get('appId') as string
|
||||
|
||||
this.cleanup(
|
||||
markAsLoadingDuring$(this.$loading$, this.preload.appFull(this.appId))
|
||||
.pipe(
|
||||
tap(app => {
|
||||
this.app = app
|
||||
const appP = peekProperties(this.app)
|
||||
this.hideLAN = !appP.lanAddress || (appP.id === 'mastodon' && appP.versionInstalled === '3.3.0') // @TODO delete this hack in 0.3.0
|
||||
}),
|
||||
concatMap(() => this.syncWhenDependencyInstalls()), // must be final in stack
|
||||
catchError(e => of(this.setError(e))),
|
||||
).subscribe(),
|
||||
)
|
||||
this.pkgId = this.route.snapshot.paramMap.get('pkgId')
|
||||
this.pkgSub = this.patch.watch$('package-data', this.pkgId).subscribe(pkg => this.pkg = pkg)
|
||||
}
|
||||
|
||||
ionViewDidEnter () {
|
||||
markAsLoadingDuringP(this.$loadingDependencies$, this.getApp())
|
||||
async ngOnDestroy () {
|
||||
this.pkgSub.unsubscribe()
|
||||
}
|
||||
|
||||
async doRefresh (event: any) {
|
||||
await Promise.all([
|
||||
this.getApp(),
|
||||
pauseFor(600),
|
||||
])
|
||||
event.target.complete()
|
||||
}
|
||||
|
||||
async scrollToRequirements () {
|
||||
return this.scrollToElement('service-requirements-' + this.appId)
|
||||
}
|
||||
|
||||
async getApp (): Promise<void> {
|
||||
try {
|
||||
await this.preload.loadInstalledApp(this.appId)
|
||||
this.clearError()
|
||||
} catch (e) {
|
||||
this.setError(e)
|
||||
}
|
||||
}
|
||||
|
||||
async launchUiTab () {
|
||||
let uiAddress: string
|
||||
if (this.config.isTor()) {
|
||||
uiAddress = `http://${this.app.torAddress.getValue()}`
|
||||
} else {
|
||||
uiAddress = `https://${this.app.lanAddress.getValue()}`
|
||||
}
|
||||
return window.open(uiAddress, '_blank')
|
||||
}
|
||||
|
||||
async copyTor () {
|
||||
const app = peekProperties(this.app)
|
||||
let message = ''
|
||||
await copyToClipboard(app.torAddress || '').then(success => { message = success ? 'copied to clipboard!' : 'failed to copy' })
|
||||
|
||||
const toast = await this.toastCtrl.create({
|
||||
header: message,
|
||||
position: 'bottom',
|
||||
duration: 1000,
|
||||
cssClass: 'notification-toast',
|
||||
})
|
||||
await toast.present()
|
||||
}
|
||||
|
||||
async copyLAN () {
|
||||
const app = peekProperties(this.app)
|
||||
let message = ''
|
||||
await copyToClipboard(app.lanAddress).then(success => { message = success ? 'copied to clipboard!' : 'failed to copy' })
|
||||
|
||||
const toast = await this.toastCtrl.create({
|
||||
header: message,
|
||||
position: 'bottom',
|
||||
duration: 1000,
|
||||
cssClass: 'notification-toast',
|
||||
})
|
||||
await toast.present()
|
||||
launchUiTab (): void {
|
||||
window.open(this.config.launchableURL(this.pkg.installed), '_blank')
|
||||
}
|
||||
|
||||
async stop (): Promise<void> {
|
||||
const app = peekProperties(this.app)
|
||||
|
||||
const { id, title, version } = this.pkg.installed.manifest
|
||||
await this.loader.of({
|
||||
message: `Stopping ${app.title}...`,
|
||||
message: `Stopping...`,
|
||||
spinner: 'lines',
|
||||
cssClass: 'loader',
|
||||
}).displayDuringAsync(async () => {
|
||||
const { breakages } = await this.apiService.stopApp(this.appId, true)
|
||||
const { breakages } = await this.apiService.dryStopPackage({ id })
|
||||
|
||||
if (breakages.length) {
|
||||
const { cancelled } = await wizardModal(
|
||||
this.modalCtrl,
|
||||
this.wizardBaker.stop({
|
||||
id: app.id,
|
||||
title: app.title,
|
||||
version: app.versionInstalled,
|
||||
id,
|
||||
title,
|
||||
version,
|
||||
breakages,
|
||||
}),
|
||||
)
|
||||
@@ -160,76 +85,28 @@ export class AppInstalledShowPage extends Cleanup {
|
||||
if (cancelled) return { }
|
||||
}
|
||||
|
||||
return this.apiService.stopApp(this.appId).then(chill)
|
||||
return this.apiService.stopPackage({ id }).then(chill)
|
||||
}).catch(e => this.setError(e))
|
||||
}
|
||||
|
||||
async tryStart (): Promise<void> {
|
||||
const app = peekProperties(this.app)
|
||||
if (app.startAlert) {
|
||||
this.presentAlertStart(app)
|
||||
const message = this.pkg.installed.manifest.alerts.start
|
||||
if (message) {
|
||||
this.presentAlertStart(message)
|
||||
} else {
|
||||
this.start(app)
|
||||
this.start()
|
||||
}
|
||||
}
|
||||
|
||||
async presentModalBackup (type: 'create' | 'restore') {
|
||||
const modal = await this.modalCtrl.create({
|
||||
backdropDismiss: false,
|
||||
component: AppBackupPage,
|
||||
presentingElement: await this.modalCtrl.getTop(),
|
||||
componentProps: {
|
||||
app: peekProperties(this.app),
|
||||
type,
|
||||
},
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
async presentAlertStopBackup (): Promise<void> {
|
||||
const app = peekProperties(this.app)
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
backdropDismiss: false,
|
||||
header: 'Warning',
|
||||
message: `${app.title} is not finished backing up. Are you sure you want stop the process?`,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Stop',
|
||||
cssClass: 'alert-danger',
|
||||
handler: () => {
|
||||
this.stopBackup()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
async stopBackup (): Promise<void> {
|
||||
await this.loader.of({
|
||||
message: `Stopping backup...`,
|
||||
spinner: 'lines',
|
||||
cssClass: 'loader',
|
||||
}).displayDuringP(this.apiService.stopAppBackup(this.appId))
|
||||
.catch (e => this.setError(e))
|
||||
}
|
||||
|
||||
async uninstall () {
|
||||
const app = peekProperties(this.app)
|
||||
|
||||
const { id, title, version, alerts } = this.pkg.installed.manifest
|
||||
const data = await wizardModal(
|
||||
this.modalCtrl,
|
||||
this.wizardBaker.uninstall({
|
||||
id: app.id,
|
||||
title: app.title,
|
||||
version: app.versionInstalled,
|
||||
uninstallAlert: app.uninstallAlert,
|
||||
id,
|
||||
title,
|
||||
version,
|
||||
uninstallAlert: alerts.uninstall,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -251,10 +128,64 @@ export class AppInstalledShowPage extends Cleanup {
|
||||
return await popover.present()
|
||||
}
|
||||
|
||||
private async presentAlertStart (app: AppInstalledFull): Promise<void> {
|
||||
scrollToRequirements () {
|
||||
const el = document.getElementById('dependencies')
|
||||
if (!el) return
|
||||
let y = el.offsetTop
|
||||
return this.content.scrollToPoint(0, y, 1000)
|
||||
}
|
||||
|
||||
async fixDep (action: 'install' | 'update' | 'configure', id: string): Promise<void> {
|
||||
switch (action) {
|
||||
case 'install':
|
||||
case 'update':
|
||||
return this.installDep(id)
|
||||
case 'configure':
|
||||
return this.configureDep(id)
|
||||
}
|
||||
}
|
||||
|
||||
private async installDep (depId: string): Promise<void> {
|
||||
const version = this.pkg.installed.manifest.dependencies[depId].version
|
||||
const dependentTitle = this.pkg.installed.manifest.title
|
||||
|
||||
const installRec: Recommendation = {
|
||||
dependentId: this.pkgId,
|
||||
dependentTitle,
|
||||
dependentIcon: this.pkg['static-files'].icon,
|
||||
version,
|
||||
description: `${dependentTitle} requires an install of ${(this.pkg.installed.status['dependency-errors'][depId] as DependencyErrorNotInstalled)?.title} satisfying ${version}.`,
|
||||
}
|
||||
const navigationExtras: NavigationExtras = {
|
||||
state: { installRec },
|
||||
}
|
||||
|
||||
await this.navCtrl.navigateForward(`/services/marketplace/${depId}`, navigationExtras)
|
||||
}
|
||||
|
||||
private async configureDep (depId: string): Promise<void> {
|
||||
const configErrors = (this.pkg.installed.status['dependency-errors'][depId] as DependencyErrorConfigUnsatisfied).errors
|
||||
|
||||
const description = `<ul>${configErrors.map(d => `<li>${d}</li>`).join('\n')}</ul>`
|
||||
const dependentTitle = this.pkg.installed.manifest.title
|
||||
|
||||
const configRecommendation: Recommendation = {
|
||||
dependentId: this.pkgId,
|
||||
dependentTitle,
|
||||
dependentIcon: this.pkg['static-files'].icon,
|
||||
description,
|
||||
}
|
||||
const navigationExtras: NavigationExtras = {
|
||||
state: { configRecommendation },
|
||||
}
|
||||
|
||||
await this.navCtrl.navigateForward(`/services/installed/${depId}/config`, navigationExtras)
|
||||
}
|
||||
|
||||
private async presentAlertStart (message: string): Promise<void> {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Warning',
|
||||
message: app.startAlert,
|
||||
message,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
@@ -263,7 +194,7 @@ export class AppInstalledShowPage extends Cleanup {
|
||||
{
|
||||
text: 'Start',
|
||||
handler: () => {
|
||||
this.start(app)
|
||||
this.start()
|
||||
},
|
||||
},
|
||||
],
|
||||
@@ -271,40 +202,18 @@ export class AppInstalledShowPage extends Cleanup {
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
private async start (app: AppInstalledFull): Promise<void> {
|
||||
private async start (): Promise<void> {
|
||||
this.loader.of({
|
||||
message: `Starting ${app.title}...`,
|
||||
message: `Starting...`,
|
||||
spinner: 'lines',
|
||||
cssClass: 'loader',
|
||||
}).displayDuringP(
|
||||
this.apiService.startApp(this.appId),
|
||||
this.apiService.startPackage({ id: this.pkgId }),
|
||||
).catch(e => this.setError(e))
|
||||
}
|
||||
|
||||
private setError (e: Error): Observable<void> {
|
||||
this.$error$.next(e.message)
|
||||
this.error = e.message
|
||||
return of()
|
||||
}
|
||||
|
||||
private clearError () {
|
||||
this.$error$.next('')
|
||||
}
|
||||
|
||||
private async scrollToElement (elementId: string) {
|
||||
const el = document.getElementById(elementId)
|
||||
|
||||
if (!el) return
|
||||
|
||||
let y = el.offsetTop
|
||||
return this.content.scrollToPoint(0, y, 1000)
|
||||
}
|
||||
|
||||
private syncWhenDependencyInstalls (): Observable<void> {
|
||||
return this.app.configuredRequirements.pipe(
|
||||
filter(deps => !!deps),
|
||||
switchMap(reqs => this.appModel.watchForInstallations(reqs)),
|
||||
concatMap(() => markAsLoadingDuringP(this.$loadingDependencies$, this.getApp())),
|
||||
catchError(e => of(console.error(e))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ const routes: Routes = [
|
||||
PwaBackComponentModule,
|
||||
SharingModule,
|
||||
],
|
||||
declarations: [AppInstructionsPage],
|
||||
declarations: [
|
||||
AppInstructionsPage,
|
||||
],
|
||||
})
|
||||
export class AppInstructionsPageModule { }
|
||||
|
||||
@@ -7,22 +7,14 @@
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding-top">
|
||||
<ion-content class="ion-padding">
|
||||
<ion-spinner *ngIf="loading; else loaded" class="center" name="lines" color="warning"></ion-spinner>
|
||||
|
||||
<ion-spinner *ngIf="$loading$ | async" class="center" name="lines" color="warning"></ion-spinner>
|
||||
|
||||
<ng-container *ngIf="!($loading$ | async)">
|
||||
|
||||
<ion-item *ngIf="!app.instructions">
|
||||
<ion-label class="ion-text-wrap">
|
||||
<p>No instructions for {{ app.title }} {{ app.versionInstalled }}.</p>
|
||||
</ion-label>
|
||||
<ng-template #loaded>
|
||||
<ion-item *ngIf="error" style="margin-bottom: 16px;">
|
||||
<ion-text class="ion-text-wrap" color="danger">{{ error }}</ion-text>
|
||||
</ion-item>
|
||||
|
||||
<div style="
|
||||
padding-left: var(--ion-padding,16px);
|
||||
padding-right: var(--ion-padding,16px);
|
||||
padding-bottom: var(--ion-padding,16px);
|
||||
" *ngIf="app.instructions" [innerHTML]="app.instructions | markdown"></div>
|
||||
</ng-container>
|
||||
<div *ngIf="instructions" class="instuctions-padding" [innerHTML]="instructions | markdown"></div>
|
||||
</ng-template>
|
||||
</ion-content>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user