Feat/update tab (#1865)

* implement updates tab for viewing all updates from all marketplaces in one place

* remove auto-check-updates

* feat: implement updates page (#1888)

* feat: implement updates page

* chore: comments

* better styling in update tab

* rework marketplace service (#1891)

* rework marketplace service

* remove unneeded ?

* fix: refactor marketplace to cache requests

Co-authored-by: waterplea <alexander@inkin.ru>

Co-authored-by: Alex Inkin <alexander@inkin.ru>
This commit is contained in:
Matt Hill
2022-10-27 15:48:12 -06:00
committed by Aiden McClelland
parent d380cc31fa
commit 26c37ba824
53 changed files with 723 additions and 724 deletions

View File

@@ -1,12 +1,12 @@
{
"name": "embassy-os",
"version": "0.3.2",
"version": "0.3.2.1",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "embassy-os",
"version": "0.3.2",
"version": "0.3.2.1",
"dependencies": {
"@angular/animations": "^14.1.0",
"@angular/common": "^14.1.0",
@@ -20,6 +20,7 @@
"@materia-ui/ngx-monaco-editor": "^6.0.0",
"@start9labs/argon2": "^0.1.0",
"@start9labs/emver": "^0.1.5",
"angular-svg-round-progressbar": "^9.0.0",
"ansi-to-html": "^0.7.2",
"base64-js": "^1.5.1",
"cbor": "npm:@jprochazk/cbor@^0.4.9",
@@ -4127,6 +4128,18 @@
"ajv": "^8.8.2"
}
},
"node_modules/angular-svg-round-progressbar": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/angular-svg-round-progressbar/-/angular-svg-round-progressbar-9.0.0.tgz",
"integrity": "sha512-q8d2AEG9u+GMAMrZY40NgejN5fHwR4iK+rRxtJ7NnMEvvuAMqt9UEtKe0SqVQHvZYE6W16L5J9yaO+TEtfRjpw==",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": "^14.0.0",
"@angular/core": "^14.0.0"
}
},
"node_modules/ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",
@@ -17628,6 +17641,14 @@
"fast-deep-equal": "^3.1.3"
}
},
"angular-svg-round-progressbar": {
"version": "9.0.0",
"resolved": "https://registry.npmjs.org/angular-svg-round-progressbar/-/angular-svg-round-progressbar-9.0.0.tgz",
"integrity": "sha512-q8d2AEG9u+GMAMrZY40NgejN5fHwR4iK+rRxtJ7NnMEvvuAMqt9UEtKe0SqVQHvZYE6W16L5J9yaO+TEtfRjpw==",
"requires": {
"tslib": "^2.3.0"
}
},
"ansi-colors": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-4.1.3.tgz",

View File

@@ -39,6 +39,7 @@
"@materia-ui/ngx-monaco-editor": "^6.0.0",
"@start9labs/argon2": "^0.1.0",
"@start9labs/emver": "^0.1.5",
"angular-svg-round-progressbar": "^9.0.0",
"ansi-to-html": "^0.7.2",
"base64-js": "^1.5.1",
"cbor": "npm:@jprochazk/cbor@^0.4.9",

View File

