refactor: isolate network toast and login redirect to separate services (#1412)

* refactor: isolate network toast and login redirect to separate services

* chore: remove accidentally committed sketch of a service

* chore: tidying things up

* feat: add `GlobalModule` encapsulating all global subscription services

* remove angular build cache when building deps

* chore: fix more issues found while testing

* chore: fix issues reported by testing

* chore: fix template error

* chore: fix server-info

* chore: fix server-info

* fix: switch to Observable to fix race conditions

* fix embassy name display on load

* update patchdb

* clean up patch data watch

Co-authored-by: Lucy Cifferello <12953208+elvece@users.noreply.github.com>
This commit is contained in:
Alex Inkin
2022-05-27 01:56:47 +03:00
committed by GitHub
parent 4829637b46
commit 4f3223d3ad
88 changed files with 1379 additions and 1079 deletions

View File

@@ -10,7 +10,7 @@
"check:diagnostic-ui": "tsc --project projects/diagnostic-ui/tsconfig.json --noEmit --skipLibCheck",
"check:setup-wizard": "tsc --project projects/setup-wizard/tsconfig.json --noEmit --skipLibCheck",
"check:ui": "tsc --project projects/ui/tsconfig.json --noEmit --skipLibCheck",
"build:deps": "cd ../patch-db/client && npm install && npm run build",
"build:deps": "rm -rf .angular/cache && cd ../patch-db/client && npm install && npm run build",
"build:diagnostic-ui": "ng run diagnostic-ui:build",
"build:setup-wizard": "ng run setup-wizard:build",
"build:ui": "ng run ui:build && tsc projects/ui/postprocess.ts && node projects/ui/postprocess.js && git log | head -n1 > dist/ui/git-hash.txt",

View File

@@ -1,5 +1,6 @@
import { Injectable } from '@angular/core'
import { HttpClient, HttpErrorResponse } from '@angular/common/http'
import { HttpError, RpcError } from '@start9labs/shared'
@Injectable({
providedIn: 'root',
@@ -25,31 +26,6 @@ export class HttpService {
}
}
function RpcError(e: RPCError['error']): void {
const { code, message, data } = e
this.code = code
if (typeof data === 'string') {
this.message = `${message}\n\n${data}`
} else {
if (data.details) {
this.message = `${message}\n\n${data.details}`
} else {
this.message = message
}
}
}
function HttpError(e: HttpErrorResponse): void {
const { status, statusText } = e
this.code = status
this.message = statusText
this.details = null
this.revision = null
}
function isRpcError<Error, Result>(
arg: { error: Error } | { result: Result },
): arg is { error: Error } {
@@ -102,7 +78,3 @@ export interface RPCError extends RPCBase {
}
export type RPCResponse<T> = RPCSuccess<T> | RPCError
type HttpError = HttpErrorResponse & {
error: { code: string; message: string }
}

View File

@@ -29,7 +29,7 @@ export class AppComponent {
this.stateService.isMigrating = false
await this.navCtrl.navigateForward(`/recover`)
}
} catch (e) {
} catch (e: any) {
this.errorToastService.present(e)
}
}

View File

@@ -73,7 +73,7 @@ export class EmbassyPage {
})
await alert.present()
}
} catch (e) {
} catch (e: any) {
this.errorToastService.present(e)
} finally {
this.loading = false
@@ -141,7 +141,7 @@ export class EmbassyPage {
} else {
await this.navCtrl.navigateForward(`/success`)
}
} catch (e) {
} catch (e: any) {
this.errorToastService.present({
message: `${e.message}\n\nRestart Embassy to try again.`,
})

View File

@@ -119,7 +119,7 @@ export class RecoverPage {
await alert.present()
this.hasShownGuidAlert = true
}
} catch (e) {
} catch (e: any) {
this.errorToastService.present(e)
} finally {
this.loading = false
@@ -205,7 +205,7 @@ export class RecoverPage {
try {
await this.stateService.importDrive(guid)
await this.navCtrl.navigateForward(`/success`)
} catch (e) {
} catch (e: any) {
this.errorToastService.present(e)
} finally {
loader.dismiss()

View File

@@ -30,7 +30,7 @@ export class SuccessPage {
encodeURIComponent(this.stateService.cert),
)
this.download()
} catch (e) {
} catch (e: any) {
await this.errCtrl.present(e)
}
}

View File

@@ -8,6 +8,7 @@ import {
import { Observable } from 'rxjs'
import * as aesjs from 'aes-js'
import * as pbkdf2 from 'pbkdf2'
import { HttpError, RpcError } from '@start9labs/shared'
@Injectable({
providedIn: 'root',
@@ -80,7 +81,7 @@ export class HttpService {
.then(res => JSON.parse(res))
.catch(e => {
if (!e.status && !e.statusText) {
throw new EncryptionError(e)
throw new EncryptionError()
} else {
throw new HttpError(e)
}
@@ -120,34 +121,10 @@ export class HttpService {
}
}
function RpcError(e: RPCError['error']): void {
const { code, message, data } = e
this.code = code
if (typeof data === 'string') {
this.message = `${message}\n\n${data}`
} else {
if (data.details) {
this.message = `${message}\n\n${data.details}`
} else {
this.message = message
}
}
}
function HttpError(e: HttpErrorResponse): void {
const { status, statusText } = e
this.code = status
this.message = statusText
this.details = null
}
function EncryptionError(e: HttpErrorResponse): void {
this.code = null
this.message = 'Invalid Key'
this.details = null
class EncryptionError {
readonly code = null
readonly message = 'Invalid Key'
readonly details = null
}
function isRpcError<Error, Result>(
@@ -205,10 +182,6 @@ export interface RPCError extends RPCBase {
export type RPCResponse<T> = RPCSuccess<T> | RPCError
type HttpError = HttpErrorResponse & {
error: { code: string; message: string }
}
export interface HttpOptions {
method: Method
url: string

View File

@@ -49,7 +49,7 @@ export class StateService {
let progress
try {
progress = await this.apiService.getRecoveryStatus()
} catch (e) {
} catch (e: any) {
this.errorToastService.present({
message: `${e.message}\n\nRestart Embassy to try again.`,
})

View File

@@ -0,0 +1,10 @@
import { HttpErrorResponse } from '@angular/common/http'
export class HttpError {
readonly code = this.error.status
readonly message = this.error.statusText
readonly details = null
readonly revision = null
constructor(private readonly error: HttpErrorResponse) {}
}

View File

@@ -0,0 +1,25 @@
import { RpcErrorDetails } from '../types/rpc-error-details'
export class RpcError<T> {
readonly code = this.error.code
readonly message = this.getMessage()
readonly revision = this.getRevision()
constructor(private readonly error: RpcErrorDetails<T>) {}
private getMessage(): string {
if (typeof this.error.data === 'string') {
return `${this.error.message}\n\n${this.error.data}`
}
return this.error.data.details
? `${this.error.message}\n\n${this.error.data.details}`
: this.error.message
}
private getRevision(): T | null {
return typeof this.error.data === 'string'
? null
: this.error.data.revision || null
}
}

View File

@@ -2,6 +2,9 @@
* Public API Surface of @start9labs/shared
*/
export * from './classes/http-error'
export * from './classes/rpc-error'
export * from './components/markdown/markdown.component'
export * from './components/markdown/markdown.module'
export * from './components/text-spinner/text-spinner.component.module'
@@ -27,6 +30,7 @@ export * from './services/destroy.service'
export * from './services/emver.service'
export * from './services/error-toast.service'
export * from './types/rpc-error-details'
export * from './types/url'
export * from './types/workspace-config'

View File

@@ -0,0 +1,10 @@
export interface RpcErrorDetails<T> {
code: number
message: string
data?:
| {
details: string
revision?: T | null
}
| string
}

View File

@@ -14,14 +14,14 @@ export function capitalizeFirstLetter(string: string): string {
return string.charAt(0).toUpperCase() + string.slice(1)
}
export const exists = (t: any) => {
export function exists<T>(t: T | undefined): t is T {
return t !== undefined
}
export function debounce(delay: number = 300): MethodDecorator {
return function (
target: any,
propertyKey: string,
propertyKey: string | symbol,
descriptor: PropertyDescriptor,
) {
const timeoutKey = Symbol()

View File

@@ -1,8 +1,8 @@
<ion-app>
<ion-app appEnter>
<ion-content>
<ion-split-pane
contentId="main-content"
[disabled]="!showMenu"
[disabled]="!(authService.isVerified$ | async)"
(ionSplitPaneVisible)="splitPaneVisible($event)"
>
<ion-menu contentId="main-content" type="overlay">

View File

@@ -1,40 +1,6 @@
import { Component, HostListener, NgZone } from '@angular/core'
import { Router } from '@angular/router'
import {
AlertController,
IonicSafeString,
LoadingController,
ModalController,
ToastController,
} from '@ionic/angular'
import { ToastButton } from '@ionic/core'
import { Storage } from '@ionic/storage-angular'
import {
debounce,
isEmptyObject,
Emver,
ErrorToastService,
} from '@start9labs/shared'
import { Subscription } from 'rxjs'
import {
debounceTime,
distinctUntilChanged,
filter,
take,
} from 'rxjs/operators'
import { AuthService, AuthState } from './services/auth.service'
import { ApiService } from './services/api/embassy-api.service'
import { Component } from '@angular/core'
import { AuthService } from './services/auth.service'
import { SplitPaneTracker } from './services/split-pane.service'
import { PatchDbService } from './services/patch-db/patch-db.service'
import {
ConnectionFailure,
ConnectionService,
} from './services/connection.service'
import { ConfigService } from './services/config.service'
import { UIData } from 'src/app/services/patch-db/data-model'
import { LocalStorageService } from './services/local-storage.service'
import { EOSService } from './services/eos.service'
import { OSWelcomePage } from './modals/os-welcome/os-welcome.page'
@Component({
selector: 'app-root',
@@ -42,333 +8,12 @@ import { OSWelcomePage } from './modals/os-welcome/os-welcome.page'
styleUrls: ['app.component.scss'],
})
export class AppComponent {
showMenu = false
offlineToast: HTMLIonToastElement
updateToast: HTMLIonToastElement
notificationToast: HTMLIonToastElement
subscriptions: Subscription[] = []
constructor(
private readonly storage: Storage,
private readonly authService: AuthService,
private readonly router: Router,
private readonly embassyApi: ApiService,
private readonly alertCtrl: AlertController,
private readonly loadingCtrl: LoadingController,
private readonly emver: Emver,
private readonly connectionService: ConnectionService,
private readonly modalCtrl: ModalController,
private readonly toastCtrl: ToastController,
private readonly errToast: ErrorToastService,
private readonly config: ConfigService,
private readonly zone: NgZone,
readonly authService: AuthService,
private readonly splitPane: SplitPaneTracker,
private readonly patch: PatchDbService,
private readonly localStorageService: LocalStorageService,
private readonly eosService: EOSService,
) {
this.init()
}
@HostListener('document:keydown.enter', ['$event'])
@debounce()
handleKeyboardEvent() {
const elems = document.getElementsByClassName('enter-click')
const elem = elems[elems.length - 1] as HTMLButtonElement
if (elem && !elem.classList.contains('no-click') && !elem.disabled) {
elem.click()
}
}
) {}
splitPaneVisible({ detail }: any) {
this.splitPane.sidebarOpen$.next(detail.visible)
}
async init() {
await this.storage.create()
await this.authService.init()
await this.localStorageService.init()
this.router.initialNavigation()
// watch auth
this.authService.watch$().subscribe(async auth => {
// VERIFIED
if (auth === AuthState.VERIFIED) {
await this.patch.start()
this.showMenu = true
// if on the login screen, route to dashboard
if (this.router.url.startsWith('/login')) {
this.router.navigate([''], { replaceUrl: true })
}
this.subscriptions = this.subscriptions.concat([
// start the connection monitor
...this.connectionService.start(),
// watch connection to display connectivity issues
this.watchConnection(),
])
this.patch
.watch$()
.pipe(
filter(obj => !isEmptyObject(obj)),
take(1),
)
.subscribe(data => {
// check for updates to EOS
this.checkForEosUpdate(data.ui)
// show eos welcome message
this.showEosWelcome(data.ui['ack-welcome'])
this.subscriptions = this.subscriptions.concat([
// watch status to present toast for updated state
this.watchStatus(),
// watch version to refresh browser window
this.watchVersion(),
// watch unread notification count to display toast
this.watchNotifications(),
])
})
// UNVERIFIED
} else if (auth === AuthState.UNVERIFIED) {
this.subscriptions.forEach(sub => sub.unsubscribe())
this.subscriptions = []
this.showMenu = false
this.patch.stop()
this.storage.clear()
if (this.errToast) this.errToast.dismiss()
if (this.updateToast) this.updateToast.dismiss()
if (this.notificationToast) this.notificationToast.dismiss()
if (this.offlineToast) this.offlineToast.dismiss()
this.zone.run(() => {
this.router.navigate(['/login'], { replaceUrl: true })
})
}
})
}
private checkForEosUpdate(ui: UIData): void {
if (ui['auto-check-updates'] !== false) {
this.eosService.getEOS()
}
}
private async showEosWelcome(ackVersion: string): Promise<void> {
if (!this.config.skipStartupAlerts && ackVersion !== this.config.version) {
const modal = await this.modalCtrl.create({
component: OSWelcomePage,
presentingElement: await this.modalCtrl.getTop(),
backdropDismiss: false,
componentProps: {
version: this.config.version,
},
})
modal.onWillDismiss().then(() => {
this.embassyApi
.setDbValue({ pointer: '/ack-welcome', value: this.config.version })
.catch()
})
modal.present()
}
}
private watchConnection(): Subscription {
return this.connectionService
.watchFailure$()
.pipe(distinctUntilChanged(), debounceTime(500))
.subscribe(async connectionFailure => {
if (connectionFailure === ConnectionFailure.None) {
if (this.offlineToast) {
await this.offlineToast.dismiss()
this.offlineToast = undefined
}
} else {
let message: string | IonicSafeString
let link: string
switch (connectionFailure) {
case ConnectionFailure.Network:
message = 'Phone or computer has no network connection.'
break
case ConnectionFailure.Tor:
message = 'Browser unable to connect over Tor.'
link = 'https://start9.com/latest/support/common-issues'
break
case ConnectionFailure.Lan:
message = 'Embassy not found on Local Area Network.'
link = 'https://start9.com/latest/support/common-issues'
break
}
await this.presentToastOffline(message, link)
}
})
}
private watchStatus(): Subscription {
return this.patch
.watch$('server-info', 'status-info', 'updated')
.subscribe(isUpdated => {
if (isUpdated && !this.updateToast) {
this.presentToastUpdated()
}
})
}
private watchVersion(): Subscription {
return this.patch.watch$('server-info', 'version').subscribe(version => {
if (this.emver.compare(this.config.version, version) !== 0) {
this.presentAlertRefreshNeeded()
}
})
}
private watchNotifications(): Subscription {
let previous: number
return this.patch
.watch$('server-info', 'unread-notification-count')
.subscribe(count => {
if (previous !== undefined && count > previous)
this.presentToastNotifications()
previous = count
})
}
private async presentAlertRefreshNeeded() {
const alert = await this.alertCtrl.create({
backdropDismiss: true,
header: 'Refresh Needed',
message:
'Your user interface is cached and out of date. Hard refresh the page to get the latest UI.',
buttons: [
{
text: 'Refresh Page',
cssClass: 'enter-click',
handler: () => {
location.reload()
},
},
],
})
await alert.present()
}
private async presentToastUpdated() {
if (this.updateToast) return
this.updateToast = await this.toastCtrl.create({
header: 'EOS download complete!',
message:
'Restart your Embassy for these updates to take effect. It can take several minutes to come back online.',
position: 'bottom',
duration: 0,
cssClass: 'success-toast',
buttons: [
{
side: 'start',
icon: 'close',
handler: () => {
return true
},
},
{
side: 'end',
text: 'Restart',
handler: () => {
this.restart()
},
},
],
})
await this.updateToast.present()
}
private async presentToastNotifications() {
if (this.notificationToast) return
this.notificationToast = await this.toastCtrl.create({
header: 'Embassy',
message: `New notifications`,
position: 'bottom',
duration: 4000,
buttons: [
{
side: 'start',
icon: 'close',
handler: () => {
return true
},
},
{
side: 'end',
text: 'View',
handler: () => {
this.router.navigate(['/notifications'], {
queryParams: { toast: true },
})
},
},
],
})
await this.notificationToast.present()
}
private async presentToastOffline(
message: string | IonicSafeString,
link?: string,
) {
if (this.offlineToast) {
this.offlineToast.message = message
return
}
let buttons: ToastButton[] = [
{
side: 'start',
icon: 'close',
handler: () => {
return true
},
},
]
if (link) {
buttons.push({
side: 'end',
text: 'View solutions',
handler: () => {
window.open(link, '_blank', 'noreferrer')
return false
},
})
}
this.offlineToast = await this.toastCtrl.create({
header: 'Unable to Connect',
cssClass: 'warning-toast',
message,
position: 'bottom',
duration: 0,
buttons,
})
await this.offlineToast.present()
}
private async restart(): Promise<void> {
const loader = await this.loadingCtrl.create({
spinner: 'lines',
message: 'Restarting...',
cssClass: 'loader',
})
await loader.present()
try {
await this.embassyApi.restartServer({})
} catch (e) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
}

View File

@@ -1,36 +1,23 @@
import { NgModule, ErrorHandler } from '@angular/core'
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
import { RouteReuseStrategy } from '@angular/router'
import { IonicModule, IonicRouteStrategy, IonNav } from '@ionic/angular'
import { Drivers } from '@ionic/storage'
import { IonicStorageModule, Storage } from '@ionic/storage-angular'
import { HttpClientModule } from '@angular/common/http'
import { NgModule } from '@angular/core'
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
import { IonicModule } from '@ionic/angular'
import { Drivers } from '@ionic/storage'
import { IonicStorageModule } from '@ionic/storage-angular'
import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'
import { MarkdownModule, SharedPipesModule } from '@start9labs/shared'
import { AppComponent } from './app.component'
import { AppRoutingModule } from './app-routing.module'
import { ApiService } from './services/api/embassy-api.service'
import { PatchDbServiceFactory } from './services/patch-db/patch-db.factory'
import { ConfigService } from './services/config.service'
import { OSWelcomePageModule } from './modals/os-welcome/os-welcome.module'
import { PatchDbService } from './services/patch-db/patch-db.service'
import { LocalStorageBootstrap } from './services/patch-db/local-storage-bootstrap'
import { FormBuilder } from '@angular/forms'
import { GenericInputComponentModule } from './modals/generic-input/generic-input.component.module'
import { AuthService } from './services/auth.service'
import { GlobalErrorHandler } from './services/global-error-handler.service'
import { MockApiService } from './services/api/embassy-mock-api.service'
import { LiveApiService } from './services/api/embassy-live-api.service'
import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'
import {
MarkdownModule,
SharedPipesModule,
WorkspaceConfig,
} from '@start9labs/shared'
import { MarketplaceModule } from './marketplace.module'
import { PreloaderModule } from './app/preloader/preloader.module'
import { FooterModule } from './app/footer/footer.module'
import { MenuModule } from './app/menu/menu.module'
const { useMocks } = require('../../../../config.json') as WorkspaceConfig
import { EnterModule } from './app/enter/enter.module'
import { APP_PROVIDERS } from './app.providers'
import { GlobalModule } from './app/global/global.module'
@NgModule({
declarations: [AppComponent],
@@ -51,40 +38,16 @@ const { useMocks } = require('../../../../config.json') as WorkspaceConfig
MenuModule,
PreloaderModule,
FooterModule,
EnterModule,
OSWelcomePageModule,
MarkdownModule,
GenericInputComponentModule,
MonacoEditorModule,
SharedPipesModule,
MarketplaceModule,
GlobalModule,
],
providers: [
FormBuilder,
IonNav,
{
provide: RouteReuseStrategy,
useClass: IonicRouteStrategy,
},
{
provide: ApiService,
useClass: useMocks ? MockApiService : LiveApiService,
},
{
provide: PatchDbService,
useFactory: PatchDbServiceFactory,
deps: [
ConfigService,
ApiService,
LocalStorageBootstrap,
AuthService,
Storage,
],
},
{
provide: ErrorHandler,
useClass: GlobalErrorHandler,
},
],
providers: APP_PROVIDERS,
bootstrap: [AppComponent],
})
export class AppModule {}

View File

@@ -0,0 +1,65 @@
import { DOCUMENT } from '@angular/common'
import { APP_INITIALIZER, ErrorHandler, Provider } from '@angular/core'
import { FormBuilder } from '@angular/forms'
import { Router, RouteReuseStrategy } from '@angular/router'
import { IonicRouteStrategy, IonNav } from '@ionic/angular'
import { Storage } from '@ionic/storage-angular'
import { WorkspaceConfig } from '@start9labs/shared'
import { ApiService } from './services/api/embassy-api.service'
import { MockApiService } from './services/api/embassy-mock-api.service'
import { LiveApiService } from './services/api/embassy-live-api.service'
import {
PATCH_SOURCE,
mockSourceFactory,
realSourceFactory,
} from './services/patch-db/patch-db.factory'
import { ConfigService } from './services/config.service'
import { GlobalErrorHandler } from './services/global-error-handler.service'
import { AuthService } from './services/auth.service'
import { LocalStorageService } from './services/local-storage.service'
const { useMocks } = require('../../../../config.json') as WorkspaceConfig
export const APP_PROVIDERS: Provider[] = [
FormBuilder,
IonNav,
{
provide: RouteReuseStrategy,
useClass: IonicRouteStrategy,
},
{
provide: ApiService,
useClass: useMocks ? MockApiService : LiveApiService,
},
{
provide: PATCH_SOURCE,
deps: [ApiService, ConfigService, DOCUMENT],
useFactory: useMocks ? mockSourceFactory : realSourceFactory,
},
{
provide: ErrorHandler,
useClass: GlobalErrorHandler,
},
{
provide: APP_INITIALIZER,
deps: [Storage, AuthService, LocalStorageService, Router],
useFactory: appInitializer,
multi: true,
},
]
export function appInitializer(
storage: Storage,
auth: AuthService,
localStorage: LocalStorageService,
router: Router,
): () => Promise<void> {
return async () => {
await storage.create()
await auth.init()
await localStorage.init()
router.initialNavigation()
}
}

View File

@@ -0,0 +1,21 @@
import { Directive, HostListener, Inject } from '@angular/core'
import { DOCUMENT } from '@angular/common'
import { debounce } from '@start9labs/shared'
@Directive({
selector: '[appEnter]',
})
export class EnterDirective {
constructor(@Inject(DOCUMENT) private readonly document: Document) {}
@HostListener('document:keydown.enter')
@debounce()
handleKeyboardEvent() {
const elems = this.document.querySelectorAll('.enter-click')
const elem = elems[elems.length - 1] as HTMLButtonElement
if (elem && !elem.classList.contains('no-click') && !elem.disabled) {
elem.click()
}
}
}

View File

@@ -0,0 +1,9 @@
import { NgModule } from '@angular/core'
import { EnterDirective } from './enter.directive'
@NgModule({
declarations: [EnterDirective],
exports: [EnterDirective],
})
export class EnterModule {}

View File

@@ -0,0 +1,64 @@
import {
ClassProvider,
ExistingProvider,
Inject,
InjectionToken,
NgModule,
OnDestroy,
Type,
} from '@angular/core'
import { merge, Observable } from 'rxjs'
import { OfflineService } from './services/offline.service'
import { LogoutService } from './services/logout.service'
import { PatchMonitorService } from './services/patch-monitor.service'
import { PatchDataService } from './services/patch-data.service'
import { ConnectionMonitorService } from './services/connection-monitor.service'
import { UnreadToastService } from './services/unread-toast.service'
import { RefreshToastService } from './services/refresh-toast.service'
import { UpdateToastService } from './services/update-toast.service'
const GLOBAL_SERVICE = new InjectionToken<readonly Observable<unknown>[]>(
'A multi token of global Observable services',
)
@NgModule({
providers: [
[
ConnectionMonitorService,
LogoutService,
OfflineService,
RefreshToastService,
UnreadToastService,
UpdateToastService,
].map(useClass),
[PatchDataService, PatchMonitorService].map(useExisting),
],
})
export class GlobalModule implements OnDestroy {
readonly subscription = merge(...this.services).subscribe()
constructor(
@Inject(GLOBAL_SERVICE)
private readonly services: readonly Observable<unknown>[],
) {}
ngOnDestroy() {
this.subscription.unsubscribe()
}
}
function useClass(useClass: Type<unknown>): ClassProvider {
return {
provide: GLOBAL_SERVICE,
multi: true,
useClass,
}
}
function useExisting(useExisting: Type<unknown>): ExistingProvider {
return {
provide: GLOBAL_SERVICE,
multi: true,
useExisting,
}
}

View File

@@ -0,0 +1,21 @@
import { Injectable } from '@angular/core'
import { EMPTY, Observable } from 'rxjs'
import { switchMap } from 'rxjs/operators'
import { PatchMonitorService } from './patch-monitor.service'
import { ConnectionService } from 'src/app/services/connection.service'
// Start connection monitor upon PatchDb start
@Injectable()
export class ConnectionMonitorService extends Observable<unknown> {
private readonly stream$ = this.patchMonitor.pipe(
switchMap(started => (started ? this.connectionService.start() : EMPTY)),
)
constructor(
private readonly patchMonitor: PatchMonitorService,
private readonly connectionService: ConnectionService,
) {
super(subscriber => this.stream$.subscribe(subscriber))
}
}

View File

@@ -0,0 +1,27 @@
import { Injectable, NgZone } from '@angular/core'
import { Router } from '@angular/router'
import { filter, tap } from 'rxjs/operators'
import { Observable } from 'rxjs'
import { AuthService } from 'src/app/services/auth.service'
// Redirect to login page upon broken authorization
@Injectable()
export class LogoutService extends Observable<unknown> {
private readonly stream$ = this.authService.isVerified$.pipe(
filter(verified => !verified),
tap(() => {
this.zone.run(() => {
this.router.navigate(['/login'], { replaceUrl: true })
})
}),
)
constructor(
private readonly authService: AuthService,
private readonly zone: NgZone,
private readonly router: Router,
) {
super(subscriber => this.stream$.subscribe(subscriber))
}
}

View File

@@ -0,0 +1,115 @@
import { Injectable } from '@angular/core'
import { IonicSafeString, ToastController, ToastOptions } from '@ionic/angular'
import { ToastButton } from '@ionic/core'
import { EMPTY, from, Observable } from 'rxjs'
import {
debounceTime,
distinctUntilChanged,
filter,
map,
switchMap,
tap,
} from 'rxjs/operators'
import { AuthService } from 'src/app/services/auth.service'
import {
ConnectionFailure,
ConnectionService,
} from 'src/app/services/connection.service'
// Watch for connection status
@Injectable()
export class OfflineService extends Observable<unknown> {
private current?: HTMLIonToastElement
private readonly connection$ = this.connectionService
.watchFailure$()
.pipe(distinctUntilChanged(), debounceTime(500))
private readonly stream$ = this.authService.isVerified$.pipe(
// Close on logout
tap(() => this.current?.dismiss()),
switchMap(verified => (verified ? this.connection$ : EMPTY)),
// Close on change to connection state
tap(() => this.current?.dismiss()),
filter(connection => connection !== ConnectionFailure.None),
map(getMessage),
switchMap(({ message, link }) =>
this.getToast().pipe(
tap(toast => {
this.current = toast
toast.message = message
toast.buttons = getButtons(link)
toast.present()
}),
),
),
)
constructor(
private readonly authService: AuthService,
private readonly connectionService: ConnectionService,
private readonly toastCtrl: ToastController,
) {
super(subscriber => this.stream$.subscribe(subscriber))
}
private getToast(): Observable<HTMLIonToastElement> {
return from(this.toastCtrl.create(TOAST))
}
}
const TOAST: ToastOptions = {
header: 'Unable to Connect',
cssClass: 'warning-toast',
message: '',
position: 'bottom',
duration: 0,
buttons: [],
}
function getMessage(failure: ConnectionFailure): OfflineMessage {
switch (failure) {
case ConnectionFailure.Network:
return { message: 'Phone or computer has no network connection.' }
case ConnectionFailure.Tor:
return {
message: 'Browser unable to connect over Tor.',
link: 'https://start9.com/latest/support/common-issues',
}
case ConnectionFailure.Lan:
return {
message: 'Embassy not found on Local Area Network.',
link: 'https://start9.com/latest/support/common-issues',
}
}
}
function getButtons(link?: string): ToastButton[] {
const buttons: ToastButton[] = [
{
side: 'start',
icon: 'close',
handler: () => true,
},
]
if (link) {
buttons.push({
side: 'end',
text: 'View solutions',
handler: () => {
window.open(link, '_blank', 'noreferrer')
return false
},
})
}
return buttons
}
interface OfflineMessage {
readonly message: string | IonicSafeString
readonly link?: string
}

View File

@@ -0,0 +1,80 @@
import { Injectable } from '@angular/core'
import { ModalController } from '@ionic/angular'
import { Storage } from '@ionic/storage-angular'
import { Observable, of } from 'rxjs'
import { filter, share, switchMap, take, tap } from 'rxjs/operators'
import { isEmptyObject } from '@start9labs/shared'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { AuthService } from 'src/app/services/auth.service'
import { DataModel, UIData } from 'src/app/services/patch-db/data-model'
import { EOSService } from 'src/app/services/eos.service'
import { OSWelcomePage } from 'src/app/modals/os-welcome/os-welcome.page'
import { ConfigService } from 'src/app/services/config.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { PatchMonitorService } from './patch-monitor.service'
// Get data from PatchDb after is starts and act upon it
@Injectable({
providedIn: 'root',
})
export class PatchDataService extends Observable<DataModel | null> {
private readonly stream$ = this.patchMonitor.pipe(
switchMap(started =>
started
? this.patch.watch$().pipe(
filter(obj => !isEmptyObject(obj)),
take(1),
tap(({ ui }) => {
// check for updates to EOS
this.checkForEosUpdate(ui)
// show eos welcome message
this.showEosWelcome(ui['ack-welcome'])
}),
)
: of(null),
),
share(),
)
constructor(
private readonly patchMonitor: PatchMonitorService,
private readonly authService: AuthService,
private readonly patch: PatchDbService,
private readonly storage: Storage,
private readonly eosService: EOSService,
private readonly config: ConfigService,
private readonly modalCtrl: ModalController,
private readonly embassyApi: ApiService,
) {
super(subscriber => this.stream$.subscribe(subscriber))
}
private checkForEosUpdate(ui: UIData): void {
if (ui['auto-check-updates'] !== false) {
this.eosService.getEOS()
}
}
private async showEosWelcome(ackVersion: string): Promise<void> {
if (this.config.skipStartupAlerts || ackVersion === this.config.version) {
return
}
const modal = await this.modalCtrl.create({
component: OSWelcomePage,
presentingElement: await this.modalCtrl.getTop(),
backdropDismiss: false,
componentProps: {
version: this.config.version,
},
})
modal.onWillDismiss().then(() => {
this.embassyApi
.setDbValue({ pointer: '/ack-welcome', value: this.config.version })
.catch()
})
await modal.present()
}
}

View File

@@ -0,0 +1,37 @@
import { Injectable } from '@angular/core'
import { Storage } from '@ionic/storage-angular'
import { from, Observable, of } from 'rxjs'
import { mapTo, share, switchMap } from 'rxjs/operators'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { AuthService } from 'src/app/services/auth.service'
import { ConnectionService } from 'src/app/services/connection.service'
// Start and stop PatchDb upon verification
@Injectable({
providedIn: 'root',
})
export class PatchMonitorService extends Observable<boolean> {
private readonly stream$ = this.authService.isVerified$.pipe(
switchMap(verified => {
if (verified) {
return from(this.patch.start()).pipe(mapTo(true))
}
this.patch.stop()
this.storage.clear()
return of(false)
}),
share(),
)
constructor(
private readonly connectionService: ConnectionService,
private readonly authService: AuthService,
private readonly patch: PatchDbService,
private readonly storage: Storage,
) {
super(subscriber => this.stream$.subscribe(subscriber))
}
}

View File

@@ -0,0 +1,52 @@
import { Injectable } from '@angular/core'
import { AlertController, AlertOptions } from '@ionic/angular'
import { EMPTY, from, Observable } from 'rxjs'
import { filter, switchMap } from 'rxjs/operators'
import { Emver } from '@start9labs/shared'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { ConfigService } from 'src/app/services/config.service'
import { PatchDataService } from './patch-data.service'
// Watch version to refresh browser window
@Injectable()
export class RefreshToastService extends Observable<unknown> {
private readonly stream$ = this.patchData.pipe(
switchMap(data =>
data ? this.patch.watch$('server-info', 'version') : EMPTY,
),
filter(version => !!this.emver.compare(this.config.version, version)),
switchMap(() => this.getAlert()),
switchMap(alert => alert.present()),
)
constructor(
private readonly patchData: PatchDataService,
private readonly patch: PatchDbService,
private readonly emver: Emver,
private readonly config: ConfigService,
private readonly alertCtrl: AlertController,
) {
super(subscriber => this.stream$.subscribe(subscriber))
}
private getAlert(): Observable<HTMLIonAlertElement> {
return from(this.alertCtrl.create(ALERT))
}
}
const ALERT: AlertOptions = {
backdropDismiss: true,
header: 'Refresh Needed',
message:
'Your user interface is cached and out of date. Hard refresh the page to get the latest UI.',
buttons: [
{
text: 'Refresh Page',
cssClass: 'enter-click',
handler: () => {
location.reload()
},
},
],
}

View File

@@ -0,0 +1,80 @@
import { Injectable } from '@angular/core'
import { Router } from '@angular/router'
import {
LoadingController,
ToastController,
ToastOptions,
} from '@ionic/angular'
import { EMPTY, merge, Observable } from 'rxjs'
import { filter, pairwise, switchMap, tap } from 'rxjs/operators'
import { ErrorToastService } from '@start9labs/shared'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { ConfigService } from 'src/app/services/config.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { PatchDataService } from './patch-data.service'
// Watch unread notification count to display toast
@Injectable()
export class UnreadToastService extends Observable<unknown> {
private unreadToast: HTMLIonToastElement
private readonly stream$ = this.patchData.pipe(
switchMap(data => {
if (data) {
return this.patch.watch$('server-info', 'unread-notification-count')
}
this.unreadToast?.dismiss()
return EMPTY
}),
pairwise(),
filter(([prev, cur]) => cur > prev),
tap(() => {
this.showToast()
}),
)
constructor(
private readonly router: Router,
private readonly patchData: PatchDataService,
private readonly patch: PatchDbService,
private readonly config: ConfigService,
private readonly embassyApi: ApiService,
private readonly toastCtrl: ToastController,
) {
super(subscriber => this.stream$.subscribe(subscriber))
}
private async showToast() {
await this.unreadToast?.dismiss()
this.unreadToast = await this.toastCtrl.create(TOAST)
this.unreadToast.buttons.push({
side: 'end',
text: 'View',
handler: () => {
this.router.navigate(['/notifications'], {
queryParams: { toast: true },
})
},
})
await this.unreadToast.present()
}
}
const TOAST: ToastOptions = {
header: 'Embassy',
message: `New notifications`,
position: 'bottom',
duration: 4000,
buttons: [
{
side: 'start',
icon: 'close',
handler: () => true,
},
],
}

View File

@@ -0,0 +1,98 @@
import { Injectable } from '@angular/core'
import {
LoadingController,
LoadingOptions,
ToastController,
ToastOptions,
} from '@ionic/angular'
import { EMPTY, Observable } from 'rxjs'
import { distinctUntilChanged, filter, switchMap } from 'rxjs/operators'
import { ErrorToastService } from '@start9labs/shared'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { PatchDataService } from './patch-data.service'
// Watch status to present toast for updated state
@Injectable()
export class UpdateToastService extends Observable<unknown> {
private updateToast: HTMLIonToastElement
private readonly stream$ = this.patchData.pipe(
switchMap(data => {
if (data) {
return this.patch.watch$('server-info', 'status-info', 'updated')
}
this.errToast.dismiss()
this.updateToast?.dismiss()
return EMPTY
}),
distinctUntilChanged(),
filter(Boolean),
switchMap(() => this.showToast()),
)
constructor(
private readonly patchData: PatchDataService,
private readonly patch: PatchDbService,
private readonly embassyApi: ApiService,
private readonly toastCtrl: ToastController,
private readonly errToast: ErrorToastService,
private readonly loadingCtrl: LoadingController,
) {
super(subscriber => this.stream$.subscribe(subscriber))
}
private async showToast() {
await this.updateToast?.dismiss()
this.updateToast = await this.toastCtrl.create(TOAST)
this.updateToast.buttons.push({
side: 'end',
text: 'Restart',
handler: () => {
this.restart()
},
})
await this.updateToast.present()
}
private async restart(): Promise<void> {
const loader = await this.loadingCtrl.create(LOADER)
await loader.present()
try {
await this.embassyApi.restartServer({})
} catch (e: any) {
await this.errToast.present(e)
} finally {
await loader.dismiss()
}
}
}
const LOADER: LoadingOptions = {
spinner: 'lines',
message: 'Restarting...',
cssClass: 'loader',
}
const TOAST: ToastOptions = {
header: 'EOS download complete!',
message:
'Restart your Embassy for these updates to take effect. It can take several minutes to come back online.',
position: 'bottom',
duration: 0,
cssClass: 'success-toast',
buttons: [
{
side: 'start',
icon: 'close',
handler: () => true,
},
],
}

View File

@@ -44,7 +44,7 @@ export class SnekDirective {
pointer: '/gaming',
value: { snake: { 'high-score': data.highScore } },
})
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
this.loadingCtrl.dismiss()

View File

@@ -157,7 +157,7 @@ export class BackupDrivesComponent {
entry,
})
return true
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
return false
} finally {
@@ -209,9 +209,8 @@ export class BackupDrivesComponent {
try {
const res = await this.embassyApi.updateBackupTarget(value)
const entry = Object.values(res)[0]
this.backupService.cifs[index].entry = entry
} catch (e) {
this.backupService.cifs[index].entry = Object.values(res)[0]
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
@@ -229,7 +228,7 @@ export class BackupDrivesComponent {
try {
await this.embassyApi.removeBackupTarget({ id })
this.backupService.cifs.splice(index, 1)
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()

View File

@@ -48,7 +48,7 @@ export class BackupService {
entry: drive as DiskBackupTarget,
}
})
} catch (e) {
} catch (e: any) {
this.loadingError = getErrorMessage(e)
} finally {
this.loading = false

View File

@@ -212,8 +212,7 @@ export class FormObjectComponent {
component: EnumListPage,
})
modal.onWillDismiss().then((res: { data: string[] }) => {
const data = res.data
modal.onWillDismiss<string[]>().then(({ data }) => {
if (!data) return
this.updateEnumList(key, current, data)
})

View File

@@ -1,23 +1,23 @@
<div *ngIf="loading$ | async" 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>
<ion-label class="long-message">
Checking for installed services which depend on
{{ params.title }}...
</ion-label>
</div>
<div *ngIf="!(loading$ | async) && !!dependentViolation" 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;">
WARNING
</ion-label>
<div class="wrapper">
<div class="warning">
<ion-label color="warning" class="label">WARNING</ion-label>
</div>
<div class="long-message">
{{ dependentViolation }}
</div>
<div style="margin: 25px 0px;">
<div style="border-width: 0px 0px 1px 0px; font-size: unset; text-align: left; font-weight: bold; margin-left: 13px; border-style: solid; border-color: var(--ion-color-light-tint);">
<div *ngIf="patch.data['package-data']" class="items">
<div class="affected">
<ion-text color="warning">Affected Services</ion-text>
</div>
@@ -25,8 +25,11 @@
style="--ion-item-background: margin-top: 5px"
*ngFor="let dep of dependentBreakages | keyvalue"
>
<ion-thumbnail style="position: relative; height: 4vh; width: 4vh" slot="start">
<img [src]="patch.data['package-data'][dep.key]['static-files'].icon" />
<ion-thumbnail class="thumbnail" slot="start">
<img
alt=""
[src]="patch.data['package-data'][dep.key]['static-files'].icon"
/>
</ion-thumbnail>
<ion-label>
<h5>{{ patch.data['package-data'][dep.key].manifest.title }}</h5>
@@ -34,4 +37,4 @@
</ion-item>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,35 @@
.wrapper {
margin-top: 25px;
}
.warning {
margin: 15px;
display: flex;
justify-content: center;
align-items: center;
}
.label {
font-size: xx-large;
font-weight: bold;
}
.items {
margin: 25px 0;
}
.affected {
border-width: 0 0 1px 0;
font-size: unset;
text-align: left;
font-weight: bold;
margin-left: 13px;
border-style: solid;
border-color: var(--ion-color-light-tint);
}
.thumbnail {
position: relative;
height: 4vh;
width: 4vh;
}

View File

@@ -10,7 +10,10 @@ import { WizardAction } from '../wizard-types'
@Component({
selector: 'dependents',
templateUrl: './dependents.component.html',
styleUrls: ['../install-wizard.component.scss'],
styleUrls: [
'./dependents.component.scss',
'../install-wizard.component.scss',
],
})
export class DependentsComponent {
@Input() params: {

View File

@@ -54,7 +54,7 @@ export class LogsPage {
this.loading = false
return logsRes.entries
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
}
}

View File

@@ -1,38 +1,29 @@
import { Injectable } from '@angular/core'
import { CanActivate, Router, CanActivateChild } from '@angular/router'
import { tap } from 'rxjs/operators'
import { AuthState, AuthService } from '../services/auth.service'
import { CanActivate, Router, CanActivateChild, UrlTree } from '@angular/router'
import { map } from 'rxjs/operators'
import { AuthService } from '../services/auth.service'
import { Observable } from 'rxjs'
@Injectable({
providedIn: 'root',
})
export class AuthGuard implements CanActivate, CanActivateChild {
authState: AuthState
constructor (
constructor(
private readonly authService: AuthService,
private readonly router: Router,
) {
this.authService.watch$()
.pipe(
tap(auth => this.authState = auth),
).subscribe()
}
) {}
canActivate (): boolean {
canActivate(): Observable<boolean | UrlTree> {
return this.runAuthCheck()
}
canActivateChild (): boolean {
canActivateChild(): Observable<boolean | UrlTree> {
return this.runAuthCheck()
}
private runAuthCheck (): boolean {
if (this.authState === AuthState.VERIFIED) {
return true
} else {
this.router.navigate(['/login'], { replaceUrl: true })
return false
}
private runAuthCheck(): Observable<boolean | UrlTree> {
return this.authService.isVerified$.pipe(
map(verified => verified || this.router.parseUrl('/login')),
)
}
}

View File

@@ -1,31 +1,21 @@
import { Injectable } from '@angular/core'
import { CanActivate, Router } from '@angular/router'
import { tap } from 'rxjs/operators'
import { AuthService, AuthState } from '../services/auth.service'
import { CanActivate, Router, UrlTree } from '@angular/router'
import { map } from 'rxjs/operators'
import { AuthService } from '../services/auth.service'
import { Observable } from 'rxjs'
@Injectable({
providedIn: 'root',
})
export class UnauthGuard implements CanActivate {
authState: AuthState
constructor (
constructor(
private readonly authService: AuthService,
private readonly router: Router,
) {
this.authService.watch$()
.pipe(
tap(auth => this.authState = auth),
).subscribe()
}
) {}
canActivate (): boolean {
if (this.authState === AuthState.VERIFIED) {
this.router.navigateByUrl('')
return false
} else {
return true
}
canActivate(): Observable<boolean | UrlTree> {
return this.authService.isVerified$.pipe(
map(verified => !verified || this.router.parseUrl('')),
)
}
}

View File

@@ -6,7 +6,11 @@
</ion-button>
</ion-buttons>
<ion-title>Config</ion-title>
<ion-buttons *ngIf="!loadingText && !loadingError && hasConfig" slot="end" class="ion-padding-end">
<ion-buttons
*ngIf="!loadingText && !loadingError && hasConfig"
slot="end"
class="ion-padding-end"
>
<ion-button fill="clear" (click)="resetDefaults()">
<ion-icon slot="start" name="refresh"></ion-icon>
Reset Defaults
@@ -16,38 +20,52 @@
</ion-header>
<ion-content class="ion-padding">
<!-- loading -->
<text-spinner *ngIf="loadingText; else loaded" [text]="loadingText"></text-spinner>
<text-spinner *ngIf="loadingText" [text]="loadingText"></text-spinner>
<!-- not loading -->
<ng-template #loaded>
<ng-container *ngIf="!loadingText && pkg">
<ion-item *ngIf="loadingError; else noError">
<ion-label>
<ion-text color="danger">
{{ loadingError }}
</ion-text>
<ion-text color="danger"> {{ loadingError }} </ion-text>
</ion-label>
</ion-item>
<ng-template #noError>
<ion-item *ngIf="hasConfig && !pkg.installed.status.configured && !configForm.dirty">
<ion-item
*ngIf="hasConfig && !pkg.installed.status.configured && !configForm.dirty"
>
<ion-label>
<ion-text color="success">To use the default config for {{ pkg.manifest.title }}, click "Save" below.</ion-text>
<ion-text color="success"
>To use the default config for {{ pkg.manifest.title }}, click
"Save" below.</ion-text
>
</ion-label>
</ion-item>
<!-- auto-config -->
<ion-item lines="none" *ngIf="dependentInfo" class="rec-item" style="margin-bottom: 48px;">
<ion-item
lines="none"
*ngIf="dependentInfo"
class="rec-item"
style="margin-bottom: 48px"
>
<ion-label>
<h2 style="display: flex; align-items: center;">
<img style="width: 18px; margin: 4px;" [src]="pkg['static-files'].icon" [alt]="pkg.manifest.title"/>
<ion-text style="margin: 5px; font-family: 'Montserrat'; font-size: 18px;">{{ pkg.manifest.title }}</ion-text>
<h2 style="display: flex; align-items: center">
<img
style="width: 18px; margin: 4px"
[src]="pkg['static-files'].icon"
[alt]="pkg.manifest.title"
/>
<ion-text
style="margin: 5px; font-family: 'Montserrat'; font-size: 18px"
>{{ pkg.manifest.title }}</ion-text
>
</h2>
<p>
<ion-text color="dark">
The following modifications have been made to {{ pkg.manifest.title }} to satisfy {{ dependentInfo.title }}:
The following modifications have been made to {{
pkg.manifest.title }} to satisfy {{ dependentInfo.title }}:
<ul>
<li *ngFor="let d of diff" [innerHtml]="d"></li>
</ul>
@@ -56,14 +74,17 @@
</p>
</ion-label>
</ion-item>
<!-- no config -->
<ion-item *ngIf="!hasConfig">
<ion-label>
<p>No config options for {{ pkg.manifest.title }} {{ pkg.manifest.version }}.</p>
<p>
No config options for {{ pkg.manifest.title }} {{
pkg.manifest.version }}.
</p>
</ion-label>
</ion-item>
<!-- has config -->
<form *ngIf="hasConfig" [formGroup]="configForm" novalidate>
<form-object
@@ -74,16 +95,32 @@
></form-object>
</form>
</ng-template>
</ng-template>
</ng-container>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-buttons *ngIf="!loadingText && !loadingError" slot="end" class="ion-padding-end">
<ion-button *ngIf="hasConfig" fill="outline" [disabled]="saving" (click)="save()" class="enter-click" [class.no-click]="saving">
<ion-buttons
*ngIf="!loadingText && !loadingError"
slot="end"
class="ion-padding-end"
>
<ion-button
*ngIf="hasConfig"
fill="outline"
[disabled]="saving"
(click)="save()"
class="enter-click"
[class.no-click]="saving"
>
Save
</ion-button>
<ion-button *ngIf="!hasConfig" fill="outline" (click)="dismiss()" class="enter-click">
<ion-button
*ngIf="!hasConfig"
fill="outline"
(click)="dismiss()"
class="enter-click"
>
Close
</ion-button>
</ion-buttons>

View File

@@ -36,7 +36,7 @@ export class AppConfigPage {
@Input() pkgId: string
@Input() dependentInfo?: DependentInfo
diff: string[] // only if dependent info
pkg: PackageDataEntry
pkg?: PackageDataEntry
loadingText: string | undefined
configSpec: ConfigSpec
configForm: FormGroup
@@ -58,7 +58,7 @@ export class AppConfigPage {
async ngOnInit() {
this.pkg = this.patch.getData()['package-data'][this.pkgId]
this.hasConfig = !!this.pkg.manifest.config
this.hasConfig = !!this.pkg?.manifest.config
if (!this.hasConfig) return
@@ -102,7 +102,7 @@ export class AppConfigPage {
this.diff = this.getDiff(patch)
this.markDirty(patch)
}
} catch (e) {
} catch (e: any) {
this.loadingError = getErrorMessage(e)
} finally {
this.loadingText = undefined
@@ -133,7 +133,7 @@ export class AppConfigPage {
if (this.configForm.invalid) {
document
.getElementsByClassName('validation-error')[0]
.parentElement.parentElement.scrollIntoView({ behavior: 'smooth' })
?.parentElement.parentElement.scrollIntoView({ behavior: 'smooth' })
return
}
@@ -170,7 +170,7 @@ export class AppConfigPage {
config,
})
this.modalCtrl.dismiss()
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
this.saving = false

View File

@@ -82,7 +82,7 @@ export class AppRecoverSelectPage {
password: this.password,
})
this.modalCtrl.dismiss(undefined, 'success')
} catch (e) {
} catch (e: any) {
this.error = getErrorMessage(e)
} finally {
loader.dismiss()

View File

@@ -50,7 +50,7 @@ export class GenericFormPage {
this.formGroup.markAllAsTouched()
document
.getElementsByClassName('validation-error')[0]
.parentElement.parentElement.scrollIntoView({ behavior: 'smooth' })
?.parentElement.parentElement.scrollIntoView({ behavior: 'smooth' })
return
}

View File

@@ -52,7 +52,7 @@ export class GenericInputComponent {
try {
await this.options.submitFn(value)
this.modalCtrl.dismiss(undefined, 'success')
} catch (e) {
} catch (e: any) {
this.error = getErrorMessage(e)
}
}

View File

@@ -178,7 +178,7 @@ export class AppActionsPage {
})
setTimeout(() => successModal.present(), 400)
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
return false
} finally {

View File

@@ -1,9 +1,9 @@
import { ChangeDetectionStrategy, Component } from "@angular/core";
import { ChangeDetectionStrategy, Component } from '@angular/core'
@Component({
selector: "app-list-empty",
templateUrl: "app-list-empty.component.html",
styleUrls: ["app-list-empty.component.scss"],
selector: 'app-list-empty',
templateUrl: 'app-list-empty.component.html',
styleUrls: ['app-list-empty.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppListEmptyComponent {}

View File

@@ -18,6 +18,7 @@ export class AppListPage {
recoveredPkgs: readonly RecoveredInfo[] = []
order: readonly string[] = []
reordering = false
loading = true
constructor(
private readonly api: ApiService,
@@ -26,7 +27,9 @@ export class AppListPage {
) {}
get empty(): boolean {
return !this.pkgs.length && isEmptyObject(this.recoveredPkgs)
return (
!this.loading && !this.pkgs.length && isEmptyObject(this.recoveredPkgs)
)
}
ngOnInit() {
@@ -40,6 +43,7 @@ export class AppListPage {
this.pkgs = pkgs
this.recoveredPkgs = recoveredPkgs
this.order = order
this.loading = false
// set order in UI DB if there were unknown packages
if (order.length < pkgs.length) {

View File

@@ -12,10 +12,12 @@ export class PackageInfoPipe implements PipeTransform {
constructor(private readonly patch: PatchDbService) {}
transform(pkg: PackageDataEntry): Observable<PkgInfo> {
return this.patch.watch$('package-data', pkg.manifest.id).pipe(
filter(v => !!v),
map(getPackageInfo),
startWith(getPackageInfo(pkg)),
)
return this.patch
.watch$('package-data', pkg.manifest.id)
.pipe(
filter<PackageDataEntry>(Boolean),
startWith(pkg),
map(getPackageInfo),
)
}
}

View File

@@ -57,7 +57,7 @@ export class AppMetricsPage {
async getMetrics(): Promise<void> {
try {
this.metrics = await this.embassyApi.getPkgMetrics({ id: this.pkgId })
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
this.stopDaemon()
} finally {

View File

@@ -149,7 +149,7 @@ export class AppPropertiesPage {
id: this.pkgId,
})
this.node = getValueByPointer(this.properties, this.pointer || '')
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
this.loading = false

View File

@@ -122,7 +122,7 @@ export class AppShowStatusComponent {
try {
await this.embassyApi.stopPackage({ id })
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
@@ -149,7 +149,7 @@ export class AppShowStatusComponent {
try {
await this.embassyApi.startPackage({ id: this.pkg.manifest.id })
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()

View File

@@ -42,7 +42,7 @@ export class DevConfigPage {
let doc: any
try {
doc = yaml.load(this.code)
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
}
@@ -73,7 +73,7 @@ export class DevConfigPage {
pointer: `/dev/${this.projectId}/config`,
value: this.code,
})
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
this.saving = false

View File

@@ -60,7 +60,7 @@ export class DevInstructionsPage {
pointer: `/dev/${this.projectId}/instructions`,
value: this.code,
})
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
this.saving = false

View File

@@ -159,7 +159,7 @@ export class DeveloperListPage {
} else {
await this.api.setDbValue({ pointer: `/dev`, value: { [id]: def } })
}
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
@@ -197,7 +197,7 @@ export class DeveloperListPage {
try {
await this.api.setDbValue({ pointer: `/dev/${id}/name`, value: newName })
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
@@ -216,7 +216,7 @@ export class DeveloperListPage {
const devDataToSave: DevData = JSON.parse(JSON.stringify(this.devData))
delete devDataToSave[id]
await this.api.setDbValue({ pointer: `/dev`, value: devDataToSave })
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()

View File

@@ -81,7 +81,7 @@ export class DeveloperMenuPage {
pointer: `/dev/${this.projectId}/basic-info`,
value: basicInfo,
})
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()

View File

@@ -1,8 +1,8 @@
import { Component } from '@angular/core'
import { LoadingController, getPlatforms } from '@ionic/angular'
import { Subscription } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { AuthService } from 'src/app/services/auth.service'
import { Router } from '@angular/router'
@Component({
selector: 'login',
@@ -13,30 +13,24 @@ export class LoginPage {
password = ''
unmasked = false
error = ''
loader: HTMLIonLoadingElement
patchConnectionSub: Subscription
loader?: HTMLIonLoadingElement
constructor (
constructor(
private readonly router: Router,
private readonly authService: AuthService,
private readonly loadingCtrl: LoadingController,
private readonly api: ApiService,
) { }
) {}
ngOnDestroy () {
if (this.loader) {
this.loader.dismiss()
this.loader = undefined
}
if (this.patchConnectionSub) {
this.patchConnectionSub.unsubscribe()
}
ngOnDestroy() {
this.loader?.dismiss()
}
toggleMask () {
toggleMask() {
this.unmasked = !this.unmasked
}
async submit () {
async submit() {
this.error = ''
this.loader = await this.loadingCtrl.create({
@@ -53,9 +47,11 @@ export class LoginPage {
metadata: { platforms: getPlatforms() },
})
this.authService.setVerified()
this.password = ''
} catch (e) {
this.authService
.setVerified()
.then(() => this.router.navigate([''], { replaceUrl: true }))
} catch (e: any) {
this.error = e.code === 34 ? 'Invalid Password' : e.message
} finally {
this.loader.dismiss()

View File

@@ -1,6 +1,6 @@
import { Component } from '@angular/core'
import { Observable } from 'rxjs'
import { filter, first, map, startWith, switchMapTo, tap } from 'rxjs/operators'
import { filter, first, map, startWith, switchMapTo } from 'rxjs/operators'
import { exists, isEmptyObject } from '@start9labs/shared'
import {
AbstractMarketplaceService,
@@ -9,7 +9,6 @@ import {
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { spreadProgress } from '../utils/spread-progress'
@Component({
selector: 'marketplace-list',
@@ -20,7 +19,6 @@ export class MarketplaceListPage {
.watch$('package-data')
.pipe(
filter(data => exists(data) && !isEmptyObject(data)),
tap(pkgs => Object.values(pkgs).forEach(spreadProgress)),
startWith({}),
)

View File

@@ -7,11 +7,9 @@ import {
} from '@start9labs/marketplace'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { BehaviorSubject, defer, Observable, of } from 'rxjs'
import { BehaviorSubject, Observable, of } from 'rxjs'
import { catchError, filter, shareReplay, switchMap, tap } from 'rxjs/operators'
import { spreadProgress } from '../utils/spread-progress'
@Component({
selector: 'marketplace-show',
templateUrl: './marketplace-show.page.html',
@@ -23,13 +21,12 @@ export class MarketplaceShowPage {
readonly loadVersion$ = new BehaviorSubject<string>('*')
readonly localPkg$ = defer(() =>
this.patch.watch$('package-data', this.pkgId),
).pipe(
filter<PackageDataEntry>(Boolean),
tap(spreadProgress),
shareReplay({ bufferSize: 1, refCount: true }),
)
readonly localPkg$ = this.patch
.watch$('package-data', this.pkgId)
.pipe(
filter<PackageDataEntry>(Boolean),
shareReplay({ bufferSize: 1, refCount: true }),
)
readonly pkg$: Observable<MarketplacePkg> = this.loadVersion$.pipe(
switchMap(version =>

View File

@@ -1,5 +0,0 @@
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
export function spreadProgress(pkg: PackageDataEntry) {
pkg['install-progress'] = { ...pkg['install-progress'] }
}

View File

@@ -75,8 +75,8 @@
<ion-label>
<h2>
<b>
<span *ngIf="not['package-id']"
>{{ patch.data['package-data'][not['package-id']] ?
<span *ngIf="not['package-id'] && patch.data['package-data']">
{{ patch.data['package-data'][not['package-id']] ?
patch.data['package-data'][not['package-id']].manifest.title :
not['package-id'] }} -
</span>

View File

@@ -59,7 +59,7 @@ export class NotificationsPage {
})
this.beforeCursor = notifications[notifications.length - 1]?.id
this.needInfinite = notifications.length >= this.perPage
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
return notifications
@@ -78,7 +78,7 @@ export class NotificationsPage {
await this.embassyApi.deleteNotification({ id })
this.notifications.splice(index, 1)
this.beforeCursor = this.notifications[this.notifications.length - 1]?.id
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
@@ -169,7 +169,7 @@ export class NotificationsPage {
})
this.notifications = []
this.beforeCursor = undefined
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()

View File

@@ -147,7 +147,7 @@ export class MarketplacesPage {
{ 'server-id': this.patch.getData()['server-info'].id },
url,
)
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
loader.dismiss()
return
@@ -158,7 +158,7 @@ export class MarketplacesPage {
try {
marketplace['selected-id'] = id
await this.api.setDbValue({ pointer: `/marketplace`, value: marketplace })
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
loader.dismiss()
}
@@ -190,7 +190,7 @@ export class MarketplacesPage {
try {
delete marketplace['known-hosts'][id]
await this.api.setDbValue({ pointer: `/marketplace`, value: marketplace })
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
@@ -223,7 +223,7 @@ export class MarketplacesPage {
url,
)
marketplace['known-hosts'][id] = { name, url }
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
loader.dismiss()
return
@@ -233,7 +233,7 @@ export class MarketplacesPage {
try {
await this.api.setDbValue({ pointer: `/marketplace`, value: marketplace })
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
@@ -266,7 +266,7 @@ export class MarketplacesPage {
)
marketplace['known-hosts'][id] = { name, url }
marketplace['selected-id'] = id
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
loader.dismiss()
return
@@ -276,7 +276,7 @@ export class MarketplacesPage {
try {
await this.api.setDbValue({ pointer: `/marketplace`, value: marketplace })
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
loader.dismiss()
return

View File

@@ -7,24 +7,23 @@
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<ion-item-group *ngIf="patch.data['server-info'] as server">
<ion-content *ngIf="ui$ | async as ui" class="ion-padding-top">
<ion-item-group *ngIf="server$ | async as server">
<ion-item-divider>General</ion-item-divider>
<ion-item button (click)="presentModalName()">
<ion-item button (click)="presentModalName('Embassy-' + server.id)">
<ion-label>Device Name</ion-label>
<ion-note slot="end">{{ patch.data.ui.name || defaultName }}</ion-note>
<ion-note slot="end">{{ ui.name || 'Embassy-' + server.id }}</ion-note>
</ion-item>
<ion-item-divider>Marketplace</ion-item-divider>
<ion-item
button
(click)="serverConfig.presentAlert('auto-check-updates', patch.data.ui['auto-check-updates'] !== false)"
(click)="serverConfig.presentAlert('auto-check-updates', ui['auto-check-updates'] !== false)"
>
<ion-label>Auto Check for Updates</ion-label>
<ion-note slot="end"
>{{ patch.data.ui['auto-check-updates'] !== false ? 'Enabled' :
'Disabled' }}</ion-note
>
<ion-note slot="end">
{{ ui['auto-check-updates'] !== false ? 'Enabled' : 'Disabled' }}
</ion-note>
</ion-item>
</ion-item-group>
</ion-content>

View File

@@ -21,39 +21,37 @@ import { LocalStorageService } from '../../../services/local-storage.service'
})
export class PreferencesPage {
@ViewChild(IonContent) content: IonContent
defaultName: string
clicks = 0
constructor (
readonly ui$ = this.patch.watch$('ui')
readonly server$ = this.patch.watch$('server-info')
constructor(
private readonly loadingCtrl: LoadingController,
private readonly modalCtrl: ModalController,
private readonly api: ApiService,
private readonly toastCtrl: ToastController,
private readonly localStorageService: LocalStorageService,
public readonly serverConfig: ServerConfigService,
public readonly patch: PatchDbService,
) { }
private readonly patch: PatchDbService,
readonly serverConfig: ServerConfigService,
) {}
ngOnInit () {
this.defaultName = `Embassy-${this.patch.getData()['server-info'].id}`
}
ngAfterViewInit () {
ngAfterViewInit() {
this.content.scrollToPoint(undefined, 1)
}
async presentModalName (): Promise<void> {
async presentModalName(placeholder: string): Promise<void> {
const options: GenericInputOptions = {
title: 'Edit Device Name',
message: 'This is for your reference only.',
label: 'Device Name',
useMask: false,
placeholder: this.defaultName,
placeholder,
nullable: true,
initialValue: this.patch.getData().ui.name,
buttonText: 'Save',
submitFn: (value: string) =>
this.setDbValue('name', value || this.defaultName),
this.setDbValue('name', value || placeholder),
}
const modal = await this.modalCtrl.create({
@@ -66,7 +64,7 @@ export class PreferencesPage {
await modal.present()
}
private async setDbValue (key: string, value: string): Promise<void> {
private async setDbValue(key: string, value: string): Promise<void> {
const loader = await this.loadingCtrl.create({
spinner: 'lines',
message: 'Saving...',
@@ -81,7 +79,7 @@ export class PreferencesPage {
}
}
async addClick () {
async addClick() {
this.clicks++
if (this.clicks >= 5) {
this.clicks = 0

View File

@@ -55,7 +55,7 @@ export class ServerMetricsPage {
private async getMetrics(): Promise<void> {
try {
this.metrics = await this.embassyApi.getServerMetrics({})
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
this.stopDaemon()
}

View File

@@ -1,12 +1,11 @@
<ion-header>
<ion-toolbar>
<ion-title *ngIf="!patch.loaded"
>Loading<span class="loading-dots"></span
></ion-title>
<ion-title *ngIf="patch.loaded"
>{{ patch.data.ui.name || "Embassy-" + patch.data['server-info'].id
}}</ion-title
>
<ion-title *ngIf="patch.loaded else loading">
{{ (ui$ | async).name || "Embassy-" + (server$ | async).id }}
</ion-title>
<ng-template #loading>
<ion-title>Loading<span class="loading-dots"></span></ion-title>
</ng-template>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
@@ -22,18 +21,19 @@
<!-- not loading -->
<ng-template #data>
<ion-item-group>
<ion-item-group *ngIf="server$ | async as server">
<div *ngFor="let cat of settings | keyvalue : asIsOrder">
<ion-item-divider>
<ion-text color="dark" *ngIf="cat.key !== 'Power'"
>{{ cat.key }}</ion-text
>
<ion-text color="dark" *ngIf="cat.key !== 'Power'">
{{ cat.key }}
</ion-text>
<ion-text
color="dark"
*ngIf="cat.key === 'Power'"
(click)="addClick()"
>{{ cat.key }}</ion-text
>
{{ cat.key }}
</ion-text>
</ion-item-divider>
<ng-container *ngFor="let button of cat.value">
<ion-item
@@ -50,16 +50,13 @@
<!-- "Create Backup" button only -->
<p *ngIf="button.title === 'Create Backup'">
<ng-container
*ngIf="patch.data['server-info']['status-info'] as statusInfo"
>
<ng-container *ngIf="server['status-info'] as statusInfo">
<ion-text
color="warning"
*ngIf="!statusInfo['backing-up'] && !statusInfo['update-progress']"
>
Last Backup: {{ patch.data['server-info']['last-backup'] ?
(patch.data['server-info']['last-backup'] | date: 'short') :
'never' }}
Last Backup: {{ server['last-backup'] ?
(server['last-backup'] | date: 'short') : 'never' }}
</ion-text>
<span *ngIf="!!statusInfo['backing-up']" class="inline">
<ion-spinner
@@ -74,7 +71,7 @@
<p *ngIf="button.title === 'Software Update'">
<ng-container *ngIf="button.disabled | async; else enabled">
<ion-text
*ngIf="patch.data['server-info']['status-info'].updated"
*ngIf="server['status-info'].updated"
class="inline"
color="warning"
>

View File

@@ -15,7 +15,6 @@ import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
import { exists, isEmptyObject, ErrorToastService } from '@start9labs/shared'
import { EOSService } from 'src/app/services/eos.service'
import { ServerStatus } from 'src/app/services/patch-db/data-model'
import { LocalStorageService } from 'src/app/services/local-storage.service'
@Component({
@@ -24,10 +23,12 @@ import { LocalStorageService } from 'src/app/services/local-storage.service'
styleUrls: ['server-show.page.scss'],
})
export class ServerShowPage {
ServerStatus = ServerStatus
hasRecoveredPackage: boolean
clicks = 0
readonly server$ = this.patch.watch$('server-info')
readonly ui$ = this.patch.watch$('ui')
constructor(
private readonly alertCtrl: AlertController,
private readonly modalCtrl: ModalController,
@@ -164,7 +165,7 @@ export class ServerShowPage {
this.embassyApi.repairDisk({}).then(_ => {
this.restart()
})
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
}
},
@@ -185,7 +186,7 @@ export class ServerShowPage {
try {
await this.embassyApi.restartServer({})
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
@@ -202,7 +203,7 @@ export class ServerShowPage {
try {
await this.embassyApi.shutdownServer({})
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
@@ -219,7 +220,7 @@ export class ServerShowPage {
try {
await this.embassyApi.systemRebuild({})
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
@@ -239,7 +240,7 @@ export class ServerShowPage {
if (updateAvailable) {
this.updateEos()
}
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
@@ -387,7 +388,8 @@ export class ServerShowPage {
},
{
title: 'Kernel Logs',
description: 'Diagnostic log stream for device drivers and other kernel processes',
description:
'Diagnostic log stream for device drivers and other kernel processes',
icon: 'receipt-outline',
action: () =>
this.navCtrl.navigateForward(['kernel-logs'], {

View File

@@ -8,10 +8,9 @@
</ion-header>
<ion-content>
<ion-item-divider>Basic</ion-item-divider>
<ion-item-group *ngIf="patch.data['server-info'] as server">
<ion-item-group *ngIf="server$ | async as server">
<ion-item>
<ion-label>
<h2>EmbassyOS Version</h2>
@@ -47,5 +46,4 @@
</ion-button>
</ion-item>
</ion-item-group>
</ion-content>
</ion-content>

View File

@@ -12,20 +12,23 @@ import { ConfigService } from 'src/app/services/config.service'
export class ServerSpecsPage {
@ViewChild(IonContent) content: IonContent
constructor (
private readonly toastCtrl: ToastController,
public readonly patch: PatchDbService,
public readonly config: ConfigService,
) { }
readonly server$ = this.patch.watch$('server-info')
ngAfterViewInit () {
constructor(
private readonly toastCtrl: ToastController,
private readonly patch: PatchDbService,
public readonly config: ConfigService,
) {}
ngAfterViewInit() {
this.content.scrollToPoint(undefined, 1)
}
async copy (address: string) {
async copy(address: string) {
let message = ''
await copyToClipboard(address || '')
.then(success => { message = success ? 'copied to clipboard!' : 'failed to copy'})
await copyToClipboard(address || '').then(success => {
message = success ? 'copied to clipboard!' : 'failed to copy'
})
const toast = await this.toastCtrl.create({
header: message,
@@ -35,7 +38,7 @@ export class ServerSpecsPage {
await toast.present()
}
asIsOrder (a: any, b: any) {
asIsOrder(a: any, b: any) {
return 0
}
}

View File

@@ -23,7 +23,7 @@ export class SessionsPage {
async ngOnInit() {
try {
this.sessionInfo = await this.embassyApi.getSessions({})
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
this.loading = false
@@ -62,7 +62,7 @@ export class SessionsPage {
try {
await this.embassyApi.killSessions({ ids: [id] })
delete this.sessionInfo.sessions[id]
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()

View File

@@ -37,7 +37,7 @@ export class SSHKeysPage {
async getKeys(): Promise<void> {
try {
this.sshKeys = await this.embassyApi.getSshKeys({})
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
this.loading = false
@@ -111,7 +111,7 @@ export class SSHKeysPage {
const entry = this.sshKeys[i]
await this.embassyApi.deleteSshKey({ fingerprint: entry.fingerprint })
this.sshKeys.splice(i, 1)
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()

View File

@@ -49,7 +49,7 @@ export class WifiPage {
if (!this.wifi.country) {
await this.presentAlertCountry()
}
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
this.loading = false
@@ -179,7 +179,7 @@ export class WifiPage {
await this.api.setWifiCountry({ country })
await this.getWifi()
this.wifi.country = country
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
@@ -271,7 +271,7 @@ export class WifiPage {
try {
await this.api.connectWifi({ ssid })
await this.confirmWifi(ssid)
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
@@ -290,7 +290,7 @@ export class WifiPage {
await this.api.deleteWifi({ ssid })
await this.getWifi()
delete this.wifi.ssids[ssid]
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
@@ -313,7 +313,7 @@ export class WifiPage {
connect: false,
})
await this.getWifi()
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
@@ -337,7 +337,7 @@ export class WifiPage {
})
await this.confirmWifi(ssid, true)
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()

View File

@@ -266,7 +266,7 @@ export abstract class ApiService implements Source<DataModel>, Http<DataModel> {
private syncResponse<
T,
F extends (...args: any[]) => Promise<{ response: T; revision?: Revision }>,
>(f: F, temp?: Operation): (...args: Parameters<F>) => Promise<T> {
>(f: F, temp?: Operation<unknown>): (...args: Parameters<F>) => Promise<T> {
return (...a) => {
// let expireId = undefined
// if (temp) {

View File

@@ -19,9 +19,23 @@ import { BehaviorSubject } from 'rxjs'
import { LocalStorageBootstrap } from '../patch-db/local-storage-bootstrap'
import { mockPatchData } from './mock-patch'
const PROGRESS: InstallProgress = {
size: 120,
downloaded: 0,
'download-complete': false,
validated: 0,
'validation-complete': false,
unpacked: 0,
'unpack-complete': false,
}
@Injectable()
export class MockApiService extends ApiService {
readonly mockPatch$ = new BehaviorSubject<Update<DataModel>>(undefined)
readonly mockPatch$ = new BehaviorSubject<Update<DataModel>>({
id: 1,
value: mockPatchData,
expireId: null,
})
private readonly revertTime = 4000
sequence: number
@@ -391,8 +405,13 @@ export class MockApiService extends ApiService {
await pauseFor(8000)
appPatch[0].value = PackageMainStatus.Stopped
this.updateMock(appPatch)
const newPatch = [
{
...appPatch[0],
value: PackageMainStatus.Stopped,
},
]
this.updateMock(newPatch)
}
await pauseFor(1000)
@@ -454,32 +473,21 @@ export class MockApiService extends ApiService {
params: RR.InstallPackageReq,
): Promise<RR.InstallPackageRes> {
await pauseFor(2000)
const initialProgress: InstallProgress = {
size: 120,
downloaded: 0,
'download-complete': false,
validated: 0,
'validation-complete': false,
unpacked: 0,
'unpack-complete': false,
}
setTimeout(async () => {
this.updateProgress(params.id, initialProgress)
this.updateProgress(params.id)
}, 1000)
const pkg: PackageDataEntry = {
...Mock.LocalPkgs[params.id],
state: PackageState.Installing,
'install-progress': initialProgress,
installed: undefined,
}
const patch = [
const patch: Operation<PackageDataEntry>[] = [
{
op: PatchOp.ADD,
path: `/package-data/${params.id}`,
value: pkg,
value: {
...Mock.LocalPkgs[params.id],
state: PackageState.Installing,
'install-progress': { ...PROGRESS },
installed: undefined,
},
},
]
return this.withRevision(patch)
@@ -527,32 +535,20 @@ export class MockApiService extends ApiService {
params: RR.RestorePackagesReq,
): Promise<RR.RestorePackagesRes> {
await pauseFor(2000)
const patch: Operation[] = params.ids.map(id => {
const initialProgress: InstallProgress = {
size: 120,
downloaded: 120,
'download-complete': true,
validated: 0,
'validation-complete': false,
unpacked: 0,
'unpack-complete': false,
}
const pkg: PackageDataEntry = {
...Mock.LocalPkgs[id],
state: PackageState.Restoring,
'install-progress': initialProgress,
installed: undefined,
}
const patch: Operation<PackageDataEntry>[] = params.ids.map(id => {
setTimeout(async () => {
this.updateProgress(id, initialProgress)
this.updateProgress(id)
}, 2000)
return {
op: PatchOp.ADD,
path: `/package-data/${id}`,
value: pkg,
value: {
...Mock.LocalPkgs[id],
state: PackageState.Restoring,
'install-progress': { ...PROGRESS },
installed: undefined,
},
}
})
@@ -703,11 +699,11 @@ export class MockApiService extends ApiService {
await pauseFor(2000)
setTimeout(async () => {
const patch2 = [
const patch2: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/package-data/${params.id}`,
} as RemoveOperation,
},
]
this.updateMock(patch2)
}, this.revertTime)
@@ -727,11 +723,11 @@ export class MockApiService extends ApiService {
params: RR.DeleteRecoveredPackageReq,
): Promise<RR.DeleteRecoveredPackageRes> {
await pauseFor(2000)
const patch = [
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/recovered-packages/${params.id}`,
} as RemoveOperation,
},
]
return this.withRevision(patch)
}
@@ -747,30 +743,30 @@ export class MockApiService extends ApiService {
}
}
private async updateProgress(
id: string,
initialProgress: InstallProgress,
): Promise<void> {
private async updateProgress(id: string): Promise<void> {
const progress = { ...PROGRESS }
const phases = [
{ progress: 'downloaded', completion: 'download-complete' },
{ progress: 'validated', completion: 'validation-complete' },
{ progress: 'unpacked', completion: 'unpack-complete' },
]
for (let phase of phases) {
let i = initialProgress[phase.progress]
while (i < initialProgress.size) {
let i = progress[phase.progress]
while (i < progress.size) {
await pauseFor(250)
i = Math.min(i + 5, initialProgress.size)
initialProgress[phase.progress] = i
if (i === initialProgress.size) {
initialProgress[phase.completion] = true
i = Math.min(i + 5, progress.size)
progress[phase.progress] = i
if (i === progress.size) {
progress[phase.completion] = true
}
const patch = [
{
op: PatchOp.REPLACE,
path: `/package-data/${id}/install-progress`,
value: initialProgress,
value: { ...progress },
},
]
this.updateMock(patch)
@@ -778,7 +774,7 @@ export class MockApiService extends ApiService {
}
setTimeout(() => {
const patch2: any = [
const patch2: Operation<PackageDataEntry>[] = [
{
op: PatchOp.REPLACE,
path: `/package-data/${id}`,
@@ -822,7 +818,7 @@ export class MockApiService extends ApiService {
this.updateMock(patch2)
setTimeout(async () => {
const patch3: Operation[] = [
const patch3: Operation<ServerStatus>[] = [
{
op: PatchOp.REPLACE,
path: '/server-info/status',
@@ -847,7 +843,7 @@ export class MockApiService extends ApiService {
}, 1000)
}
private async updateMock(patch: Operation[]): Promise<void> {
private async updateMock<T>(patch: Operation<T>[]): Promise<void> {
if (!this.sequence) {
const { sequence } = await this.bootstrapper.init()
this.sequence = sequence
@@ -861,7 +857,7 @@ export class MockApiService extends ApiService {
}
private async withRevision<T>(
patch: Operation[],
patch: Operation<unknown>[],
response: T = null,
): Promise<WithRevision<T>> {
if (!this.sequence) {

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@angular/core'
import { BehaviorSubject, Observable } from 'rxjs'
import { distinctUntilChanged } from 'rxjs/operators'
import { Observable, ReplaySubject } from 'rxjs'
import { distinctUntilChanged, map } from 'rxjs/operators'
import { Storage } from '@ionic/storage-angular'
export enum AuthState {
@@ -12,27 +12,29 @@ export enum AuthState {
})
export class AuthService {
private readonly LOGGED_IN_KEY = 'loggedInKey'
private readonly authState$: BehaviorSubject<AuthState> = new BehaviorSubject(undefined)
private readonly authState$ = new ReplaySubject<AuthState>(1)
constructor (
private readonly storage: Storage,
) { }
readonly isVerified$ = this.watch$().pipe(
map(state => state === AuthState.VERIFIED),
)
async init (): Promise<void> {
constructor(private readonly storage: Storage) {}
async init(): Promise<void> {
const loggedIn = await this.storage.get(this.LOGGED_IN_KEY)
this.authState$.next( loggedIn ? AuthState.VERIFIED : AuthState.UNVERIFIED)
this.authState$.next(loggedIn ? AuthState.VERIFIED : AuthState.UNVERIFIED)
}
watch$ (): Observable<AuthState> {
watch$(): Observable<AuthState> {
return this.authState$.pipe(distinctUntilChanged())
}
async setVerified (): Promise<void> {
async setVerified(): Promise<void> {
await this.storage.set(this.LOGGED_IN_KEY, true)
this.authState$.next(AuthState.VERIFIED)
}
async setUnverified (): Promise<void> {
async setUnverified(): Promise<void> {
this.authState$.next(AuthState.UNVERIFIED)
}
}

View File

@@ -4,21 +4,34 @@ import {
combineLatest,
fromEvent,
merge,
Observable,
Subject,
Subscription,
} from 'rxjs'
import { PatchConnection, PatchDbService } from './patch-db/patch-db.service'
import { distinctUntilChanged } from 'rxjs/operators'
import {
distinctUntilChanged,
map,
mapTo,
startWith,
tap,
} from 'rxjs/operators'
import { ConfigService } from './config.service'
@Injectable({
providedIn: 'root',
})
export class ConnectionService {
private readonly networkState$ = new BehaviorSubject<boolean>(true)
private readonly connectionFailure$ = new BehaviorSubject<ConnectionFailure>(
ConnectionFailure.None,
private readonly networkState$ = merge(
fromEvent(window, 'online').pipe(mapTo(true)),
fromEvent(window, 'offline').pipe(mapTo(false)),
).pipe(
startWith(null),
map(() => navigator.onLine),
)
private readonly connectionFailure$ = new Subject<ConnectionFailure>()
constructor(
private readonly configService: ConfigService,
private readonly patch: PatchDbService,
@@ -28,15 +41,8 @@ export class ConnectionService {
return this.connectionFailure$.asObservable()
}
start(): Subscription[] {
const sub1 = merge(
fromEvent(window, 'online'),
fromEvent(window, 'offline'),
).subscribe(event => {
this.networkState$.next(event.type === 'online')
})
const sub2 = combineLatest([
start(): Observable<unknown> {
return combineLatest([
// 1
this.networkState$.pipe(distinctUntilChanged()),
// 2
@@ -45,20 +51,21 @@ export class ConnectionService {
this.patch
.watch$('server-info', 'status-info', 'update-progress')
.pipe(distinctUntilChanged()),
]).subscribe(async ([network, patchConnection, progress]) => {
if (!network) {
this.connectionFailure$.next(ConnectionFailure.Network)
} else if (patchConnection !== PatchConnection.Disconnected) {
this.connectionFailure$.next(ConnectionFailure.None)
} else if (!!progress && progress.downloaded === progress.size) {
this.connectionFailure$.next(ConnectionFailure.None)
} else if (!this.configService.isTor()) {
this.connectionFailure$.next(ConnectionFailure.Lan)
} else {
this.connectionFailure$.next(ConnectionFailure.Tor)
}
})
return [sub1, sub2]
]).pipe(
tap(([network, patchConnection, progress]) => {
if (!network) {
this.connectionFailure$.next(ConnectionFailure.Network)
} else if (patchConnection !== PatchConnection.Disconnected) {
this.connectionFailure$.next(ConnectionFailure.None)
} else if (!!progress && progress.downloaded === progress.size) {
this.connectionFailure$.next(ConnectionFailure.None)
} else if (!this.configService.isTor()) {
this.connectionFailure$.next(ConnectionFailure.Lan)
} else {
this.connectionFailure$.next(ConnectionFailure.Tor)
}
}),
)
}
}

View File

@@ -1,11 +1,9 @@
import { Injectable } from '@angular/core'
import {
AbstractControl,
FormArray,
FormBuilder,
FormControl,
FormGroup,
ValidationErrors,
ValidatorFn,
Validators,
} from '@angular/forms'
@@ -69,7 +67,7 @@ export class FormService {
}
getListItem(spec: ValueSpecList, entry: any) {
const listItemValidators = this.getListItemValidators(spec)
const listItemValidators = getListItemValidators(spec)
if (isValueSpecListOf(spec, 'string')) {
return this.formBuilder.control(entry, listItemValidators)
} else if (isValueSpecListOf(spec, 'number')) {
@@ -83,14 +81,6 @@ export class FormService {
}
}
private getListItemValidators(spec: ValueSpecList) {
if (isValueSpecListOf(spec, 'string')) {
return this.stringValidators(spec.spec)
} else if (isValueSpecListOf(spec, 'number')) {
return this.numberValidators(spec.spec)
}
}
private getFormGroup(
config: ConfigSpec,
validators: ValidatorFn[] = [],
@@ -112,7 +102,7 @@ export class FormService {
let value: any
switch (spec.type) {
case 'string':
validators = this.stringValidators(spec)
validators = stringValidators(spec)
if (currentValue !== undefined) {
value = currentValue
} else {
@@ -120,7 +110,7 @@ export class FormService {
}
return this.formBuilder.control(value, validators)
case 'number':
validators = this.numberValidators(spec)
validators = numberValidators(spec)
if (currentValue !== undefined) {
value = currentValue
} else {
@@ -130,7 +120,7 @@ export class FormService {
case 'object':
return this.getFormGroup(spec.spec, [], currentValue)
case 'list':
validators = this.listValidators(spec)
validators = listValidators(spec)
const mapped = (
Array.isArray(currentValue) ? currentValue : (spec.default as any[])
).map(entry => {
@@ -149,56 +139,64 @@ export class FormService {
return this.formBuilder.control(value)
}
}
}
private stringValidators(
spec: ValueSpecString | ListValueSpecString,
): ValidatorFn[] {
const validators: ValidatorFn[] = []
function getListItemValidators(spec: ValueSpecList) {
if (isValueSpecListOf(spec, 'string')) {
return stringValidators(spec.spec)
} else if (isValueSpecListOf(spec, 'number')) {
return numberValidators(spec.spec)
}
}
if (!(spec as ValueSpecString).nullable) {
validators.push(Validators.required)
}
function stringValidators(
spec: ValueSpecString | ListValueSpecString,
): ValidatorFn[] {
const validators: ValidatorFn[] = []
if (spec.pattern) {
validators.push(Validators.pattern(spec.pattern))
}
return validators
if (!(spec as ValueSpecString).nullable) {
validators.push(Validators.required)
}
private numberValidators(
spec: ValueSpecNumber | ListValueSpecNumber,
): ValidatorFn[] {
const validators: ValidatorFn[] = []
validators.push(isNumber())
if (!(spec as ValueSpecNumber).nullable) {
validators.push(Validators.required)
}
if (spec.integral) {
validators.push(isInteger())
}
validators.push(numberInRange(spec.range))
return validators
if (spec.pattern) {
validators.push(Validators.pattern(spec.pattern))
}
private listValidators(spec: ValueSpecList): ValidatorFn[] {
const validators: ValidatorFn[] = []
return validators
}
validators.push(listInRange(spec.range))
function numberValidators(
spec: ValueSpecNumber | ListValueSpecNumber,
): ValidatorFn[] {
const validators: ValidatorFn[] = []
validators.push(listItemIssue())
validators.push(isNumber())
if (!isValueSpecListOf(spec, 'enum')) {
validators.push(listUnique(spec))
}
return validators
if (!(spec as ValueSpecNumber).nullable) {
validators.push(Validators.required)
}
if (spec.integral) {
validators.push(isInteger())
}
validators.push(numberInRange(spec.range))
return validators
}
function listValidators(spec: ValueSpecList): ValidatorFn[] {
const validators: ValidatorFn[] = []
validators.push(listInRange(spec.range))
validators.push(listItemIssue())
if (!isValueSpecListOf(spec, 'enum')) {
validators.push(listUnique(spec))
}
return validators
}
function isFullUnion(
@@ -208,48 +206,47 @@ function isFullUnion(
}
export function numberInRange(stringRange: string): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
return control => {
const value = control.value
if (!value) return null
try {
Range.from(stringRange).checkIncludes(value)
return null
} catch (e) {
} catch (e: any) {
return { numberNotInRange: { value: `Number must be ${e.message}` } }
}
}
}
export function isNumber(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
return !control.value || control.value == Number(control.value)
return control =>
!control.value || control.value == Number(control.value)
? null
: { notNumber: { value: control.value } }
}
}
export function isInteger(): ValidatorFn {
return (control: AbstractControl): ValidationErrors | null => {
return !control.value || control.value == Math.trunc(control.value)
return control =>
!control.value || control.value == Math.trunc(control.value)
? null
: { numberNotInteger: { value: control.value } }
}
}
export function listInRange(stringRange: string): ValidatorFn {
return (control: FormArray): ValidationErrors | null => {
return control => {
try {
Range.from(stringRange).checkIncludes(control.value.length)
return null
} catch (e) {
} catch (e: any) {
return { listNotInRange: { value: `List must be ${e.message}` } }
}
}
}
export function listItemIssue(): ValidatorFn {
return (parentControl: FormArray): ValidationErrors | null => {
const problemChild = parentControl.controls.find(c => c.invalid)
return parentControl => {
const { controls } = parentControl as FormArray
const problemChild = controls.find(c => c.invalid)
if (problemChild) {
return { listItemIssue: { value: 'Invalid entries' } }
} else {
@@ -259,7 +256,7 @@ export function listItemIssue(): ValidatorFn {
}
export function listUnique(spec: ValueSpecList): ValidatorFn {
return (control: FormArray): ValidationErrors | null => {
return control => {
const list = control.value
for (let idx = 0; idx < list.length; idx++) {
for (let idx2 = idx + 1; idx2 < list.length; idx2++) {
@@ -516,25 +513,29 @@ export function convertValuesRecursive(
convertValuesRecursive(spec, control)
} else if (valueSpec.type === 'list') {
const formArr = group.get(key) as FormArray
const { controls } = formArr
if (valueSpec.subtype === 'number') {
formArr.controls.forEach(control => {
controls.forEach(control => {
control.setValue(control.value ? Number(control.value) : null)
})
} else if (valueSpec.subtype === 'string') {
formArr.controls.forEach(control => {
controls.forEach(control => {
if (!control.value) control.setValue(null)
})
} else if (valueSpec.subtype === 'object') {
formArr.controls.forEach((formGroup: FormGroup) => {
controls.forEach(formGroup => {
const objectSpec = valueSpec.spec as ListValueSpecObject
convertValuesRecursive(objectSpec.spec, formGroup)
convertValuesRecursive(objectSpec.spec, formGroup as FormGroup)
})
} else if (valueSpec.subtype === 'union') {
formArr.controls.forEach((formGroup: FormGroup) => {
controls.forEach(formGroup => {
const unionSpec = valueSpec.spec as ListValueSpecUnion
const spec =
unionSpec.variants[formGroup.controls[unionSpec.tag.id].value]
convertValuesRecursive(spec, formGroup)
unionSpec.variants[
(formGroup as FormGroup).controls[unionSpec.tag.id].value
]
convertValuesRecursive(spec, formGroup as FormGroup)
})
}
}

View File

@@ -10,6 +10,7 @@ import { map, take } from 'rxjs/operators'
import { ConfigService } from './config.service'
import { Revision } from 'patch-db-client'
import { AuthService } from './auth.service'
import { HttpError, RpcError } from '@start9labs/shared'
@Injectable({
providedIn: 'root',
@@ -98,33 +99,6 @@ export class HttpService {
}
}
function RpcError(e: RPCError['error']): void {
const { code, message, data } = e
this.code = code
if (typeof data === 'string') {
this.message = `${message}\n\n${data}`
this.revision = null
} else {
if (data.details) {
this.message = `${message}\n\n${data.details}`
} else {
this.message = message
}
this.revision = data.revision
}
}
function HttpError(e: HttpErrorResponse): void {
const { status, statusText } = e
this.code = status
this.message = statusText
this.details = null
this.revision = null
}
function isRpcError<Error, Result>(
arg: { error: Error } | { result: Result },
): arg is { error: Error } {
@@ -188,10 +162,6 @@ export interface RPCError extends RPCBase {
export type RPCResponse<T> = RPCSuccess<T> | RPCError
type HttpError = HttpErrorResponse & {
error: { code: string; message: string }
}
export interface HttpOptions {
method: Method
url: string

View File

@@ -8,8 +8,9 @@ const SHOW_DISK_REPAIR = 'SHOW_DISK_REPAIR'
providedIn: 'root',
})
export class LocalStorageService {
showDevTools$: BehaviorSubject<boolean> = new BehaviorSubject(false)
showDiskRepair$: BehaviorSubject<boolean> = new BehaviorSubject(false)
readonly showDevTools$ = new BehaviorSubject<boolean>(false)
readonly showDiskRepair$ = new BehaviorSubject<boolean>(false)
constructor(private readonly storage: Storage) {}
async init() {

View File

@@ -27,16 +27,16 @@ import {
export class MarketplaceService extends AbstractMarketplaceService {
private readonly notes = new Map<string, Record<string, string>>()
private readonly init$: Observable<Marketplace> = defer(() =>
this.patch.watch$('ui', 'marketplace'),
).pipe(
map(marketplace =>
marketplace?.['selected-id']
? marketplace['known-hosts'][marketplace['selected-id']]
: this.config.marketplace,
),
shareReplay(),
)
private readonly init$: Observable<Marketplace> = this.patch
.watch$('ui', 'marketplace')
.pipe(
map(marketplace =>
marketplace?.['selected-id']
? marketplace['known-hosts'][marketplace['selected-id']]
: this.config.marketplace,
),
shareReplay(),
)
private readonly data$: Observable<MarketplaceData> = this.init$.pipe(
switchMap(({ url }) =>

View File

@@ -1,54 +1,46 @@
import { MockSource, PollSource, WebsocketSource } from 'patch-db-client'
import { ConfigService } from 'src/app/services/config.service'
import { LocalStorageBootstrap } from './local-storage-bootstrap'
import { PatchDbService } from './patch-db.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { AuthService } from '../auth.service'
import { MockApiService } from '../api/embassy-mock-api.service'
import { filter } from 'rxjs/operators'
import { inject, InjectionToken } from '@angular/core'
import { exists } from '@start9labs/shared'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { Storage } from '@ionic/storage-angular'
import { filter } from 'rxjs/operators'
import {
Bootstrapper,
MockSource,
PollSource,
Source,
WebsocketSource,
} from 'patch-db-client'
export function PatchDbServiceFactory(
config: ConfigService,
embassyApi: ApiService,
bootstrapper: LocalStorageBootstrap,
auth: AuthService,
storage: Storage,
): PatchDbService {
const {
useMocks,
patchDb: { poll },
} = config
import { ConfigService } from '../config.service'
import { LocalStorageBootstrap } from './local-storage-bootstrap'
import { ApiService } from '../api/embassy-api.service'
import { MockApiService } from '../api/embassy-mock-api.service'
import { DataModel } from './data-model'
if (useMocks) {
const source = new MockSource<DataModel>(
(embassyApi as MockApiService).mockPatch$.pipe(filter(exists)),
)
return new PatchDbService(
source,
source,
embassyApi,
bootstrapper,
auth,
storage,
)
} else {
const protocol = window.location.protocol === 'http:' ? 'ws' : 'wss'
const host = window.location.host
const wsSource = new WebsocketSource<DataModel>(
`${protocol}://${host}/ws/db`,
)
const pollSource = new PollSource<DataModel>({ ...poll }, embassyApi)
export const PATCH_SOURCE = new InjectionToken<Source<DataModel>[]>(
'[wsSources, pollSources]',
)
export const BOOTSTRAPPER = new InjectionToken<Bootstrapper<DataModel>>('', {
factory: () => inject(LocalStorageBootstrap),
})
return new PatchDbService(
wsSource,
pollSource,
embassyApi,
bootstrapper,
auth,
storage,
)
}
export function mockSourceFactory({
mockPatch$,
}: MockApiService): Source<DataModel>[] {
return Array(2).fill(
new MockSource<DataModel>(mockPatch$.pipe(filter(exists))),
)
}
export function realSourceFactory(
embassyApi: ApiService,
config: ConfigService,
{ defaultView }: Document,
): Source<DataModel>[] {
const { patchDb } = config
const { host } = defaultView.location
const protocol = defaultView.location.protocol === 'http:' ? 'ws' : 'wss'
return [
new WebsocketSource<DataModel>(`${protocol}://${host}/ws/db`),
new PollSource<DataModel>({ ...patchDb.poll }, embassyApi),
]
}

View File

@@ -1,13 +1,19 @@
import { Inject, Injectable, InjectionToken } from '@angular/core'
import { Inject, Injectable } from '@angular/core'
import { Storage } from '@ionic/storage-angular'
import { Bootstrapper, PatchDB, Source, Store } from 'patch-db-client'
import { BehaviorSubject, Observable, of, Subscription } from 'rxjs'
import {
BehaviorSubject,
Observable,
of,
ReplaySubject,
Subscription,
} from 'rxjs'
import {
catchError,
debounceTime,
filter,
finalize,
mergeMap,
skip,
switchMap,
take,
tap,
@@ -17,13 +23,7 @@ import { isEmptyObject, pauseFor } from '@start9labs/shared'
import { DataModel } from './data-model'
import { ApiService } from '../api/embassy-api.service'
import { AuthService } from '../auth.service'
import { patch } from '@start9labs/emver'
export const PATCH_HTTP = new InjectionToken<Source<DataModel>>('')
export const PATCH_SOURCE = new InjectionToken<Source<DataModel>>('')
export const BOOTSTRAPPER = new InjectionToken<Bootstrapper<DataModel>>('')
export const AUTH = new InjectionToken<AuthService>('')
export const STORAGE = new InjectionToken<Storage>('')
import { BOOTSTRAPPER, PATCH_SOURCE } from './patch-db.factory'
export enum PatchConnection {
Initializing = 'initializing',
@@ -36,13 +36,13 @@ export enum PatchConnection {
})
export class PatchDbService {
private readonly WS_SUCCESS = 'wsSuccess'
private patchConnection$ = new BehaviorSubject(PatchConnection.Initializing)
private patchConnection$ = new ReplaySubject<PatchConnection>(1)
private wsSuccess$ = new BehaviorSubject(false)
private polling$ = new BehaviorSubject(false)
private patchDb: PatchDB<DataModel>
private subs: Subscription[] = []
private sources$: BehaviorSubject<Source<DataModel>[]> = new BehaviorSubject([
this.wsSource,
this.sources[0],
])
data: DataModel
@@ -61,18 +61,18 @@ export class PatchDbService {
}
constructor(
@Inject(PATCH_SOURCE) private readonly wsSource: Source<DataModel>,
@Inject(PATCH_SOURCE) private readonly pollSource: Source<DataModel>,
@Inject(PATCH_HTTP) private readonly http: ApiService,
// [wsSources, pollSources]
@Inject(PATCH_SOURCE) private readonly sources: Source<DataModel>[],
@Inject(BOOTSTRAPPER)
private readonly bootstrapper: Bootstrapper<DataModel>,
@Inject(AUTH) private readonly auth: AuthService,
@Inject(STORAGE) private readonly storage: Storage,
private readonly http: ApiService,
private readonly auth: AuthService,
private readonly storage: Storage,
) {}
async init(): Promise<void> {
const cache = await this.bootstrapper.init()
this.sources$.next([this.wsSource, this.http])
this.sources$.next([this.sources[0], this.http])
this.patchDb = new PatchDB(this.sources$, this.http, cache)
@@ -94,13 +94,13 @@ export class PatchDbService {
console.log('patchDB: POLLING FAILED', e)
this.patchConnection$.next(PatchConnection.Disconnected)
await pauseFor(2000)
this.sources$.next([this.pollSource, this.http])
this.sources$.next([this.sources[1], this.http])
return
}
console.log('patchDB: WEBSOCKET FAILED', e)
this.polling$.next(true)
this.sources$.next([this.pollSource, this.http])
this.sources$.next([this.sources[1], this.http])
}),
)
.subscribe({
@@ -152,7 +152,7 @@ export class PatchDbService {
console.log('patchDB: SWITCHING BACK TO WEBSOCKETS')
this.patchConnection$.next(PatchConnection.Initializing)
this.polling$.next(false)
this.sources$.next([this.wsSource, this.http])
this.sources$.next([this.sources[0], this.http])
}
}),
)
@@ -180,19 +180,14 @@ export class PatchDbService {
// prettier-ignore
watch$: Store<DataModel>['watch$'] = (...args: (string | number)[]): Observable<DataModel> => {
// TODO: refactor with a better solution to race condition
const argsString = '/' + args.join('/')
const source$ =
this.patchDb?.store.watch$(...(args as [])) ||
this.patchConnection$.pipe(
skip(1),
take(1),
switchMap(() => this.patchDb.store.watch$(...(args as []))),
)
console.log('patchDB: WATCHING ', argsString)
return source$.pipe(
return this.patchConnection$.pipe(
filter(status => status === PatchConnection.Connected),
take(1),
switchMap(() => this.patchDb.store.watch$(...(args as []))),
tap(data => console.log('patchDB: NEW VALUE', argsString, data)),
catchError(e => {
console.error('patchDB: WATCH ERROR', e)

View File

@@ -47,18 +47,25 @@ function getDependencyStatus(pkg: PackageDataEntry): DependencyStatus {
}
function getHealthStatus(status: Status): HealthStatus {
if (status.main.status === PackageMainStatus.Running) {
const values = Object.values(status.main.health)
if (values.some(h => h.result === 'failure')) {
return HealthStatus.Failure
} else if (values.some(h => h.result === 'starting')) {
return HealthStatus.Starting
} else if (values.some(h => h.result === 'loading')) {
return HealthStatus.Loading
} else {
return HealthStatus.Healthy
}
if (status.main.status !== PackageMainStatus.Running || !status.main.health) {
return
}
const values = Object.values(status.main.health)
if (values.some(h => h.result === 'failure')) {
return HealthStatus.Failure
}
if (values.some(h => h.result === 'starting')) {
return HealthStatus.Starting
}
if (values.some(h => h.result === 'loading')) {
return HealthStatus.Loading
}
return HealthStatus.Healthy
}
export interface StatusRendering {

View File

@@ -37,7 +37,7 @@ export class ServerConfigService {
try {
await this.saveFns[key](data)
} catch (e) {
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()

View File

@@ -5,5 +5,5 @@ import { Injectable } from '@angular/core'
providedIn: 'root',
})
export class SplitPaneTracker {
sidebarOpen$: BehaviorSubject<boolean> = new BehaviorSubject(false)
}
readonly sidebarOpen$ = new BehaviorSubject<boolean>(false)
}

View File

@@ -1,9 +1,9 @@
export interface InstallProgress {
size: number | null
downloaded: number
'download-complete': boolean
validated: number
'validation-complete': boolean
unpacked: number
'unpack-complete': boolean
readonly size: number | null
readonly downloaded: number
readonly 'download-complete': boolean
readonly validated: number
readonly 'validation-complete': boolean
readonly unpacked: number
readonly 'unpack-complete': boolean
}

View File

@@ -1,31 +1,54 @@
import { Observable, from, interval, race, OperatorFunction, Observer, combineLatest } from 'rxjs'
import { take, map, concatMap } from 'rxjs/operators'
import {
Observable,
from,
interval,
race,
OperatorFunction,
Observer,
combineLatest,
} from 'rxjs'
import { take, map } from 'rxjs/operators'
export function fromAsync$<S, T> (async: (s: S) => Promise<T>, s: S): Observable<T>
export function fromAsync$<T> (async: () => Promise<T>): Observable<T>
export function fromAsync$<S, T> (async: (s: S) => Promise<T>, s?: S): Observable<T> {
export function fromAsync$<S, T>(
async: (s: S) => Promise<T>,
s: S,
): Observable<T>
export function fromAsync$<T>(async: () => Promise<T>): Observable<T>
export function fromAsync$<S, T>(
async: (s: S) => Promise<T>,
s?: S,
): Observable<T> {
return from(async(s as S))
}
export function fromAsyncP<T> (async: () => Promise<T>): Promise<T>
export function fromAsyncP<S, T> (async: (s: S) => Promise<T>, s?: S): Promise<T> {
export function fromAsyncP<T>(async: () => Promise<T>): Promise<T>
export function fromAsyncP<S, T>(
async: (s: S) => Promise<T>,
s?: S,
): Promise<T> {
return async(s as S)
}
// emits + completes after ms
export function emitAfter$ (ms: number): Observable<number> {
export function emitAfter$(ms: number): Observable<number> {
return interval(ms).pipe(take(1))
}
// throws unless source observable emits withing timeout
export function throwIn<T> (timeout: number): OperatorFunction<T, T> {
return o => race(
o,
emitAfter$(timeout).pipe(map(() => { throw new Error('timeout') } )))
export function throwIn<T>(timeout: number): OperatorFunction<T, T> {
return o =>
race(
o,
emitAfter$(timeout).pipe(
map(() => {
throw new Error('timeout')
}),
),
)
}
// o.pipe(squash) : Observable<void> regardless of o emission type.
export const squash = map(() => { })
export const squash = map(() => {})
/*
The main purpose of fromSync$ is to normalize error handling during a sequence
@@ -59,10 +82,10 @@ export const squash = map(() => { })
}
```
*/
export function fromSync$<S, T> (sync: (s: S) => T, s: S): Observable<T>
export function fromSync$<T> (sync: () => T): Observable<T>
export function fromSync$<S, T> (sync: (s: S) => T, s?: S): Observable<T> {
return new Observable( (subscriber: Observer<T>) => {
export function fromSync$<S, T>(sync: (s: S) => T, s: S): Observable<T>
export function fromSync$<T>(sync: () => T): Observable<T>
export function fromSync$<S, T>(sync: (s: S) => T, s?: S): Observable<T> {
return new Observable((subscriber: Observer<T>) => {
try {
subscriber.next(sync(s as S))
subscriber.complete()
@@ -71,24 +94,3 @@ export function fromSync$<S, T> (sync: (s: S) => T, s?: S): Observable<T> {
}
})
}
/*
this function concats the current values (e.g in behavior subjects) or next values (in traditional observables) to a collection of values in a pipe.
e.g. if t: Observable<T>, and o1: Observable<O1> o2: Observable<O2> then t.pipe(concatObservableValues([o1, o2])): Observable<[T1, O1, O2]> and emits iff t emits.
Note that the standard combineLatest([t, o1, o2]) is also of type Observable<[T, O2, O2]>, but this observable triggers when any of t, o1, o2 emits.
*/
export function concatObservableValues<T, O> (observables: [Observable<O>]): OperatorFunction<T, [T, O]>
export function concatObservableValues<T, O> (observables: [Observable<O>]): OperatorFunction<[T], [T, O]>
export function concatObservableValues<T1, T2, O> (observables: [Observable<O>]): OperatorFunction<[T1, T2], [T1, T2, O]>
export function concatObservableValues<T, O1, O2> (observables: [Observable<O1>, Observable<O2>]): OperatorFunction<[T], [T, O1, O2]>
export function concatObservableValues<T1, T2, O1, O2> (observables: [Observable<O1>, Observable<O2>]): OperatorFunction<[T1, T2], [T1, T2, O1, O2]>
export function concatObservableValues (observables: Observable<any>[]): OperatorFunction<any[], any[]> {
return o => o.pipe(concatMap(args => combineLatest(observables).pipe(
map(obs => {
if (!(args instanceof Array)) return [args, ...obs]
return [...args, ...obs]
}),
take(1),
),
))
}

View File

@@ -8,6 +8,16 @@
"noImplicitOverride": true,
"noPropertyAccessFromIndexSignature": true,
"noFallthroughCasesInSwitch": true,
"alwaysStrict": true,
// "strictNullChecks": true,
"strictBindCallApply": true,
"strictFunctionTypes": true,
// "strictPropertyInitialization": true,
// "noImplicitAny": true,
"noImplicitThis": true,
"useUnknownInCatchVariables": true,
"sourceMap": true,
"declaration": false,
"downlevelIteration": true,