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

@@ -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
)
}