@@ -1,6 +1,5 @@
{
"name": null,
"auto-check-updates": true,
"ack-welcome": "0.3.2.1",
"marketplace": {
"selected-url": "https://registry.start9.com/",

View File

@@ -1,6 +1,5 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { MarketplacePkg } from '../../../types/marketplace-pkg'
import { MarketplacePkg } from '../../../types'
@Component({
selector: 'marketplace-item',

View File

@@ -14,7 +14,7 @@ export class ReleaseNotesComponent {
private selected: string | null = null
readonly notes$ = this.marketplaceService.fetchReleaseNotes(this.pkgId)
readonly notes$ = this.marketplaceService.fetchReleaseNotes$(this.pkgId)
constructor(
private readonly route: ActivatedRoute,

View File

@@ -1,6 +1,5 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { MarketplacePkg } from '../../../types/marketplace-pkg'
import { MarketplacePkg } from '../../../types'
@Component({
selector: 'marketplace-about',

View File

@@ -7,8 +7,8 @@ import {
} from '@angular/core'
import { AlertController, ModalController } from '@ionic/angular'
import { displayEmver, Emver, MarkdownComponent } from '@start9labs/shared'
import { MarketplacePkg } from '../../../types'
import { AbstractMarketplaceService } from '../../../services/marketplace.service'
import { MarketplacePkg } from '../../../types/marketplace-pkg'
@Component({
selector: 'marketplace-additional',
@@ -57,7 +57,7 @@ export class AdditionalComponent {
}
async presentModalMd(title: string) {
const content = this.marketplaceService.fetchPackageMarkdown(
const content = this.marketplaceService.fetchStatic$(
this.pkg.manifest.id,
title,
)

View File

@@ -1,6 +1,5 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { MarketplacePkg } from '../../../types/marketplace-pkg'
import { MarketplacePkg } from '../../../types'
@Component({
selector: 'marketplace-dependencies',

View File

@@ -1,6 +1,5 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { MarketplacePkg } from '../../../types/marketplace-pkg'
import { MarketplacePkg } from '../../../types'
@Component({
selector: 'marketplace-package',

View File

@@ -1,7 +1,6 @@
import { NgModule, Pipe, PipeTransform } from '@angular/core'
import { MarketplacePkg } from '../types/marketplace-pkg'
import { MarketplaceManifest } from '../types/marketplace-manifest'
import { Emver } from '@start9labs/shared'
import { MarketplaceManifest, MarketplacePkg } from '../types'
import Fuse from 'fuse.js'
@Pipe({

View File

@@ -25,7 +25,4 @@ export * from './pipes/filter-packages.pipe'
export * from './services/marketplace.service'
export * from './types/dependency'
export * from './types/marketplace-info'
export * from './types/marketplace-manifest'
export * from './types/marketplace-pkg'
export * from './types'

View File

@@ -1,24 +1,33 @@
import { Observable } from 'rxjs'
import { MarketplaceInfo } from '../types/marketplace-info'
import { MarketplacePkg } from '../types/marketplace-pkg'
import {
MarketplacePkg,
Marketplace,
MarketplaceURL,
MarketplaceName,
StoreData,
} from '../types'
export abstract class AbstractMarketplaceService {
abstract getMarketplaceInfo$(): Observable<MarketplaceInfo>
abstract getKnownHosts$(): Observable<Record<MarketplaceURL, MarketplaceName>>
abstract getPackages$(): Observable<MarketplacePkg[]>
abstract getSelectedHost$(): Observable<{ url: string; name: string }>
abstract getPackage(
abstract getMarketplace$(): Observable<Marketplace>
abstract getSelectedStore$(): Observable<StoreData | null>
abstract getPackage$(
id: string,
version: string,
url?: string,
): Observable<MarketplacePkg | undefined>
abstract fetchReleaseNotes(
abstract fetchReleaseNotes$(
id: string,
url?: string,
): Observable<Record<string, string>>
abstract fetchPackageMarkdown(
abstract fetchStatic$(
id: string,
type: string,
url?: string,

View File

@@ -0,0 +1,76 @@
import { Url } from '@start9labs/shared'
export type MarketplaceURL = string
export type MarketplaceName = string
export type Marketplace = Record<MarketplaceURL, StoreData | null>
export interface StoreData {
info: StoreInfo
packages: MarketplacePkg[]
}
export interface StoreInfo {
name: MarketplaceName
categories: string[]
}
export interface MarketplacePkg {
icon: Url
license: Url
instructions: Url
manifest: MarketplaceManifest
categories: string[]
versions: string[]
'dependency-metadata': {
[id: string]: {
title: string
icon: Url
}
}
'published-at': string
}
export interface MarketplaceManifest<T = unknown> {
id: string
title: string
version: string
description: {
short: string
long: string
}
'release-notes': string
license: string // type of license
'wrapper-repo': Url
'upstream-repo': Url
'support-site': Url
'marketing-site': Url
'donation-url': Url | null
alerts: {
install: string | null
uninstall: string | null
restore: string | null
start: string | null
stop: string | null
}
dependencies: Record<string, Dependency<T>>
}
export interface Dependency<T> {
version: string
requirement:
| {
type: 'opt-in'
how: string
}
| {
type: 'opt-out'
how: string
}
| {
type: 'required'
}
description: string | null
config: T
}

View File

@@ -1,17 +0,0 @@
export interface Dependency<T> {
version: string
requirement:
| {
type: 'opt-in'
how: string
}
| {
type: 'opt-out'
how: string
}
| {
type: 'required'
}
description: string | null
config: T
}

View File

@@ -1,4 +0,0 @@
export interface MarketplaceInfo {
name: string
categories: string[]
}

View File

@@ -1,27 +0,0 @@
import { Url } from '@start9labs/shared'
import { Dependency } from './dependency'
export interface MarketplaceManifest<T = unknown> {
id: string
title: string
version: string
description: {
short: string
long: string
}
'release-notes': string
license: string // type of license
'wrapper-repo': Url
'upstream-repo': Url
'support-site': Url
'marketing-site': Url
'donation-url': Url | null
alerts: {
install: string | null
uninstall: string | null
restore: string | null
start: string | null
stop: string | null
}
dependencies: Record<string, Dependency<T>>
}

View File

@@ -1,18 +0,0 @@
import { Url } from '@start9labs/shared'
import { MarketplaceManifest } from './marketplace-manifest'
export interface MarketplacePkg {
icon: Url
license: Url
instructions: Url
manifest: MarketplaceManifest
categories: string[]
versions: string[]
'dependency-metadata': {
[id: string]: {
title: string
icon: Url
}
}
'published-at': string
}

View File

@@ -47,8 +47,9 @@ export * from './types/workspace-config'
export * from './tokens/relative-url'
export * from './util/copy-to-clipboard'
export * from './util/base-64'
export * from './util/copy-to-clipboard'
export * from './util/get-new-entries'
export * from './util/get-pkg-id'
export * from './util/misc.util'
export * from './util/rpc.util'

View File

@@ -0,0 +1,12 @@
export function getNewEntries<T extends Record<any, any>>(prev: T, curr: T): T {
return Object.entries(curr).reduce(
(result, [key, value]) =>
prev[key]
? result
: {
...result,
[key]: value,
},
{} as T,
)
}

View File

@@ -24,6 +24,13 @@ const routes: Routes = [
m => m.ServerRoutingModule,
),
},
{
path: 'updates',
canActivate: [AuthGuard],
canActivateChild: [AuthGuard],
loadChildren: () =>
import('./pages/updates/updates.module').then(m => m.UpdatesPageModule),
},
{
path: 'marketplace',
canActivate: [AuthGuard],

View File

@@ -29,9 +29,7 @@
name="rocket-outline"
></ion-icon>
<ion-badge
*ngIf="
page.url === '/marketplace' && (updateCount$ | async) as updateCount
"
*ngIf="page.url === '/updates' && (updateCount$ | async) as updateCount"
color="success"
>
{{ updateCount }}

View File

@@ -1,12 +1,13 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import { EOSService } from '../../services/eos.service'
import { PatchDB } from 'patch-db-client'
import { iif, Observable } from 'rxjs'
import { filter, map, switchMap } from 'rxjs/operators'
import { combineLatest, map, Observable, of, startWith } from 'rxjs'
import { AbstractMarketplaceService } from '@start9labs/marketplace'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { SplitPaneTracker } from 'src/app/services/split-pane.service'
import { Emver } from '@start9labs/shared'
import { marketplaceSame, versionLower } from '../../pages/updates/updates.page'
@Component({
selector: 'app-menu',
@@ -26,6 +27,11 @@ export class MenuComponent {
url: '/embassy',
icon: 'cube-outline',
},
{
title: 'Updates',
url: '/updates',
icon: 'globe-outline',
},
{
title: 'Marketplace',
url: '/marketplace',
@@ -47,20 +53,23 @@ export class MenuComponent {
readonly showEOSUpdate$ = this.eosService.showUpdate$
readonly updateCount$: Observable<number> = this.patch
.watch$('ui', 'auto-check-updates')
.pipe(
filter(Boolean),
switchMap(() =>
this.marketplaceService.getUpdates$().pipe(
map(arr => {
return arr.reduce(
(acc, marketplace) => acc + marketplace.pkgs.length,
readonly updateCount$: Observable<number> = combineLatest([
this.marketplaceService.getMarketplace$(),
this.patch.watch$('package-data'),
]).pipe(
map(([marketplace, local]) =>
Object.entries(marketplace).reduce(
(length, [url, store]) =>
length +
(store?.packages.filter(
({ manifest }) =>
marketplaceSame(manifest, local, url) &&
versionLower(manifest, local, this.emver),
).length || 0),
0,
)
}),
),
),
startWith(0),
)
readonly sidebarOpen$ = this.splitPane.sidebarOpen$
@@ -71,5 +80,6 @@ export class MenuComponent {
@Inject(AbstractMarketplaceService)
private readonly marketplaceService: MarketplaceService,
private readonly splitPane: SplitPaneTracker,
private readonly emver: Emver,
) {}
}

View File

@@ -25,7 +25,6 @@ const ICONS = [
'cloud-offline-outline',
'cloud-upload-outline',
'code-outline',
'cog-outline',
'color-wand-outline',
'construct-outline',
'copy-outline',
@@ -56,12 +55,12 @@ const ICONS = [
'newspaper-outline',
'notifications-outline',
'open-outline',
'options-outline',
'pencil',
'phone-portrait-outline',
'play-circle-outline',
'play-outline',
'power',
'pricetag-outline',
'pulse',
'push-outline',
'qr-code-outline',

View File

@@ -2,14 +2,26 @@
<ion-item-group>
<ng-container *ngFor="let g of groupsArr">
<ion-item-divider>
<ion-skeleton-text animated style="width: 120px; height: 16px;"></ion-skeleton-text>
<ion-skeleton-text
animated
style="width: 120px; height: 16px"
></ion-skeleton-text>
</ion-item-divider>
<ion-item *ngFor="let r of rowsArr">
<ion-avatar *ngIf="showAvatar" slot="start">
<ion-skeleton-text [animated]="true"></ion-skeleton-text>
</ion-avatar>
<ion-label>
<ion-skeleton-text animated style="width: 200px; height: 14px;"></ion-skeleton-text>
<ion-skeleton-text
animated
style="width: 200px; height: 14px"
></ion-skeleton-text>
</ion-label>
<ion-note slot="end">
<ion-skeleton-text animated style="width: 80px; height: 14px;"></ion-skeleton-text>
<ion-skeleton-text
animated
style="width: 80px; height: 14px"
></ion-skeleton-text>
</ion-note>
</ion-item>
</ng-container>
@@ -19,11 +31,20 @@
<ng-container *ngIf="!groups">
<ion-item-group>
<ion-item *ngFor="let r of rowsArr">
<ion-avatar *ngIf="showAvatar" slot="start">
<ion-skeleton-text [animated]="true"></ion-skeleton-text>
</ion-avatar>
<ion-label>
<ion-skeleton-text animated style="width: 200px; height: 14px;"></ion-skeleton-text>
<ion-skeleton-text
animated
style="width: 200px; height: 14px"
></ion-skeleton-text>
</ion-label>
<ion-note slot="end">
<ion-skeleton-text animated style="width: 80px; height: 14px;"></ion-skeleton-text>
<ion-skeleton-text
animated
style="width: 80px; height: 14px"
></ion-skeleton-text>
</ion-note>
</ion-item>
</ion-item-group>

View File

@@ -1,17 +1,18 @@
import { Component, Input, OnChanges } from '@angular/core'
import { Component, Input } from '@angular/core'
@Component({
selector: 'skeleton-list',
templateUrl: './skeleton-list.component.html',
styleUrls: ['./skeleton-list.component.scss'],
})
export class SkeletonListComponent implements OnChanges {
export class SkeletonListComponent {
@Input() groups = 0
@Input() rows = 3
@Input() showAvatar = false
groupsArr: number[] = []
rowsArr: number[] = []
ngOnChanges() {
ngOnInit() {
this.groupsArr = Array(this.groups).fill(0)
this.rowsArr = Array(this.rows).fill(0)
}

View File

@@ -18,9 +18,9 @@ export class AppListPage {
const length = next.length
return !length || prev.length !== length
}),
map(([_, pkgs]) => {
return pkgs.sort((a, b) => (b.manifest.title > a.manifest.title ? -1 : 1))
}),
map(([_, pkgs]) =>
pkgs.sort((a, b) => (b.manifest.title > a.manifest.title ? -1 : 1)),
),
)
constructor(private readonly patch: PatchDB<DataModel>) {}

View File

@@ -11,7 +11,7 @@
</ion-header>
<ion-content class="ion-padding">
<skeleton-list *ngIf="loading" [rows]="3"></skeleton-list>
<skeleton-list *ngIf="loading"></skeleton-list>
<ion-item-group *ngIf="!loading">
<ion-item *ngFor="let metric of metrics | keyvalue : asIsOrder">
<ion-label>{{ metric.key }}</ion-label>

View File

@@ -29,13 +29,13 @@
</ion-row>
<ion-row class="ion-align-items-center">
<ion-col size="12">
<ng-container *ngIf="marketplace$ | async as marketplace; else loading">
<ng-container *ngIf="store$ | async as store; else loading">
<ng-container *ngIf="localPkgs$ | async as localPkgs">
<marketplace-categories
[categories]="marketplace.categories"
[categories]="store.categories"
[category]="category"
[updatesAvailable]="
(marketplace.pkgs | filterPackages: '':'updates':localPkgs).length
(store.packages | filterPackages: '':'updates':localPkgs).length
"
(categoryChange)="onCategoryChange($event)"
></marketplace-categories>
@@ -43,7 +43,7 @@
<div class="divider"></div>
<ion-grid
*ngIf="marketplace.pkgs | filterPackages: query:category:localPkgs as filtered"
*ngIf="store.packages | filterPackages: query:category:localPkgs as filtered"
>
<div
*ngIf="!filtered.length && category === 'updates'"

View File

@@ -1,7 +1,7 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import { AbstractMarketplaceService } from '@start9labs/marketplace'
import { PatchDB } from 'patch-db-client'
import { combineLatest, map } from 'rxjs'
import { filter, map } from 'rxjs'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
@@ -12,30 +12,22 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MarketplaceListPage {
private readonly pkgs$ = this.marketplaceService.getPackages$()
readonly store$ = this.marketplaceService.getSelectedStore$().pipe(
filter(Boolean),
map(({ info, packages }) => {
const categories = new Set<string>()
if (info.categories.includes('featured')) categories.add('featured')
categories.add('updates')
info.categories.forEach(c => categories.add(c))
categories.add('all')
private readonly categories$ = this.marketplaceService
.getMarketplaceInfo$()
.pipe(
map(({ categories }) => {
const set = new Set<string>()
if (categories.includes('featured')) set.add('featured')
set.add('updates')
categories.forEach(c => set.add(c))
set.add('all')
return set
}),
)
readonly marketplace$ = combineLatest([this.pkgs$, this.categories$]).pipe(
map(arr => {
return { pkgs: arr[0], categories: arr[1] }
return { categories, packages }
}),
)
readonly localPkgs$ = this.patch.watch$('package-data')
readonly details$ = this.marketplaceService.getUiMarketplace$().pipe(
readonly details$ = this.marketplaceService.getSelectedHost$().pipe(
map(({ url, name }) => {
let color: string
let description: string

View File

@@ -62,7 +62,7 @@ export class MarketplaceShowControlsComponent {
async tryInstall() {
const currentMarketplace = await firstValueFrom(
this.marketplaceService.getUiMarketplace$(),
this.marketplaceService.getSelectedHost$(),
)
const url = this.url || currentMarketplace.url

View File

@@ -25,7 +25,7 @@ export class MarketplaceShowPage {
readonly pkg$ = this.loadVersion$.pipe(
switchMap(version =>
this.marketplaceService.getPackage(this.pkgId, version, this.url),
this.marketplaceService.getPackage$(this.pkgId, version, this.url),
),
)

View File

@@ -200,7 +200,7 @@ export class MarketplacesPage {
loader.message = 'Validating marketplace...'
await loader.present()
const name = await this.marketplaceService.validateMarketplace(url)
const name = await firstValueFrom(this.marketplaceService.fetchInfo$(url))
// Save
loader.message = 'Saving...'

View File

@@ -1,24 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { PreferencesPage } from './preferences.page'
import { Routes, RouterModule } from '@angular/router'
import { SharedPipesModule } from '@start9labs/shared'
const routes: Routes = [
{
path: '',
component: PreferencesPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
SharedPipesModule,
],
declarations: [PreferencesPage],
})
export class PreferencesPageModule {}

View File

@@ -1,30 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="embassy"></ion-back-button>
</ion-buttons>
<ion-title (click)="addClick()">Preferences</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<ion-item-group *ngIf="name$ | async as name">
<ion-item-divider>General</ion-item-divider>
<ion-item button (click)="presentModalName(name)">
<ion-label>Device Name</ion-label>
<ion-note slot="end">{{ name.current }}</ion-note>
</ion-item>
<ion-item-divider>Marketplace</ion-item-divider>
<ion-item
*ngIf="ui$ | async as ui"
button
(click)="serverConfig.presentAlert('auto-check-updates', ui['auto-check-updates'])"
>
<ion-label>Auto Check for Updates</ion-label>
<ion-note slot="end">
{{ ui['auto-check-updates'] ? 'Enabled' : 'Disabled' }}
</ion-note>
</ion-item>
</ion-item-group>
</ion-content>

View File

@@ -1,99 +0,0 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import {
LoadingController,
ModalController,
ToastController,
} from '@ionic/angular'
import {
GenericInputComponent,
GenericInputOptions,
} from 'src/app/modals/generic-input/generic-input.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ServerConfigService } from 'src/app/services/server-config.service'
import { ClientStorageService } from '../../../services/client-storage.service'
import {
ServerNameInfo,
ServerNameService,
} from 'src/app/services/server-name.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
@Component({
selector: 'preferences',
templateUrl: './preferences.page.html',
styleUrls: ['./preferences.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class PreferencesPage {
clicks = 0
readonly ui$ = this.patch.watch$('ui')
readonly server$ = this.patch.watch$('server-info')
readonly name$ = this.serverNameService.name$
constructor(
private readonly loadingCtrl: LoadingController,
private readonly modalCtrl: ModalController,
private readonly api: ApiService,
private readonly toastCtrl: ToastController,
private readonly ClientStorageService: ClientStorageService,
private readonly patch: PatchDB<DataModel>,
private readonly serverNameService: ServerNameService,
readonly serverConfig: ServerConfigService,
) {}
async presentModalName(name: ServerNameInfo): Promise<void> {
const options: GenericInputOptions = {
title: 'Edit Device Name',
message: 'This is for your reference only.',
label: 'Device Name',
useMask: false,
placeholder: name.default,
nullable: true,
initialValue: name.current,
buttonText: 'Save',
submitFn: (value: string) =>
this.setDbValue('name', value || name.default),
}
const modal = await this.modalCtrl.create({
componentProps: { options },
cssClass: 'alertlike-modal',
presentingElement: await this.modalCtrl.getTop(),
component: GenericInputComponent,
})
await modal.present()
}
private async setDbValue(key: string, value: string): Promise<void> {
const loader = await this.loadingCtrl.create({
message: 'Saving...',
})
await loader.present()
try {
await this.api.setDbValue([key], value)
} finally {
loader.dismiss()
}
}
async addClick() {
this.clicks++
if (this.clicks >= 5) {
this.clicks = 0
const newVal = this.ClientStorageService.toggleShowDevTools()
const toast = await this.toastCtrl.create({
header: newVal ? 'Dev tools unlocked' : 'Dev tools hidden',
position: 'bottom',
duration: 1000,
})
await toast.present()
}
setTimeout(() => {
this.clicks = Math.max(this.clicks - 1, 0)
}, 10000)
}
}

View File

@@ -48,13 +48,6 @@ const routes: Routes = [
m => m.ServerMetricsPageModule,
),
},
{
path: 'preferences',
loadChildren: () =>
import('./preferences/preferences.module').then(
m => m.PreferencesPageModule,
),
},
{
path: 'restore',
loadChildren: () =>

View File

@@ -24,10 +24,7 @@
<ion-item-group *ngIf="server$ | async as server; else loading">
<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'" (click)="addClick()">
<ion-text color="dark" (click)="addClick(cat.key)">
{{ cat.key }}
</ion-text>
</ion-item-divider>

View File

@@ -4,12 +4,13 @@ import {
LoadingController,
NavController,
ModalController,
ToastController,
} from '@ionic/angular'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ActivatedRoute } from '@angular/router'
import { PatchDB } from 'patch-db-client'
import { ServerNameService } from 'src/app/services/server-name.service'
import { Observable, of } from 'rxjs'
import { firstValueFrom, Observable, of } from 'rxjs'
import { ErrorToastService } from '@start9labs/shared'
import { EOSService } from 'src/app/services/eos.service'
import { ClientStorageService } from 'src/app/services/client-storage.service'
@@ -17,6 +18,10 @@ import { OSUpdatePage } from 'src/app/modals/os-update/os-update.page'
import { getAllPackages } from '../../../util/get-package-data'
import { AuthService } from 'src/app/services/auth.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import {
GenericInputComponent,
GenericInputOptions,
} from 'src/app/modals/generic-input/generic-input.component'
@Component({
selector: 'server-show',
@@ -24,7 +29,8 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
styleUrls: ['server-show.page.scss'],
})
export class ServerShowPage {
clicks = 0
settingsClicks = 0
powerClicks = 0
readonly server$ = this.patch.watch$('server-info')
readonly name$ = this.serverNameService.name$
@@ -44,8 +50,35 @@ export class ServerShowPage {
private readonly ClientStorageService: ClientStorageService,
private readonly serverNameService: ServerNameService,
private readonly authService: AuthService,
private readonly toastCtrl: ToastController,
) {}
async presentModalName(): Promise<void> {
const name = await firstValueFrom(this.name$)
const options: GenericInputOptions = {
title: 'Edit Device Name',
message: 'This is for your reference only.',
label: 'Device Name',
useMask: false,
placeholder: name.default,
nullable: true,
initialValue: name.current,
buttonText: 'Save',
submitFn: (value: string) =>
this.setDbValue('name', value || name.default),
}
const modal = await this.modalCtrl.create({
componentProps: { options },
cssClass: 'alertlike-modal',
presentingElement: await this.modalCtrl.getTop(),
component: GenericInputComponent,
})
await modal.present()
}
async updateEos(): Promise<void> {
const modal = await this.modalCtrl.create({
component: OSUpdatePage,
@@ -170,6 +203,32 @@ export class ServerShowPage {
await alert.present()
}
addClick(title: string) {
switch (title) {
case 'Settings':
this.addSettingsClick()
break
case 'Power':
this.addPowerClick()
break
default:
return
}
}
private async setDbValue(key: string, value: string): Promise<void> {
const loader = await this.loadingCtrl.create({
message: 'Saving...',
})
await loader.present()
try {
await this.embassyApi.setDbValue([key], value)
} finally {
loader.dismiss()
}
}
// should wipe cache independent of actual BE logout
private logout() {
this.embassyApi.logout({}).catch(e => console.error('Failed to log out', e))
@@ -311,7 +370,7 @@ export class ServerShowPage {
{
title: 'Software Update',
description: 'Get the latest version of embassyOS',
icon: 'cog-outline',
icon: 'cloud-download-outline',
action: () =>
this.eosService.updateAvailable$.getValue()
? this.updateEos()
@@ -320,14 +379,11 @@ export class ServerShowPage {
disabled$: this.eosService.updatingOrBackingUp$,
},
{
title: 'Preferences',
description: 'Device name, background tasks',
icon: 'options-outline',
action: () =>
this.navCtrl.navigateForward(['preferences'], {
relativeTo: this.route,
}),
detail: true,
title: 'Device Name',
description: 'Edit the local display name of your Embassy',
icon: 'pricetag-outline',
action: () => this.presentModalName(),
detail: false,
disabled$: of(false),
},
{
@@ -504,19 +560,31 @@ export class ServerShowPage {
],
}
asIsOrder() {
return 0
private async addSettingsClick() {
this.settingsClicks++
if (this.settingsClicks === 5) {
this.settingsClicks = 0
const newVal = this.ClientStorageService.toggleShowDevTools()
const toast = await this.toastCtrl.create({
header: newVal ? 'Dev tools unlocked' : 'Dev tools hidden',
position: 'bottom',
duration: 1000,
})
await toast.present()
}
}
addClick() {
this.clicks++
if (this.clicks >= 5) {
this.clicks = 0
private addPowerClick() {
this.powerClicks++
if (this.powerClicks === 5) {
this.powerClicks = 0
this.ClientStorageService.toggleShowDiskRepair()
}
setTimeout(() => {
this.clicks = Math.max(this.clicks - 1, 0)
}, 10000)
}
asIsOrder() {
return 0
}
}

View File

@@ -0,0 +1,33 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { RouterModule, Routes } from '@angular/router'
import { FilterUpdatesPipe, UpdatesPage } from './updates.page'
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
import { MarkdownPipeModule, SharedPipesModule } from '@start9labs/shared'
import { SkeletonListComponentModule } from 'src/app/components/skeleton-list/skeleton-list.component.module'
import { RoundProgressModule } from 'angular-svg-round-progressbar'
import { InstallProgressPipeModule } from 'src/app/pipes/install-progress/install-progress.module'
const routes: Routes = [
{
path: '',
component: UpdatesPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
BadgeMenuComponentModule,
SharedPipesModule,
SkeletonListComponentModule,
MarkdownPipeModule,
RoundProgressModule,
InstallProgressPipeModule,
],
declarations: [UpdatesPage, FilterUpdatesPipe],
})
export class UpdatesPageModule {}

View File

@@ -0,0 +1,84 @@
<ion-header>
<ion-toolbar>
<ion-title>Updates</ion-title>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-item-group *ngIf="data$ | async as data">
<ng-container *ngFor="let host of data.hosts | keyvalue">
<ion-item-divider> {{ host.value }} </ion-item-divider>
<div class="ion-padding-start ion-padding-bottom">
<ion-item *ngIf="data.errors.includes(host.key)">
<ion-text color="danger">Request Failed</ion-text>
</ion-item>
<ng-container
*ngIf="data.marketplace[host.key]?.packages as packages else loading"
>
<ng-container
*ngIf="packages | filterUpdates : data.localPkgs : host.key as updates"
>
<ion-item *ngFor="let pkg of updates">
<ng-container *ngIf="data.localPkgs[pkg.manifest.id] as local">
<ion-avatar slot="start">
<img [src]="'data:image/png;base64,' + pkg.icon | trustUrl" />
</ion-avatar>
<ion-label>
<h1>{{ pkg.manifest.title }}</h1>
<h2 class="inline">
<span>{{ local.manifest.version }}</span>
&nbsp;<ion-icon name="arrow-forward"></ion-icon>&nbsp;
<ion-text color="success">
{{ pkg.manifest.version }}
</ion-text>
</h2>
<p [innerHTML]="pkg.manifest['release-notes'] | markdown"></p>
</ion-label>
<div slot="end">
<round-progress
*ngIf="local.state === PackageState.Installing else notInstalling"
[current]="local['install-progress'] | installProgress"
[max]="100"
[radius]="24"
[stroke]="4"
[rounded]="true"
color="var(--ion-color-primary)"
></round-progress>
<ng-template #notInstalling>
<ion-spinner
*ngIf="queued[pkg.manifest.id] else updateBtn"
color="dark"
></ion-spinner>
<ng-template #updateBtn>
<ion-button
(click)="update(pkg.manifest.id, host.key)"
color="dark"
strong
>
Update
</ion-button>
</ng-template>
</ng-template>
</div>
</ng-container>
</ion-item>
<ion-item *ngIf="!updates.length">
<p>All services are up to date!</p>
</ion-item>
</ng-container>
</ng-container>
<ng-template #loading>
<skeleton-list [showAvatar]="true" [rows]="2"></skeleton-list>
</ng-template>
</div>
</ng-container>
</ion-item-group>
</ion-content>

View File

@@ -0,0 +1,12 @@
ion-avatar {
position: absolute;
top: 6px;
}
ion-label {
margin-left: 64px;
}
.name:only-child {
display: none;
}

View File

@@ -0,0 +1,95 @@
import { Component, Inject } from '@angular/core'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { PatchDB } from 'patch-db-client'
import {
DataModel,
PackageDataEntry,
PackageState,
} from 'src/app/services/patch-db/data-model'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import {
AbstractMarketplaceService,
Marketplace,
MarketplaceManifest,
MarketplacePkg,
} from '@start9labs/marketplace'
import { Emver } from '@start9labs/shared'
import { Pipe, PipeTransform } from '@angular/core'
import { combineLatest, Observable } from 'rxjs'
import { PrimaryRendering } from '../../services/pkg-status-rendering.service'
interface UpdatesData {
hosts: Record<string, string>
marketplace: Marketplace
localPkgs: Record<string, PackageDataEntry>
errors: string[]
}
@Component({
selector: 'updates',
templateUrl: 'updates.page.html',
styleUrls: ['updates.page.scss'],
})
export class UpdatesPage {
queued: Record<string, boolean> = {}
readonly data$: Observable<UpdatesData> = combineLatest({
hosts: this.marketplaceService.getKnownHosts$(),
marketplace: this.marketplaceService.getMarketplace$(),
localPkgs: this.patch.watch$('package-data'),
errors: this.marketplaceService.getRequestErrors$(),
})
readonly PackageState = PackageState
readonly rendering = PrimaryRendering[PackageState.Installing]
constructor(
@Inject(AbstractMarketplaceService)
private readonly marketplaceService: MarketplaceService,
private readonly api: ApiService,
private readonly patch: PatchDB<DataModel>,
) {}
async update(id: string, url: string): Promise<void> {
this.queued[id] = true
this.api.installPackage({ id, 'marketplace-url': url })
}
}
@Pipe({
name: 'filterUpdates',
})
export class FilterUpdatesPipe implements PipeTransform {
constructor(private readonly emver: Emver) {}
transform(
pkgs: MarketplacePkg[],
local: Record<string, PackageDataEntry> = {},
url: string,
): MarketplacePkg[] {
return pkgs.filter(
({ manifest }) =>
marketplaceSame(manifest, local, url) &&
versionLower(manifest, local, this.emver),
)
}
}
export function marketplaceSame(
{ id }: MarketplaceManifest,
local: Record<string, PackageDataEntry>,
url: string,
): boolean {
return local[id]?.installed?.['marketplace-url'] === url
}
export function versionLower(
{ version, id }: MarketplaceManifest,
local: Record<string, PackageDataEntry>,
emver: Emver,
): boolean {
return (
local[id].state === PackageState.Installing ||
emver.compare(version, local[id].installed?.manifest.version || '') === 1
)
}

View File

@@ -1,8 +1,11 @@
import { NgModule } from '@angular/core'
import { InstallProgressPipe } from './install-progress.pipe'
import {
InstallProgressDisplayPipe,
InstallProgressPipe,
} from './install-progress.pipe'
@NgModule({
declarations: [InstallProgressPipe],
exports: [InstallProgressPipe],
declarations: [InstallProgressPipe, InstallProgressDisplayPipe],
exports: [InstallProgressPipe, InstallProgressDisplayPipe],
})
export class InstallProgressPipeModule {}

View File

@@ -3,11 +3,21 @@ import { InstallProgress } from 'src/app/services/patch-db/data-model'
import { packageLoadingProgress } from 'src/app/util/package-loading-progress'
@Pipe({
name: 'installProgressDisplay',
name: 'installProgress',
})
export class InstallProgressPipe implements PipeTransform {
transform(installProgress?: InstallProgress): number {
return packageLoadingProgress(installProgress)?.totalProgress || 0
}
}
@Pipe({
name: 'installProgressDisplay',
})
export class InstallProgressDisplayPipe implements PipeTransform {
transform(installProgress?: InstallProgress): string {
const totalProgress = packageLoadingProgress(installProgress)?.totalProgress || 0
const totalProgress =
packageLoadingProgress(installProgress)?.totalProgress || 0
return totalProgress < 99 ? totalProgress + '%' : 'finalizing'
}

View File

@@ -1787,7 +1787,7 @@ export module Mock {
},
'current-dependencies': {},
'dependency-info': {},
'marketplace-url': 'https://marketplace-url.com',
'marketplace-url': 'https://registry.start9.com/',
'developer-key': 'developer-key',
},
'install-progress': undefined,
@@ -1836,7 +1836,7 @@ export module Mock {
icon: 'assets/img/service-icons/bitcoind.png',
},
},
'marketplace-url': 'https://marketplace-url.com',
'marketplace-url': 'https://registry.start9.com/',
'developer-key': 'developer-key',
},
'install-progress': undefined,
@@ -1896,7 +1896,7 @@ export module Mock {
icon: 'assets/img/service-icons/btc-rpc-proxy.png',
},
},
'marketplace-url': 'https://marketplace-url.com',
'marketplace-url': 'https://registry.start9.com/',
'developer-key': 'developer-key',
},
'install-progress': undefined,

View File

@@ -1,5 +1,5 @@
import { Dump, Revision } from 'patch-db-client'
import { MarketplaceInfo, MarketplacePkg } from '@start9labs/marketplace'
import { MarketplacePkg, StoreInfo } from '@start9labs/marketplace'
import { PackagePropertiesVersioned } from 'src/app/util/properties.util'
import { ConfigSpec } from 'src/app/pkg-config/config-types'
import {
@@ -241,8 +241,8 @@ export module RR {
// marketplace
export type GetMarketplaceDataReq = { 'server-id': string }
export type GetMarketplaceDataRes = MarketplaceInfo
export type GetMarketplaceInfoReq = { 'server-id': string }
export type GetMarketplaceInfoRes = StoreInfo
export type GetMarketplaceEOSReq = {
'server-id': string

View File

@@ -567,7 +567,6 @@ export class MockApiService extends ApiService {
...Mock.LocalPkgs[params.id],
state: PackageState.Installing,
'install-progress': { ...PROGRESS },
installed: undefined,
},
},
]

View File

@@ -7,11 +7,11 @@ import {
PackageMainStatus,
PackageState,
} from 'src/app/services/patch-db/data-model'
import { Mock } from './api.fixures'
export const mockPatchData: DataModel = {
ui: {
name: `Matt's Embassy`,
'auto-check-updates': true,
'pkg-order': [],
'ack-welcome': '1.0.0',
marketplace: {
@@ -382,7 +382,10 @@ export const mockPatchData: DataModel = {
dependencies: {},
},
installed: {
manifest: {} as Manifest,
manifest: {
...Mock.MockManifestBitcoind,
version: '0.20.0',
},
'last-backup': null,
status: {
configured: true,
@@ -434,7 +437,7 @@ export const mockPatchData: DataModel = {
},
'current-dependencies': {},
'dependency-info': {},
'marketplace-url': 'https://marketplace-url.com',
'marketplace-url': 'https://registry.start9.com/',
'developer-key': 'developer-key',
},
},
@@ -648,7 +651,7 @@ export const mockPatchData: DataModel = {
icon: 'assets/img/service-icons/btc-rpc-proxy.png',
},
},
'marketplace-url': 'https://marketplace-url.com',
'marketplace-url': 'https://registry.start9.com/',
'developer-key': 'developer-key',
},
},

View File

@@ -1,163 +1,131 @@
import { Injectable } from '@angular/core'
import { Emver } from '@start9labs/shared'
import {
MarketplacePkg,
AbstractMarketplaceService,
MarketplaceInfo,
StoreData,
Marketplace,
StoreInfo,
} from '@start9labs/marketplace'
import { combineLatest, from, Observable, of } from 'rxjs'
import {
BehaviorSubject,
combineLatest,
distinctUntilKeyChanged,
from,
mergeMap,
Observable,
of,
scan,
} from 'rxjs'
import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import {
DataModel,
Manifest,
PackageState,
} from 'src/app/services/patch-db/data-model'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { PatchDB } from 'patch-db-client'
import {
distinctUntilChanged,
catchError,
filter,
map,
pairwise,
shareReplay,
startWith,
switchMap,
take,
tap,
} from 'rxjs/operators'
import { getServerInfo } from '../util/get-server-info'
type MarketplaceURL = string
interface MarketplaceData {
info: MarketplaceInfo | null
packages: MarketplacePkg[]
}
type MasterCache = Map<MarketplaceURL, MarketplaceData>
import { getNewEntries } from '@start9labs/shared'
@Injectable()
export class MarketplaceService implements AbstractMarketplaceService {
private readonly cache: MasterCache = new Map()
private readonly knownHosts$ = this.patch.watch$(
'ui',
'marketplace',
'known-hosts',
)
private readonly uiMarketplace$: Observable<{ url: string; name: string }> =
this.patch.watch$('ui', 'marketplace').pipe(
distinctUntilChanged(
(prev, curr) => prev['selected-url'] === curr['selected-url'],
),
map(data => {
const url = data['selected-url']
return {
url,
name: data['known-hosts'][url],
}
}),
private readonly selectedHost$ = this.patch.watch$('ui', 'marketplace').pipe(
distinctUntilKeyChanged('selected-url'),
map(data => ({
url: data['selected-url'],
name: data['known-hosts'][data['selected-url']],
})),
shareReplay(1),
)
private readonly marketplaceData$: Observable<MarketplaceData> =
this.uiMarketplace$.pipe(
switchMap(({ url, name }) =>
from(this.loadMarketplace(url)).pipe(
tap(data => {
this.updateName(url, name, data.info!.name)
private readonly marketplace$ = this.knownHosts$.pipe(
startWith<Record<string, string>>({}),
pairwise(),
mergeMap(([prev, curr]) => from(Object.entries(getNewEntries(prev, curr)))),
mergeMap(([url, name]) =>
this.fetchStore$(url).pipe(
map<StoreData, [string, StoreData | null]>(data => {
if (data.info) this.updateName(url, name, data.info.name)
return [url, data]
}),
startWith<[string, StoreData | null]>([url, null]),
),
),
scan<[string, StoreData | null], Record<string, StoreData | null>>(
(requests, [url, store]) => {
requests[url] = store
return requests
},
{},
),
shareReplay(1),
)
private readonly marketplaceInfo$: Observable<MarketplaceInfo> =
this.marketplaceData$.pipe(map(data => data.info!))
private readonly selectedStore$ = this.selectedHost$.pipe(
switchMap(({ url }) => this.marketplace$.pipe(map(m => m[url]))),
)
private readonly marketplacePkgs$: Observable<MarketplacePkg[]> =
this.marketplaceData$.pipe(map(data => data.packages))
private readonly updates$: Observable<
{ url: string; pkgs: MarketplacePkg[] }[]
> = this.patch.watch$('package-data').pipe(
take(1), // check once per app instance
map(localPkgs => {
return Object.values(localPkgs)
.filter(localPkg => localPkg.state === PackageState.Installed)
.reduce((localPkgMap, pkg) => {
const url = pkg.installed!['marketplace-url'] || '' // side-laoded services will not have marketplace-url
const cached = this.cache
.get(url)
?.packages.find(p => p.manifest.id === pkg.manifest.id)
if (url && !cached) {
const arr = localPkgMap.get(url) || []
localPkgMap.set(url, arr.concat(pkg.manifest))
}
return localPkgMap
}, new Map<string, Manifest[]>())
}),
switchMap(localPkgMap => {
const urls = Array.from(localPkgMap.keys())
const requests = urls.map(url => {
const ids = localPkgMap.get(url)?.map(({ id }) => {
return { id, version: '*' }
})
return from(this.loadPackages({ ids }, url)).pipe(
map(pkgs => {
const manifests = localPkgMap.get(url)!
const filtered = pkgs.filter(pkg => {
const localVersion = manifests.find(
m => m.id === pkg.manifest.id,
)?.version
return (
localVersion &&
this.emver.compare(pkg.manifest.version, localVersion) === 1
)
})
return { url, pkgs: filtered }
}),
startWith({ url, pkgs: [] }), // needed for combineLatest to emit right away
)
})
return combineLatest(requests)
}),
shareReplay(1),
)
private readonly requestErrors$ = new BehaviorSubject<string[]>([])
constructor(
private readonly api: ApiService,
private readonly patch: PatchDB<DataModel>,
private readonly emver: Emver,
) {}
getUiMarketplace$(): Observable<{ url: string; name: string }> {
return this.uiMarketplace$
getKnownHosts$(): Observable<Record<string, string>> {
return this.knownHosts$
}
getMarketplaceInfo$(): Observable<MarketplaceInfo> {
return this.marketplaceInfo$
getSelectedHost$(): Observable<{ url: string; name: string }> {
return this.selectedHost$
}
getPackages$(): Observable<MarketplacePkg[]> {
return this.marketplacePkgs$
getMarketplace$(): Observable<Marketplace> {
return this.marketplace$
}
getPackage(
getSelectedStore$(): Observable<StoreData | null> {
return this.selectedStore$
}
getPackage$(
id: string,
version: string,
url?: string,
optionalUrl?: string,
): Observable<MarketplacePkg | undefined> {
return this.uiMarketplace$.pipe(
switchMap(m => {
url = url || m.url
if (this.cache.has(url)) {
const pkg = this.getPkgFromCache(id, version, url)
if (pkg) return of(pkg)
return this.patch.watch$('ui', 'marketplace').pipe(
switchMap(marketplace => {
const url = optionalUrl || marketplace['selected-url']
if (version !== '*' || !marketplace['known-hosts'][url]) {
return this.fetchPackage$(id, version, url)
}
if (version === '*') {
return from(this.loadPackage(id, url))
} else {
return from(this.fetchPackage(id, version, url))
}
return this.selectedStore$.pipe(
filter(Boolean),
map(s => s.packages.find(p => p.manifest.id === id)),
)
}),
)
}
getUpdates$(): Observable<{ url: string; pkgs: MarketplacePkg[] }[]> {
return this.updates$
// UI only
getRequestErrors$(): Observable<string[]> {
return this.requestErrors$
}
async installPackage(
@@ -174,16 +142,71 @@ export class MarketplaceService implements AbstractMarketplaceService {
await this.api.installPackage(params)
}
async validateMarketplace(url: string): Promise<string> {
await this.loadInfo(url)
return this.cache.get(url)!.info!.name
fetchInfo$(url: string): Observable<StoreInfo> {
return this.patch
.watch$('server-info', 'id')
.pipe(
switchMap(id =>
this.api.marketplaceProxy<RR.GetMarketplaceInfoRes>(
'/package/v0/info',
{ 'server-d': id },
url,
),
),
)
}
fetchReleaseNotes(
private fetchStore$(url: string): Observable<StoreData> {
return combineLatest([this.fetchInfo$(url), this.fetchPackages$(url)]).pipe(
map(([info, packages]) => ({ info, packages })),
catchError(e => {
this.requestErrors$.next(this.requestErrors$.value.concat(url))
return of(e)
}),
)
}
private fetchPackages$(
url: string,
params: Omit<
RR.GetMarketplacePackagesReq,
'eos-version-compat' | 'page' | 'per-page'
> = {},
): Observable<MarketplacePkg[]> {
return this.patch.watch$('server-info', 'eos-version-compat').pipe(
switchMap(versionCompat => {
const qp: RR.GetMarketplacePackagesReq = {
...params,
'eos-version-compat': versionCompat,
page: 1,
'per-page': 100,
}
if (qp.ids) qp.ids = JSON.stringify(qp.ids)
return this.api.marketplaceProxy<RR.GetMarketplacePackagesRes>(
'/package/v0/index',
qp,
url,
)
}),
)
}
fetchPackage$(
id: string,
version: string,
url: string,
): Observable<MarketplacePkg | undefined> {
return this.fetchPackages$(url, { ids: [{ id, version }] }).pipe(
map(pkgs => pkgs[0]),
)
}
fetchReleaseNotes$(
id: string,
url?: string,
): Observable<Record<string, string>> {
return this.uiMarketplace$.pipe(
return this.selectedHost$.pipe(
switchMap(m => {
return from(
this.api.marketplaceProxy<Record<string, string>>(
@@ -196,12 +219,8 @@ export class MarketplaceService implements AbstractMarketplaceService {
)
}
fetchPackageMarkdown(
id: string,
type: string,
url?: string,
): Observable<string> {
return this.uiMarketplace$.pipe(
fetchStatic$(id: string, type: string, url?: string): Observable<string> {
return this.selectedHost$.pipe(
switchMap(m => {
return from(
this.api.marketplaceProxy<string>(
@@ -214,88 +233,6 @@ export class MarketplaceService implements AbstractMarketplaceService {
)
}
private async loadMarketplace(url: string): Promise<MarketplaceData> {
const cachedInfo = this.cache.get(url)?.info
const [info, packages] = await Promise.all([
cachedInfo || this.loadInfo(url),
this.loadPackages({}, url),
])
return { info, packages }
}
private async loadInfo(url: string): Promise<MarketplaceInfo> {
const info = await this.fetchInfo(url)
this.updateCache(url, info)
return info
}
private async loadPackage(
id: string,
url: string,
): Promise<MarketplacePkg | undefined> {
const pkgs = await this.loadPackages({ ids: [{ id, version: '*' }] }, url)
return pkgs[0]
}
private async loadPackages(
params: Omit<
RR.GetMarketplacePackagesReq,
'eos-version-compat' | 'page' | 'per-page'
>,
url: string,
): Promise<MarketplacePkg[]> {
const pkgs = await this.fetchPackages(params, url)
this.updateCache(url, undefined, pkgs)
return pkgs
}
private async fetchInfo(url: string): Promise<RR.GetMarketplaceDataRes> {
const { id } = await getServerInfo(this.patch)
const params: RR.GetMarketplaceDataReq = {
'server-id': id,
}
return this.api.marketplaceProxy<RR.GetMarketplaceDataRes>(
'/package/v0/info',
params,
url,
)
}
private async fetchPackage(
id: string,
version: string,
url: string,
): Promise<MarketplacePkg | undefined> {
const pkgs = await this.fetchPackages({ ids: [{ id, version }] }, url)
return pkgs[0]
}
private async fetchPackages(
params: Omit<
RR.GetMarketplacePackagesReq,
'eos-version-compat' | 'page' | 'per-page'
>,
url: string,
): Promise<RR.GetMarketplacePackagesRes> {
const qp: RR.GetMarketplacePackagesReq = {
...params,
'eos-version-compat': (await getServerInfo(this.patch))[
'eos-version-compat'
],
page: 1,
'per-page': 100,
}
if (qp.ids) qp.ids = JSON.stringify(qp.ids)
return this.api.marketplaceProxy<RR.GetMarketplacePackagesRes>(
'/package/v0/index',
qp,
url,
)
}
private async updateName(
url: string,
name: string,
@@ -305,39 +242,4 @@ export class MarketplaceService implements AbstractMarketplaceService {
this.api.setDbValue(['marketplace', 'known-hosts', url], newName)
}
}
private getPkgFromCache(
id: string,
version: string,
url: string,
): MarketplacePkg | undefined {
return this.cache.get(url)?.packages.find(p => {
const versionIsSame =
version === '*' || this.emver.compare(p.manifest.version, version) === 0
return p.manifest.id === id && versionIsSame
})
}
private updateCache(
url: string,
info?: MarketplaceInfo,
pkgs?: MarketplacePkg[],
): void {
const cache = this.cache.get(url)
let packages = cache?.packages || []
if (pkgs) {
const filtered = packages.filter(
cachedPkg =>
!pkgs.find(pkg => pkg.manifest.id === cachedPkg.manifest.id),
)
packages = filtered.concat(pkgs)
}
this.cache.set(url, {
info: info || cache?.info || null,
packages,
})
}
}

View File

@@ -23,7 +23,7 @@ export class PatchDataService extends Observable<DataModel> {
take(1),
tap(({ ui }) => {
// check for updates to EOS and services
this.checkForUpdates(ui)
this.checkForUpdates()
// show eos welcome message
this.showEosWelcome(ui['ack-welcome'])
}),
@@ -43,12 +43,9 @@ export class PatchDataService extends Observable<DataModel> {
super(subscriber => this.stream$.subscribe(subscriber))
}
private checkForUpdates(ui: UIData): void {
if (ui['auto-check-updates'] !== false) {
private checkForUpdates(): void {
this.eosService.getEOS()
this.marketplaceService.getMarketplaceInfo$().pipe(take(1)).subscribe()
this.marketplaceService.getUpdates$().pipe(take(1)).subscribe()
}
this.marketplaceService.getMarketplace$().pipe(take(1)).subscribe()
}
private async showEosWelcome(ackVersion: string): Promise<void> {

View File

@@ -11,7 +11,6 @@ export interface DataModel {
export interface UIData {
name: string | null
'auto-check-updates': boolean
'pkg-order': string[]
'ack-welcome': string // EOS emver
marketplace: UIMarketplaceData

View File

@@ -1,116 +0,0 @@
import { Injectable } from '@angular/core'
import { AlertInput, AlertButton } from '@ionic/core'
import { ApiService } from './api/embassy-api.service'
import { ConfigSpec } from 'src/app/pkg-config/config-types'
import { AlertController, LoadingController } from '@ionic/angular'
import { ErrorToastService } from '@start9labs/shared'
@Injectable({
providedIn: 'root',
})
export class ServerConfigService {
constructor(
private readonly loadingCtrl: LoadingController,
private readonly errToast: ErrorToastService,
private readonly alertCtrl: AlertController,
private readonly embassyApi: ApiService,
) {}
async presentAlert(
key: string,
current?: any,
): Promise<HTMLIonAlertElement | null> {
const spec = serverConfig[key]
let inputs: AlertInput[]
let buttons: AlertButton[] = [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Save',
handler: async (data: any) => {
const loader = await this.loadingCtrl.create({
message: 'Saving...',
})
loader.present()
try {
await this.saveFns[key](data)
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
},
cssClass: 'enter-click',
},
]
switch (spec.type) {
case 'boolean':
inputs = [
{
name: 'enabled',
type: 'radio',
label: 'Enabled',
value: true,
checked: current,
},
{
name: 'disabled',
type: 'radio',
label: 'Disabled',
value: false,
checked: !current,
},
]
break
default:
return null
}
const alert = await this.alertCtrl.create({
header: spec.name,
message: spec.description,
inputs,
buttons,
})
await alert.present()
return alert
}
// async presentModalForm (key: string) {
// const modal = await this.modalCtrl.create({
// component: AppActionInputPage,
// componentProps: {
// title: serverConfig[key].name,
// spec: (serverConfig[key] as ValueSpecObject).spec,
// },
// })
// modal.onWillDismiss().then(res => {
// if (!res.data) return
// this.saveFns[key](res.data)
// })
// await modal.present()
// }
saveFns: { [key: string]: (val: any) => Promise<any> } = {
'auto-check-updates': async (enabled: boolean) => {
return this.embassyApi.setDbValue(['auto-check-updates'], enabled)
},
}
}
export const serverConfig: ConfigSpec = {
'auto-check-updates': {
type: 'boolean',
name: 'Auto Check for Updates',
description:
'If enabled, embassyOS will automatically check for updates of itself and installed services. Updating will still require your approval and action. Updates will never be performed automatically.',
default: true,
},
}