diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9553271bc..fceedfaec 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -37,6 +37,7 @@ "cbor-web": "^8.1.0", "core-js": "^3.21.1", "dompurify": "^2.3.6", + "fast-deep-equal": "^3.1.3", "fast-json-patch": "^3.1.1", "fuse.js": "^6.4.6", "jose": "^4.9.0", @@ -7512,6 +7513,20 @@ "clone": "^1.0.2" } }, + "node_modules/define-data-property": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.0.tgz", + "integrity": "sha512-UzGwzcjyv3OtAvolTj1GoyNYzfFR+iqbGjcnBEENZVCpM4/Ng1yhGNvS3lR/xDS74Tb2wGG9WzNSNIOS9UVb2g==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.2.1", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/define-lazy-prop": { "version": "2.0.0", "devOptional": true, @@ -7521,10 +7536,12 @@ } }, "node_modules/define-properties": { - "version": "1.1.4", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", "dev": true, - "license": "MIT", "dependencies": { + "define-data-property": "^1.0.1", "has-property-descriptors": "^1.0.0", "object-keys": "^1.1.1" }, @@ -8208,7 +8225,8 @@ }, "node_modules/fast-deep-equal": { "version": "3.1.3", - "license": "MIT" + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "node_modules/fast-glob": { "version": "3.2.12", @@ -8560,12 +8578,14 @@ } }, "node_modules/get-intrinsic": { - "version": "1.1.3", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.1.tgz", + "integrity": "sha512-2DcsyfABl+gVHEfCOaTrWgyt+tb6MSEGmKq+kI5HwLbIYgjgmMcV8KQ41uaKz1xxUcn9tJtgFbQUEVcEbd0FYw==", "dev": true, - "license": "MIT", "dependencies": { "function-bind": "^1.1.1", "has": "^1.0.3", + "has-proto": "^1.0.1", "has-symbols": "^1.0.3" }, "funding": { @@ -8704,6 +8724,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", + "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "dev": true, + "dependencies": { + "get-intrinsic": "^1.1.3" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/graceful-fs": { "version": "4.2.10", "devOptional": true, @@ -8758,6 +8790,18 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", + "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "dev": true, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/has-symbols": { "version": "1.0.3", "dev": true, diff --git a/frontend/package.json b/frontend/package.json index 3b03dd26f..2e2beb413 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -62,6 +62,7 @@ "cbor-web": "^8.1.0", "core-js": "^3.21.1", "dompurify": "^2.3.6", + "fast-deep-equal": "^3.1.3", "fast-json-patch": "^3.1.1", "fuse.js": "^6.4.6", "jose": "^4.9.0", diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list.page.html b/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list.page.html index 05e4a76cf..3bc38f762 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list.page.html +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list.page.html @@ -27,7 +27,7 @@ sizeMd="6" > diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-list/package-info.pipe.ts b/frontend/projects/ui/src/app/pages/apps-routes/app-list/package-info.pipe.ts index 103845620..df48680af 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-list/package-info.pipe.ts +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-list/package-info.pipe.ts @@ -1,10 +1,7 @@ import { Pipe, PipeTransform } from '@angular/core' -import { Observable, combineLatest } from 'rxjs' -import { filter, map, startWith } from 'rxjs/operators' -import { - DataModel, - PackageDataEntry, -} from 'src/app/services/patch-db/data-model' +import { Observable, combineLatest, firstValueFrom } from 'rxjs' +import { map } from 'rxjs/operators' +import { DataModel } from 'src/app/services/patch-db/data-model' import { getPackageInfo, PkgInfo } from '../../../util/get-package-info' import { PatchDB } from 'patch-db-client' import { DepErrorService } from 'src/app/services/dep-error.service' @@ -18,12 +15,10 @@ export class PackageInfoPipe implements PipeTransform { private readonly depErrorService: DepErrorService, ) {} - transform(pkg: PackageDataEntry): Observable { + transform(pkgId: string): Observable { return combineLatest([ - this.patch - .watch$('package-data', pkg.manifest.id) - .pipe(filter(Boolean), startWith(pkg)), - this.depErrorService.depErrors$, + this.patch.watch$('package-data', pkgId), + this.depErrorService.getPkgDepErrors$(pkgId), ]).pipe(map(([pkg, depErrors]) => getPackageInfo(pkg, depErrors))) } } diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.ts b/frontend/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.ts index 4257f916d..19b29c1f6 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.ts +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.ts @@ -21,7 +21,7 @@ import { DependentInfo } from 'src/app/types/dependent-info' import { DepErrorService, DependencyErrorType, - PackageDependencyErrors, + PkgDependencyErrors, } from 'src/app/services/dep-error.service' import { combineLatest } from 'rxjs' @@ -50,15 +50,14 @@ export class AppShowPage { private readonly pkgId = getPkgId(this.route) readonly pkgPlus$ = combineLatest([ - this.patch.watch$('package-data'), - this.depErrorService.depErrors$, + this.patch.watch$('package-data', this.pkgId), + this.depErrorService.getPkgDepErrors$(this.pkgId), ]).pipe( - tap(([pkgs, _]) => { + tap(([pkg, _]) => { // if package disappears, navigate to list page - if (!pkgs[this.pkgId]) this.navCtrl.navigateRoot('/services') + if (!pkg) this.navCtrl.navigateRoot('/services') }), - map(([pkgs, depErrors]) => { - const pkg = pkgs[this.pkgId] + map(([pkg, depErrors]) => { return { pkg, dependencies: this.getDepInfo(pkg, depErrors), @@ -93,7 +92,7 @@ export class AppShowPage { private getDepInfo( pkg: PackageDataEntry, - depErrors: PackageDependencyErrors, + depErrors: PkgDependencyErrors, ): DependencyInfo[] { const pkgInstalled = pkg.installed @@ -107,7 +106,7 @@ export class AppShowPage { private getDepValues( pkgInstalled: InstalledPackageDataEntry, depId: string, - depErrors: PackageDependencyErrors, + depErrors: PkgDependencyErrors, ): DependencyInfo { const { errorText, fixText, fixAction } = this.getDepErrors( pkgInstalled, @@ -134,10 +133,10 @@ export class AppShowPage { private getDepErrors( pkgInstalled: InstalledPackageDataEntry, depId: string, - depErrors: PackageDependencyErrors, + depErrors: PkgDependencyErrors, ) { const pkgManifest = pkgInstalled.manifest - const depError = depErrors[pkgInstalled.manifest.id][depId] + const depError = depErrors[depId] let errorText: string | null = null let fixText: string | null = null diff --git a/frontend/projects/ui/src/app/pages/widgets/built-in/health/health.component.ts b/frontend/projects/ui/src/app/pages/widgets/built-in/health/health.component.ts index f69a0407b..e4f456c01 100644 --- a/frontend/projects/ui/src/app/pages/widgets/built-in/health/health.component.ts +++ b/frontend/projects/ui/src/app/pages/widgets/built-in/health/health.component.ts @@ -7,6 +7,8 @@ import { } from 'src/app/services/patch-db/data-model' import { PrimaryStatus } from 'src/app/services/pkg-status-rendering.service' import { getPackageInfo, PkgInfo } from '../../../../util/get-package-info' +import { combineLatest } from 'rxjs' +import { DepErrorService } from 'src/app/services/dep-error.service' @Component({ selector: 'widget-health', @@ -23,31 +25,32 @@ export class HealthComponent { 'Transitioning', ] as const - readonly data$ = inject(PatchDB) - .watch$('package-data') - .pipe( - map(data => { - const pkgs = Object.values(data).map( - pkg => getPackageInfo(pkg, {}), // @TODO hack because not currently using widget - ) - const result = this.labels.reduce>( - (acc, label) => ({ - ...acc, - [label]: this.getCount(label, pkgs), - }), - {}, - ) + readonly data$ = combineLatest([ + inject(PatchDB).watch$('package-data'), + inject(DepErrorService).depErrors$, + ]).pipe( + map(([data, depErrors]) => { + const pkgs = Object.values(data).map(pkg => + getPackageInfo(pkg, depErrors[pkg.manifest.id]), + ) + const result = this.labels.reduce>( + (acc, label) => ({ + ...acc, + [label]: this.getCount(label, pkgs), + }), + {}, + ) - result['Healthy'] = - pkgs.length - - result['Error'] - - result['Needs Attention'] - - result['Stopped'] - - result['Transitioning'] + result['Healthy'] = + pkgs.length - + result['Error'] - + result['Needs Attention'] - + result['Stopped'] - + result['Transitioning'] - return this.labels.map(label => result[label]) - }), - ) + return this.labels.map(label => result[label]) + }), + ) private getCount(label: string, pkgs: PkgInfo[]): number { switch (label) { diff --git a/frontend/projects/ui/src/app/services/dep-error.service.ts b/frontend/projects/ui/src/app/services/dep-error.service.ts index 733b0a377..1b1880f72 100644 --- a/frontend/projects/ui/src/app/services/dep-error.service.ts +++ b/frontend/projects/ui/src/app/services/dep-error.service.ts @@ -1,6 +1,14 @@ import { Injectable } from '@angular/core' import { Emver } from '@start9labs/shared' -import { map, shareReplay } from 'rxjs/operators' +import { + distinctUntilChanged, + filter, + map, + pairwise, + shareReplay, + startWith, + tap, +} from 'rxjs/operators' import { PatchDB } from 'patch-db-client' import { DataModel, @@ -9,9 +17,10 @@ import { InstalledPackageDataEntry, PackageMainStatus, } from './patch-db/data-model' +import * as deepEqual from 'fast-deep-equal' -export type PackageDependencyErrors = Record -export type DependencyErrors = Record +export type AllDependencyErrors = Record +export type PkgDependencyErrors = Record @Injectable({ providedIn: 'root', @@ -26,13 +35,14 @@ export class DepErrorService { })) .sort((a, b) => (b.depth > a.depth ? -1 : 1)) .reduce( - (errors, { id }): PackageDependencyErrors => ({ + (errors, { id }): AllDependencyErrors => ({ ...errors, [id]: this.getDepErrors(pkgs, id, errors), }), - {} as PackageDependencyErrors, + {} as AllDependencyErrors, ), ), + distinctUntilChanged(deepEqual), shareReplay(1), ) @@ -41,21 +51,28 @@ export class DepErrorService { private readonly patch: PatchDB, ) {} + getPkgDepErrors$(pkgId: string) { + return this.depErrors$.pipe( + map(depErrors => depErrors[pkgId]), + distinctUntilChanged(deepEqual), + ) + } + private getDepErrors( pkgs: DataModel['package-data'], pkgId: string, - outerErrors: PackageDependencyErrors, - ): DependencyErrors { + outerErrors: AllDependencyErrors, + ): PkgDependencyErrors { const pkgInstalled = pkgs[pkgId].installed if (!pkgInstalled) return {} return currentDeps(pkgs, pkgId).reduce( - (innerErrors, depId): DependencyErrors => ({ + (innerErrors, depId): PkgDependencyErrors => ({ ...innerErrors, [depId]: this.getDepError(pkgs, pkgInstalled, depId, outerErrors), }), - {} as DependencyErrors, + {} as PkgDependencyErrors, ) } @@ -63,7 +80,7 @@ export class DepErrorService { pkgs: DataModel['package-data'], pkgInstalled: InstalledPackageDataEntry, depId: string, - outerErrors: PackageDependencyErrors, + outerErrors: AllDependencyErrors, ): DependencyError | null { const depInstalled = pkgs[depId]?.installed diff --git a/frontend/projects/ui/src/app/services/pkg-status-rendering.service.ts b/frontend/projects/ui/src/app/services/pkg-status-rendering.service.ts index ce415bcbd..28c58a809 100644 --- a/frontend/projects/ui/src/app/services/pkg-status-rendering.service.ts +++ b/frontend/projects/ui/src/app/services/pkg-status-rendering.service.ts @@ -1,13 +1,12 @@ import { isEmptyObject } from '@start9labs/shared' import { - InstalledPackageDataEntry, MainStatusStarting, PackageDataEntry, PackageMainStatus, PackageState, Status, } from 'src/app/services/patch-db/data-model' -import { PackageDependencyErrors } from './dep-error.service' +import { PkgDependencyErrors } from './dep-error.service' export interface PackageStatus { primary: PrimaryStatus @@ -17,7 +16,7 @@ export interface PackageStatus { export function renderPkgStatus( pkg: PackageDataEntry, - depErrors: PackageDependencyErrors, + depErrors: PkgDependencyErrors, ): PackageStatus { let primary: PrimaryStatus let dependency: DependencyStatus | null = null @@ -25,7 +24,7 @@ export function renderPkgStatus( if (pkg.state === PackageState.Installed && pkg.installed) { primary = getPrimaryStatus(pkg.installed.status) - dependency = getDependencyStatus(pkg.installed, depErrors) + dependency = getDependencyStatus(depErrors) health = getHealthStatus( pkg.installed.status, !isEmptyObject(pkg.manifest['health-checks']), @@ -47,11 +46,8 @@ function getPrimaryStatus(status: Status): PrimaryStatus { } } -function getDependencyStatus( - pkg: InstalledPackageDataEntry, - depErrors: PackageDependencyErrors, -): DependencyStatus { - return Object.values(depErrors[pkg.manifest.id]).some(err => !!err) +function getDependencyStatus(depErrors: PkgDependencyErrors): DependencyStatus { + return Object.values(depErrors).some(err => !!err) ? DependencyStatus.Warning : DependencyStatus.Satisfied } diff --git a/frontend/projects/ui/src/app/util/get-package-info.ts b/frontend/projects/ui/src/app/util/get-package-info.ts index f98d969fb..14901f201 100644 --- a/frontend/projects/ui/src/app/util/get-package-info.ts +++ b/frontend/projects/ui/src/app/util/get-package-info.ts @@ -10,11 +10,11 @@ import { import { ProgressData } from 'src/app/types/progress-data' import { Subscription } from 'rxjs' import { packageLoadingProgress } from './package-loading-progress' -import { PackageDependencyErrors } from '../services/dep-error.service' +import { PkgDependencyErrors } from '../services/dep-error.service' export function getPackageInfo( entry: PackageDataEntry, - depErrors: PackageDependencyErrors, + depErrors: PkgDependencyErrors, ): PkgInfo { const statuses = renderPkgStatus(entry, depErrors) const primaryRendering = PrimaryRendering[statuses.primary]