diff --git a/web/package-lock.json b/web/package-lock.json index 39a3138f0..690e44048 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -32,6 +32,7 @@ "@taiga-ui/cdk": "4.30.0", "@taiga-ui/core": "4.30.0", "@taiga-ui/event-plugins": "4.5.0", + "@taiga-ui/experimental": "4.30.0", "@taiga-ui/icons": "4.30.0", "@taiga-ui/kit": "4.30.0", "@taiga-ui/layout": "4.30.0", @@ -4567,6 +4568,25 @@ "rxjs": ">=7.0.0" } }, + "node_modules/@taiga-ui/experimental": { + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/@taiga-ui/experimental/-/experimental-4.30.0.tgz", + "integrity": "sha512-0GWkBinW+tqQIFkWQbTqMBTkKGZhju3RslKRCYbjal/hfcIuSAsEPZqLqIQqVqJNz6AhaIpT0UQ+I7QXzx1/yw==", + "license": "Apache-2.0", + "dependencies": { + "tslib": ">=2.8.1" + }, + "peerDependencies": { + "@angular/common": ">=16.0.0", + "@angular/core": ">=16.0.0", + "@taiga-ui/addon-commerce": "^4.30.0", + "@taiga-ui/cdk": "^4.30.0", + "@taiga-ui/core": "^4.30.0", + "@taiga-ui/kit": "^4.30.0", + "@taiga-ui/polymorpheus": "^4.9.0", + "rxjs": ">=7.0.0" + } + }, "node_modules/@taiga-ui/i18n": { "version": "4.30.0", "resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-4.30.0.tgz", diff --git a/web/package.json b/web/package.json index f300f6fb2..f370b37bf 100644 --- a/web/package.json +++ b/web/package.json @@ -54,6 +54,7 @@ "@taiga-ui/cdk": "4.30.0", "@taiga-ui/core": "4.30.0", "@taiga-ui/event-plugins": "4.5.0", + "@taiga-ui/experimental": "4.30.0", "@taiga-ui/icons": "4.30.0", "@taiga-ui/kit": "4.30.0", "@taiga-ui/layout": "4.30.0", diff --git a/web/projects/ui/src/app/routes/portal/portal.routes.ts b/web/projects/ui/src/app/routes/portal/portal.routes.ts index 3334463d9..cbb0be9ea 100644 --- a/web/projects/ui/src/app/routes/portal/portal.routes.ts +++ b/web/projects/ui/src/app/routes/portal/portal.routes.ts @@ -57,12 +57,12 @@ const ROUTES: Routes = [ loadComponent: () => import('./routes/sideload/sideload.component'), data: toNavigationItem('/portal/sideload'), }, - // { - // title: systemTabResolver, - // path: 'updates', - // loadComponent: () => import('./routes/updates/updates.component'), - // data: toNavigationItem('/portal/updates'), - // }, + { + title: systemTabResolver, + path: 'updates', + loadComponent: () => import('./routes/updates/updates.component'), + data: toNavigationItem('/portal/updates'), + }, { title: systemTabResolver, path: 'metrics', diff --git a/web/projects/ui/src/app/routes/portal/routes/services/pipes/install-progress.pipe.ts b/web/projects/ui/src/app/routes/portal/routes/services/pipes/install-progress.pipe.ts index 7e387ba4f..3ae2d7576 100644 --- a/web/projects/ui/src/app/routes/portal/routes/services/pipes/install-progress.pipe.ts +++ b/web/projects/ui/src/app/routes/portal/routes/services/pipes/install-progress.pipe.ts @@ -22,7 +22,7 @@ export class InstallingProgressDisplayPipe implements PipeTransform { name: 'installingProgress', }) export class InstallingProgressPipe implements PipeTransform { - transform(progress: T.Progress): number { + transform(progress: T.Progress = false): number { if (progress === true) return 100 if (progress === false || progress === null || !progress.total) return 0 return Math.floor((100 * progress.done) / progress.total) diff --git a/web/projects/ui/src/app/routes/portal/routes/updates/filter-updates.pipe.ts b/web/projects/ui/src/app/routes/portal/routes/updates/filter-updates.pipe.ts index 649a6821c..285830969 100644 --- a/web/projects/ui/src/app/routes/portal/routes/updates/filter-updates.pipe.ts +++ b/web/projects/ui/src/app/routes/portal/routes/updates/filter-updates.pipe.ts @@ -1,40 +1,36 @@ -// import { inject, Pipe, PipeTransform } from '@angular/core' -// import { Exver } from '@start9labs/shared' -// import { MarketplacePkg } from '@start9labs/marketplace' -// import { -// InstalledState, -// PackageDataEntry, -// UpdatingState, -// } from 'src/app/services/patch-db/data-model' +import { inject, Pipe, PipeTransform } from '@angular/core' +import { Exver } from '@start9labs/shared' +import { MarketplacePkg } from '@start9labs/marketplace' +import { + InstalledState, + PackageDataEntry, + UpdatingState, +} from 'src/app/services/patch-db/data-model' -// @Pipe({ -// name: 'filterUpdates', -// standalone: true, -// }) -// export class FilterUpdatesPipe implements PipeTransform { -// private readonly exver = inject(Exver) +@Pipe({ + name: 'filterUpdates', + standalone: true, +}) +export class FilterUpdatesPipe implements PipeTransform { + private readonly exver = inject(Exver) -// transform( -// pkgs?: MarketplacePkg[], -// local: Record< -// string, -// PackageDataEntry -// > = {}, -// ): MarketplacePkg[] | null { -// return ( -// pkgs?.filter( -// ({ id, version, flavor }) => -// local[id] && -// this.exver.getFlavor(getVersion(local, id)) === flavor && -// this.exver.compareExver(version, getVersion(local, id)) === 1, -// ) || null -// ) -// } -// } - -// function getVersion( -// local: Record>, -// id: string, -// ): string { -// return local[id].stateInfo.manifest.version -// } + transform( + pkgs: MarketplacePkg[], + local: Record< + string, + PackageDataEntry + > = {}, + ): MarketplacePkg[] { + return pkgs.filter(({ id, version, flavor }) => { + const localPkg = local[id] + return ( + localPkg && + this.exver.getFlavor(localPkg.stateInfo.manifest.version) === flavor && + this.exver.compareExver( + version, + localPkg.stateInfo.manifest.version, + ) === 1 + ) + }) + } +} diff --git a/web/projects/ui/src/app/routes/portal/routes/updates/item.component.ts b/web/projects/ui/src/app/routes/portal/routes/updates/item.component.ts index c14510370..53b0e4e46 100644 --- a/web/projects/ui/src/app/routes/portal/routes/updates/item.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/updates/item.component.ts @@ -1,199 +1,311 @@ -// import { Component, inject, Input } from '@angular/core' -// import { RouterLink } from '@angular/router' -// import { -// MarketplacePkg, -// } from '@start9labs/marketplace' -// import { -// MarkdownPipeModule, -// SafeLinksDirective, -// SharedPipesModule, -// } from '@start9labs/shared' -// import { -// TuiDialogService, -// TuiLoader, -// TuiIcon, -// TuiLink, -// TuiButton, -// } from '@taiga-ui/core' -// import { -// TuiProgress, -// TuiAccordion, -// TuiAvatar, -// TUI_CONFIRM, -// } from '@taiga-ui/kit' -// import { NgDompurifyModule } from '@tinkoff/ng-dompurify' -// import { PatchDB } from 'patch-db-client' -// import { InstallingProgressPipe } from 'src/app/routes/portal/routes/services/pipes/install-progress.pipe' -// import { MarketplaceService } from 'src/app/services/marketplace.service' -// import { -// DataModel, -// InstalledState, -// PackageDataEntry, -// UpdatingState, -// } from 'src/app/services/patch-db/data-model' -// import { getAllPackages } from 'src/app/utils/get-package-data' -// import { hasCurrentDeps } from 'src/app/utils/has-deps' +import { DatePipe } from '@angular/common' +import { + ChangeDetectionStrategy, + Component, + inject, + input, + signal, +} from '@angular/core' +import { RouterLink } from '@angular/router' +import { MarketplacePkg } from '@start9labs/marketplace' +import { MarkdownPipeModule, SafeLinksDirective } from '@start9labs/shared' +import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile' +import { + TuiButton, + TuiIcon, + TuiLink, + TuiLoader, + TuiTitle, +} from '@taiga-ui/core' +import { TuiExpand } from '@taiga-ui/experimental' +import { + TUI_CONFIRM, + TuiAvatar, + TuiChevron, + TuiFade, + TuiProgressCircle, +} from '@taiga-ui/kit' +import { NgDompurifyModule } from '@tinkoff/ng-dompurify' +import { PatchDB } from 'patch-db-client' +import { defaultIfEmpty, firstValueFrom } from 'rxjs' +import { InstallingProgressPipe } from 'src/app/routes/portal/routes/services/pipes/install-progress.pipe' +import { MarketplaceService } from 'src/app/services/marketplace.service' +import { + DataModel, + InstalledState, + PackageDataEntry, + UpdatingState, +} from 'src/app/services/patch-db/data-model' +import { getAllPackages } from 'src/app/utils/get-package-data' +import { hasCurrentDeps } from 'src/app/utils/has-deps' +import UpdatesComponent from './updates.component' -// @Component({ -// selector: 'updates-item', -// template: ` -// -//
-// -// -// -//
-// {{ marketplacePkg.title }} -//
-// {{ localPkg.stateInfo.manifest.version }} -// -// -// {{ marketplacePkg.version }} -// -//
-//
{{ errors }}
-//
-// @if (localPkg.stateInfo.state === 'updating') { -// -// } @else { -// @if (ready) { -// -// } @else { -// -// } -// } -//
-// -// What's new -//

-// -// View listing -// -//
-//
-// `, -// styles: [ -// ` -// :host { -// display: block; -// --tui-background-neutral-1-hover: transparent; +@Component({ + standalone: true, + selector: 'updates-item', + template: ` + + +
+ + + {{ item().title }} + + + {{ local().stateInfo.manifest.version }} + + + {{ item().version }} + + +
+ + +
+ + {{ local().stateInfo.manifest.version }} + + + {{ item().version }} +
+ + {{ item().gitHash }} + {{ item().s9pk.publishedAt | date }} + +
+ @if (local().stateInfo.state === 'updating') { + + } @else { + @if (ready()) { + + } @else { + + } + } + +
+ + + + + @if (error()) { +

{{ error() }}

+ } + +

+ Package Hash + {{ item().gitHash }} +

+

+ Published + {{ item().s9pk.publishedAt | date }} +

+

+ + + View listing + + What's new + +

+

+
+ + + `, + styles: ` + @import '@taiga-ui/core/styles/taiga-ui-local'; -// &:not(:last-child) { -// border-bottom: 1px solid var(--tui-background-neutral-1); -// } -// } -// `, -// ], -// standalone: true, -// imports: [ -// RouterLink, -// MarkdownPipeModule, -// NgDompurifyModule, -// SafeLinksDirective, -// SharedPipesModule, -// TuiProgress, -// TuiAccordion, -// TuiAvatar, -// TuiIcon, -// TuiButton, -// TuiLink, -// TuiLoader, -// InstallingProgressPipe, -// ], -// }) -// export class UpdatesItemComponent { -// private readonly dialogs = inject(TuiDialogService) -// private readonly patch = inject>(PatchDB) -// private readonly marketplaceService = inject(MarketplaceService) + :host { + display: contents; + } -// @Input({ required: true }) -// marketplacePkg!: MarketplacePkg + tui-icon { + font-size: 1rem; + } -// @Input({ required: true }) -// localPkg!: PackageDataEntry + div { + display: flex; + align-items: center; + gap: 0.25rem; + white-space: nowrap; + } -// @Input({ required: true }) -// url!: string + tr:first-child { + min-height: var(--tui-height-l); + word-break: break-word; + clip-path: inset(0 round var(--tui-radius-s)); + cursor: pointer; + @include transition(background); -// get pkgId(): string { -// return this.marketplacePkg.id -// } + @media ($tui-mouse) { + &:hover { + background: var(--tui-background-neutral-1); + } + } + } -// get errors(): string { -// return this.marketplaceService.updateErrors[this.pkgId] -// } + td { + min-width: 0; + vertical-align: middle; -// get ready(): boolean { -// return !this.marketplaceService.updateQueue[this.pkgId] -// } + &:first-child { + white-space: nowrap; + } -// async onClick() { -// const { id } = this.marketplacePkg + &:last-child { + text-align: right; + white-space: nowrap; -// delete this.marketplaceService.updateErrors[id] -// this.marketplaceService.updateQueue[id] = true + div { + justify-content: flex-end; + } + } -// if (hasCurrentDeps(id, await getAllPackages(this.patch))) { -// const proceed = await this.alert() + &[colspan]:only-child { + padding: 0 3rem; + text-align: left; -// if (proceed) { -// await this.update() -// } else { -// delete this.marketplaceService.updateQueue[id] -// } -// } else { -// await this.update() -// } -// } + [tuiLink] { + float: right; + } + } + } -// private async update() { -// const { id, version } = this.marketplacePkg + [tuiTitle] { + margin: 1rem 0; + } -// try { -// await this.marketplaceService.installPackage(id, version, this.url) -// delete this.marketplaceService.updateQueue[id] -// } catch (e: any) { -// delete this.marketplaceService.updateQueue[id] -// this.marketplaceService.updateErrors[id] = e.message -// } -// } + .mobile { + display: none; + } -// private async alert(): Promise { -// return new Promise(async resolve => { -// this.dialogs -// .open(TUI_CONFIRM, { -// label: 'Warning', -// size: 's', -// data: { -// content: `Services that depend on ${this.localPkg.stateInfo.manifest.title} will no longer work properly and may crash`, -// yes: 'Continue', -// no: 'Cancel', -// }, -// }) -// .subscribe(response => resolve(response)) -// }) -// } -// } + :host-context(tui-root._mobile) { + tr:first-child { + display: grid; + grid-template-columns: 1fr min-content; + align-items: center; + padding: 0.5rem 0; + } + + td[colspan]:only-child { + padding: 0 0.5rem; + } + + [tuiButton] { + font-size: 0; + gap: 0; + } + + .desktop { + display: none; + } + + .mobile { + display: flex; + } + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + RouterLink, + TuiExpand, + TuiButton, + TuiChevron, + TuiAvatar, + TuiLink, + TuiIcon, + TuiLoader, + TuiProgressCircle, + TuiTitle, + MarkdownPipeModule, + NgDompurifyModule, + SafeLinksDirective, + DatePipe, + InstallingProgressPipe, + TuiFade, + ], +}) +export class UpdatesItemComponent { + private readonly dialogs = inject(TuiResponsiveDialogService) + private readonly patch = inject>(PatchDB) + private readonly service = inject(MarketplaceService) + + readonly parent = inject(UpdatesComponent) + readonly expanded = signal(false) + readonly error = signal('') + readonly ready = signal(true) + + readonly item = input.required() + readonly local = + input.required>() + + async onClick() { + this.ready.set(false) + this.error.set('') + + if (hasCurrentDeps(this.item().id, await getAllPackages(this.patch))) { + if (await this.alert()) { + await this.update() + } else { + this.ready.set(true) + } + } else { + await this.update() + } + } + + private async update() { + const { id, version } = this.item() + const url = this.parent.current()?.url || '' + + try { + await this.service.installPackage(id, version, url) + this.ready.set(true) + } catch (e: any) { + this.ready.set(true) + this.error.set(e.message) + } + } + + private async alert(): Promise { + return firstValueFrom( + this.dialogs + .open(TUI_CONFIRM, { + label: 'Warning', + size: 's', + data: { + content: `Services that depend on ${this.local().stateInfo.manifest.title} will no longer work properly and may crash`, + yes: 'Continue', + no: 'Cancel', + }, + }) + .pipe(defaultIfEmpty(false)), + ) + } +} diff --git a/web/projects/ui/src/app/routes/portal/routes/updates/updates.component.ts b/web/projects/ui/src/app/routes/portal/routes/updates/updates.component.ts index 174b534eb..78f13341c 100644 --- a/web/projects/ui/src/app/routes/portal/routes/updates/updates.component.ts +++ b/web/projects/ui/src/app/routes/portal/routes/updates/updates.component.ts @@ -1,95 +1,251 @@ -// import { CommonModule } from '@angular/common' -// import { ChangeDetectionStrategy, Component, inject } from '@angular/core' -// import { -// StoreIconComponentModule, -// } from '@start9labs/marketplace' -// import { TuiAvatar } from '@taiga-ui/kit' -// import { TuiCell } from '@taiga-ui/layout' -// import { PatchDB } from 'patch-db-client' -// import { combineLatest, map } from 'rxjs' -// import { FilterUpdatesPipe } from 'src/app/routes/portal/routes/updates/filter-updates.pipe' -// import { UpdatesItemComponent } from 'src/app/routes/portal/routes/updates/item.component' -// import { ConfigService } from 'src/app/services/config.service' -// import { MarketplaceService } from 'src/app/services/marketplace.service' -// import { -// DataModel, -// InstalledState, -// PackageDataEntry, -// UpdatingState, -// } from 'src/app/services/patch-db/data-model' -// import { isInstalled, isUpdating } from 'src/app/utils/get-package-data' +import { + ChangeDetectionStrategy, + Component, + inject, + signal, +} from '@angular/core' +import { toSignal } from '@angular/core/rxjs-interop' +import { + Marketplace, + StoreIconComponentModule, + StoreIdentity, +} from '@start9labs/marketplace' +import { TuiTable } from '@taiga-ui/addon-table' +import { TUI_IS_MOBILE } from '@taiga-ui/cdk' +import { TuiButton, TuiNotification, TuiTitle } from '@taiga-ui/core' +import { + TuiAvatar, + TuiBadgeNotification, + TuiFade, + TuiSkeleton, +} from '@taiga-ui/kit' +import { TuiCell } from '@taiga-ui/layout' +import { PatchDB } from 'patch-db-client' +import { combineLatest, map, tap } from 'rxjs' +import { ConfigService } from 'src/app/services/config.service' +import { MarketplaceService } from 'src/app/services/marketplace.service' +import { + DataModel, + InstalledState, + PackageDataEntry, + UpdatingState, +} from 'src/app/services/patch-db/data-model' +import { TitleDirective } from 'src/app/services/title.service' +import { isInstalled, isUpdating } from 'src/app/utils/get-package-data' +import { FilterUpdatesPipe } from './filter-updates.pipe' +import { UpdatesItemComponent } from './item.component' -// @Component({ -// template: ` -// @if (data$ | async; as data) { -// @for (host of data.hosts; track host) { -//

-// -// {{ host.name }} -//

-// @if (data.errors.includes(host.url)) { -//

Request Failed

-// } -// @if (data.mp[host.url]?.packages | filterUpdates: data.local; as pkgs) { -// @for (pkg of pkgs; track pkg) { -// -// } @empty { -//

All services are up to date!

-// } -// } @else { -// @for (i of [0, 1, 2]; track i) { -//
-// -// Loading update item -// -// Loading actions -// -//
-// } -// } -// } -// } -// `, -// host: { class: 'g-page' }, -// changeDetection: ChangeDetectionStrategy.OnPush, -// standalone: true, -// imports: [ -// CommonModule, -// TuiCell, -// TuiAvatar, -// StoreIconComponentModule, -// FilterUpdatesPipe, -// UpdatesItemComponent, -// ], -// }) -// export default class UpdatesComponent { -// private readonly marketplaceService = inject(MarketplaceService) +interface UpdatesData { + hosts: StoreIdentity[] + marketplace: Marketplace + localPkgs: Record> + errors: string[] +} -// readonly mp = inject(ConfigService).marketplace -// readonly data$ = combineLatest({ -// hosts: this.marketplaceService.getKnownHosts$(true), -// mp: this.marketplaceService.getMarketplace$(), -// local: inject>(PatchDB) -// .watch$('packageData') -// .pipe( -// map(pkgs => -// Object.entries(pkgs).reduce( -// (acc, [id, val]) => { -// if (isInstalled(val) || isUpdating(val)) -// return { ...acc, [id]: val } -// return acc -// }, -// {} as Record< -// string, -// PackageDataEntry -// >, -// ), -// ), -// ), -// errors: this.marketplaceService.getRequestErrors$(), -// }) -// } +@Component({ + template: ` + + + @if (current()) { + + {{ current()?.name }} + } + + +
+ @if (data()?.errors?.includes(current()?.url || '')) { + + Request Failed + + } +
+ + + + + + + + + + + + @if ( + data()?.marketplace?.[current()?.url || '']?.packages; + as packages + ) { + @if (packages | filterUpdates: data()?.localPkgs; as updates) { + @for (pkg of updates; track $index) { + + } @empty { + + + + } + } + } @else { + + + + + + + } + +
NameVersionPackage HashPublished
All services are up to date!
Loading
Loading
+
+
+ `, + styles: ` + :host { + display: flex; + padding: 0; + } + + aside { + display: flex; + flex-direction: column; + gap: 0.5rem; + padding-top: 1rem; + color: var(--tui-text-secondary); + } + + label:not(:last-child) { + display: none; + } + + [tuiCell] { + white-space: nowrap; + + &:not(.g-secondary) { + background: var(--tui-background-neutral-1); + box-shadow: inset 0 0 0 1px var(--tui-background-neutral-1-hover); + color: var(--tui-text-primary); + } + } + + td { + clip-path: inset(0.5rem round var(--tui-radius-s)); + } + + :host-context(tui-root._mobile) { + aside { + width: 100%; + } + + section { + background: none; + box-shadow: none; + } + + [tuiCell] { + color: var(--tui-text-primary) !important; + } + + :host._selected { + aside { + display: none; + } + } + + :host:not(._selected) { + section { + display: none; + } + } + } + `, + host: { + class: 'g-page', + '[class._selected]': 'current()', + }, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + TuiCell, + TuiAvatar, + TuiTitle, + TuiNotification, + TuiSkeleton, + TuiTable, + TuiBadgeNotification, + TuiFade, + TuiButton, + StoreIconComponentModule, + FilterUpdatesPipe, + UpdatesItemComponent, + TitleDirective, + ], +}) +export default class UpdatesComponent { + private readonly isMobile = inject(TUI_IS_MOBILE) + private readonly marketplaceService = inject(MarketplaceService) + + readonly mp = inject(ConfigService).marketplace + readonly current = signal(null) + + readonly data = toSignal( + combineLatest({ + hosts: this.marketplaceService + .getKnownHosts$(true) + .pipe(tap(([store]) => !this.isMobile && this.current.set(store))), + marketplace: this.marketplaceService.marketplace$, + localPkgs: inject>(PatchDB) + .watch$('packageData') + .pipe( + map(pkgs => + Object.entries(pkgs).reduce< + Record> + >( + (acc, [id, val]) => + isInstalled(val) || isUpdating(val) + ? { ...acc, [id]: val } + : acc, + {}, + ), + ), + ), + errors: this.marketplaceService.getRequestErrors$(), + }), + ) + + clear(url: string): string { + return url.replace(/https?:\/\//, '').replace(/\/$/, '') + } +} diff --git a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts index d6c9f8834..74d98417f 100644 --- a/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/web/projects/ui/src/app/services/api/embassy-mock-api.service.ts @@ -1054,11 +1054,11 @@ export class MockApiService extends ApiService { ...Mock.LocalPkgs[params.id], stateInfo: { // if installing - state: 'installing', + // state: 'installing', // if updating - // state: 'updating', - // manifest: mockPatchData.packageData[params.id].stateInfo.manifest!, + state: 'updating', + manifest: mockPatchData.packageData[params.id].stateInfo.manifest!, // both installingInfo: { diff --git a/web/projects/ui/src/app/services/badge.service.ts b/web/projects/ui/src/app/services/badge.service.ts index a89eb8cf9..ee947c51f 100644 --- a/web/projects/ui/src/app/services/badge.service.ts +++ b/web/projects/ui/src/app/services/badge.service.ts @@ -9,14 +9,15 @@ import { map, Observable, pairwise, + shareReplay, startWith, switchMap, } from 'rxjs' +import { ConnectionService } from 'src/app/services/connection.service' import { EOSService } from 'src/app/services/eos.service' +import { MarketplaceService } from 'src/app/services/marketplace.service' import { NotificationService } from 'src/app/services/notification.service' import { DataModel } from 'src/app/services/patch-db/data-model' -import { MarketplaceService } from 'src/app/services/marketplace.service' -import { ConnectionService } from 'src/app/services/connection.service' import { getManifest } from 'src/app/utils/get-package-data' @Injectable({ @@ -54,35 +55,35 @@ export class BadgeService { ), ) - // private readonly updates$ = combineLatest([ - // this.marketplaceService.getMarketplace$(true), - // this.local$, - // ]).pipe( - // map( - // ([marketplace, local]) => - // Object.entries(marketplace).reduce( - // (list, [_, store]) => - // store?.packages.reduce( - // (result, { id, version }) => - // local[id] && - // this.exver.compareExver( - // version, - // getManifest(local[id]).version, - // ) === 1 - // ? result.add(id) - // : result, - // list, - // ) || list, - // new Set(), - // ).size, - // ), - // shareReplay(1), - // ) + private readonly updates$ = combineLatest([ + this.marketplaceService.marketplace$, + this.local$, + ]).pipe( + map( + ([marketplace, local]) => + Object.entries(marketplace).reduce( + (list, [_, store]) => + store?.packages.reduce( + (result, { id, version }) => + local[id] && + this.exver.compareExver( + version, + getManifest(local[id]).version, + ) === 1 + ? result.add(id) + : result, + list, + ) || list, + new Set(), + ).size, + ), + shareReplay(1), + ) getCount(id: string): Observable { switch (id) { - // case '/portal/updates': - // return this.updates$ + case '/portal/updates': + return this.updates$ case '/portal/system': return this.system$ case '/portal/notifications': diff --git a/web/projects/ui/src/app/services/marketplace.service.ts b/web/projects/ui/src/app/services/marketplace.service.ts index b760c50a7..7c82ed624 100644 --- a/web/projects/ui/src/app/services/marketplace.service.ts +++ b/web/projects/ui/src/app/services/marketplace.service.ts @@ -1,32 +1,39 @@ import { Injectable } from '@angular/core' import { - StoreIdentity, - MarketplacePkg, GetPackageRes, + Marketplace, + MarketplacePkg, + StoreData, StoreDataWithUrl, + StoreIdentity, } from '@start9labs/marketplace' +import { Exver, sameUrl } from '@start9labs/shared' +import { T } from '@start9labs/start-sdk' import { PatchDB } from 'patch-db-client' import { BehaviorSubject, catchError, combineLatest, + distinctUntilChanged, filter, from, map, + mergeMap, Observable, of, - shareReplay, - switchMap, - distinctUntilChanged, + pairwise, ReplaySubject, + scan, + shareReplay, + startWith, + switchMap, + tap, } from 'rxjs' import { RR } from 'src/app/services/api/api.types' import { ApiService } from 'src/app/services/api/embassy-api.service' import { DataModel, UIStore } from 'src/app/services/patch-db/data-model' -import { ConfigService } from './config.service' -import { Exver } from '@start9labs/shared' import { ClientStorageService } from './client-storage.service' -import { T } from '@start9labs/start-sdk' +import { ConfigService } from './config.service' @Injectable({ providedIn: 'root', @@ -66,7 +73,10 @@ export class MarketplaceService { const { start9, community } = this.config.marketplace let arr = [ toStoreIdentity(start9, hosts[start9]), - toStoreIdentity(community, hosts[community]), + toStoreIdentity(community, { + ...hosts[community], + name: 'Community Registry', + }), ] return arr.concat( @@ -93,6 +103,32 @@ export class MarketplaceService { private readonly requestErrors$ = new BehaviorSubject([]) + readonly marketplace$: Observable = this.knownHosts$.pipe( + startWith([]), + pairwise(), + mergeMap(([prev, curr]) => + curr.filter(c => !prev.find(p => sameUrl(c.url, p.url))), + ), + mergeMap(({ url, name }) => + this.fetchRegistry$(url).pipe( + tap(data => { + if (data?.info.name) this.updateStoreName(url, name, data.info.name) + }), + map(data => [url, data]), + startWith<[string, StoreData | null]>([url, null]), + ), + ), + scan<[string, StoreData | null], Record>( + (requests, [url, store]) => { + requests[url] = store + + return requests + }, + {}, + ), + shareReplay({ bufferSize: 1, refCount: true }), + ) + constructor( private readonly api: ApiService, private readonly patch: PatchDB, @@ -230,10 +266,6 @@ export class MarketplaceService { } } - // UI only - readonly updateErrors: Record = {} - readonly updateQueue: Record = {} - getRequestErrors$(): Observable { return this.requestErrors$ } @@ -251,6 +283,19 @@ export class MarketplaceService { await this.api.installPackage(params) } + + private async updateStoreName( + url: string, + oldName: string | undefined, + newName: string, + ): Promise { + if (oldName !== newName) { + this.api.setDbValue( + ['marketplace', 'knownHosts', url, 'name'], + newName, + ) + } + } } function toStoreIdentity(url: string, uiStore: UIStore): StoreIdentity { diff --git a/web/projects/ui/src/app/utils/system-utilities.ts b/web/projects/ui/src/app/utils/system-utilities.ts index 13abd4003..ea62da33b 100644 --- a/web/projects/ui/src/app/utils/system-utilities.ts +++ b/web/projects/ui/src/app/utils/system-utilities.ts @@ -16,11 +16,10 @@ export const SYSTEM_UTILITIES: Record = icon: '@tui.upload', title: 'Sideload', }, - // @TODO 040 - // '/portal/updates': { - // icon: '@tui.globe', - // title: 'Updates', - // }, + '/portal/updates': { + icon: '@tui.globe', + title: 'Updates', + }, // @TODO 041 // '/portal/backups': { // icon: '@tui.save', diff --git a/web/projects/ui/src/styles.scss b/web/projects/ui/src/styles.scss index 85511c705..a6bacdbc5 100644 --- a/web/projects/ui/src/styles.scss +++ b/web/projects/ui/src/styles.scss @@ -69,6 +69,7 @@ hr { min-height: fit-content; flex: 1; padding: 1rem; + overflow: hidden; } .g-aside { @@ -76,7 +77,7 @@ hr { top: 1px; left: 1px; margin: 1px; - width: 16rem; + width: 18rem; padding: 0.5rem; text-transform: capitalize; box-shadow: 1px 0 var(--tui-border-normal);