feat: refactor updates (#2860)

This commit is contained in:
Alex Inkin
2025-03-29 22:50:23 +04:00
committed by GitHub
parent 4b4cf76641
commit 1883c9666e
12 changed files with 703 additions and 372 deletions

20
web/package-lock.json generated
View File

@@ -32,6 +32,7 @@
"@taiga-ui/cdk": "4.30.0", "@taiga-ui/cdk": "4.30.0",
"@taiga-ui/core": "4.30.0", "@taiga-ui/core": "4.30.0",
"@taiga-ui/event-plugins": "4.5.0", "@taiga-ui/event-plugins": "4.5.0",
"@taiga-ui/experimental": "4.30.0",
"@taiga-ui/icons": "4.30.0", "@taiga-ui/icons": "4.30.0",
"@taiga-ui/kit": "4.30.0", "@taiga-ui/kit": "4.30.0",
"@taiga-ui/layout": "4.30.0", "@taiga-ui/layout": "4.30.0",
@@ -4567,6 +4568,25 @@
"rxjs": ">=7.0.0" "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": { "node_modules/@taiga-ui/i18n": {
"version": "4.30.0", "version": "4.30.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-4.30.0.tgz", "resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-4.30.0.tgz",

View File

@@ -54,6 +54,7 @@
"@taiga-ui/cdk": "4.30.0", "@taiga-ui/cdk": "4.30.0",
"@taiga-ui/core": "4.30.0", "@taiga-ui/core": "4.30.0",
"@taiga-ui/event-plugins": "4.5.0", "@taiga-ui/event-plugins": "4.5.0",
"@taiga-ui/experimental": "4.30.0",
"@taiga-ui/icons": "4.30.0", "@taiga-ui/icons": "4.30.0",
"@taiga-ui/kit": "4.30.0", "@taiga-ui/kit": "4.30.0",
"@taiga-ui/layout": "4.30.0", "@taiga-ui/layout": "4.30.0",

View File

@@ -57,12 +57,12 @@ const ROUTES: Routes = [
loadComponent: () => import('./routes/sideload/sideload.component'), loadComponent: () => import('./routes/sideload/sideload.component'),
data: toNavigationItem('/portal/sideload'), data: toNavigationItem('/portal/sideload'),
}, },
// { {
// title: systemTabResolver, title: systemTabResolver,
// path: 'updates', path: 'updates',
// loadComponent: () => import('./routes/updates/updates.component'), loadComponent: () => import('./routes/updates/updates.component'),
// data: toNavigationItem('/portal/updates'), data: toNavigationItem('/portal/updates'),
// }, },
{ {
title: systemTabResolver, title: systemTabResolver,
path: 'metrics', path: 'metrics',

View File

@@ -22,7 +22,7 @@ export class InstallingProgressDisplayPipe implements PipeTransform {
name: 'installingProgress', name: 'installingProgress',
}) })
export class InstallingProgressPipe implements PipeTransform { export class InstallingProgressPipe implements PipeTransform {
transform(progress: T.Progress): number { transform(progress: T.Progress = false): number {
if (progress === true) return 100 if (progress === true) return 100
if (progress === false || progress === null || !progress.total) return 0 if (progress === false || progress === null || !progress.total) return 0
return Math.floor((100 * progress.done) / progress.total) return Math.floor((100 * progress.done) / progress.total)

View File

@@ -1,40 +1,36 @@
// import { inject, Pipe, PipeTransform } from '@angular/core' import { inject, Pipe, PipeTransform } from '@angular/core'
// import { Exver } from '@start9labs/shared' import { Exver } from '@start9labs/shared'
// import { MarketplacePkg } from '@start9labs/marketplace' import { MarketplacePkg } from '@start9labs/marketplace'
// import { import {
// InstalledState, InstalledState,
// PackageDataEntry, PackageDataEntry,
// UpdatingState, UpdatingState,
// } from 'src/app/services/patch-db/data-model' } from 'src/app/services/patch-db/data-model'
// @Pipe({ @Pipe({
// name: 'filterUpdates', name: 'filterUpdates',
// standalone: true, standalone: true,
// }) })
// export class FilterUpdatesPipe implements PipeTransform { export class FilterUpdatesPipe implements PipeTransform {
// private readonly exver = inject(Exver) private readonly exver = inject(Exver)
// transform( transform(
// pkgs?: MarketplacePkg[], pkgs: MarketplacePkg[],
// local: Record< local: Record<
// string, string,
// PackageDataEntry<InstalledState | UpdatingState> PackageDataEntry<InstalledState | UpdatingState>
// > = {}, > = {},
// ): MarketplacePkg[] | null { ): MarketplacePkg[] {
// return ( return pkgs.filter(({ id, version, flavor }) => {
// pkgs?.filter( const localPkg = local[id]
// ({ id, version, flavor }) => return (
// local[id] && localPkg &&
// this.exver.getFlavor(getVersion(local, id)) === flavor && this.exver.getFlavor(localPkg.stateInfo.manifest.version) === flavor &&
// this.exver.compareExver(version, getVersion(local, id)) === 1, this.exver.compareExver(
// ) || null version,
// ) localPkg.stateInfo.manifest.version,
// } ) === 1
// } )
})
// function getVersion( }
// local: Record<string, PackageDataEntry<InstalledState | UpdatingState>>, }
// id: string,
// ): string {
// return local[id].stateInfo.manifest.version
// }

View File

@@ -1,199 +1,311 @@
// import { Component, inject, Input } from '@angular/core' import { DatePipe } from '@angular/common'
// import { RouterLink } from '@angular/router' import {
// import { ChangeDetectionStrategy,
// MarketplacePkg, Component,
// } from '@start9labs/marketplace' inject,
// import { input,
// MarkdownPipeModule, signal,
// SafeLinksDirective, } from '@angular/core'
// SharedPipesModule, import { RouterLink } from '@angular/router'
// } from '@start9labs/shared' import { MarketplacePkg } from '@start9labs/marketplace'
// import { import { MarkdownPipeModule, SafeLinksDirective } from '@start9labs/shared'
// TuiDialogService, import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
// TuiLoader, import {
// TuiIcon, TuiButton,
// TuiLink, TuiIcon,
// TuiButton, TuiLink,
// } from '@taiga-ui/core' TuiLoader,
// import { TuiTitle,
// TuiProgress, } from '@taiga-ui/core'
// TuiAccordion, import { TuiExpand } from '@taiga-ui/experimental'
// TuiAvatar, import {
// TUI_CONFIRM, TUI_CONFIRM,
// } from '@taiga-ui/kit' TuiAvatar,
// import { NgDompurifyModule } from '@tinkoff/ng-dompurify' TuiChevron,
// import { PatchDB } from 'patch-db-client' TuiFade,
// import { InstallingProgressPipe } from 'src/app/routes/portal/routes/services/pipes/install-progress.pipe' TuiProgressCircle,
// import { MarketplaceService } from 'src/app/services/marketplace.service' } from '@taiga-ui/kit'
// import { import { NgDompurifyModule } from '@tinkoff/ng-dompurify'
// DataModel, import { PatchDB } from 'patch-db-client'
// InstalledState, import { defaultIfEmpty, firstValueFrom } from 'rxjs'
// PackageDataEntry, import { InstallingProgressPipe } from 'src/app/routes/portal/routes/services/pipes/install-progress.pipe'
// UpdatingState, import { MarketplaceService } from 'src/app/services/marketplace.service'
// } from 'src/app/services/patch-db/data-model' import {
// import { getAllPackages } from 'src/app/utils/get-package-data' DataModel,
// import { hasCurrentDeps } from 'src/app/utils/has-deps' 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({ @Component({
// selector: 'updates-item', standalone: true,
// template: ` selector: 'updates-item',
// <tui-accordion-item borders="top-bottom"> template: `
// <div class="g-action"> <tr (click)="expanded.set(!expanded())">
// <tui-avatar size="s"> <td>
// <img alt="" [src]="marketplacePkg.icon" /> <div [style.gap.rem]="0.75">
// </tui-avatar> <tui-avatar size="s"><img alt="" [src]="item().icon" /></tui-avatar>
// <div [style.flex]="1" [style.overflow]="'hidden'"> <span tuiTitle [style.margin]="'-0.125rem 0 0'">
// <strong>{{ marketplacePkg.title }}</strong> <b tuiFade>{{ item().title }}</b>
// <div> <span tuiSubtitle tuiFade class="mobile">
// {{ localPkg.stateInfo.manifest.version }} <span class="g-secondary">
// <tui-icon icon="@tui.arrow-right" [style.font-size.rem]="1" /> {{ local().stateInfo.manifest.version }}
// <span [style.color]="'var(--tui-text-positive)'"> </span>
// {{ marketplacePkg.version }} <tui-icon icon="@tui.arrow-right" />
// </span> <span class="g-positive">{{ item().version }}</span>
// </div> </span>
// <div [style.color]="'var(--tui-text-negative)'">{{ errors }}</div> </span>
// </div> </div>
// @if (localPkg.stateInfo.state === 'updating') { </td>
// <tui-progress-circle <td class="desktop">
// class="g-positive" <div>
// size="s" <span class="g-secondary">
// [max]="1" {{ local().stateInfo.manifest.version }}
// [value]=" </span>
// (localPkg.stateInfo.installingInfo.progress.overall <tui-icon icon="@tui.arrow-right" />
// | installingProgress) || 0 <span class="g-positive">{{ item().version }}</span>
// " </div>
// /> </td>
// } @else { <td class="desktop">{{ item().gitHash }}</td>
// @if (ready) { <td class="desktop">{{ item().s9pk.publishedAt | date }}</td>
// <button <td>
// tuiButton <div>
// size="s" @if (local().stateInfo.state === 'updating') {
// [appearance]="errors ? 'destructive' : 'primary'" <tui-progress-circle
// (click.stop)="onClick()" class="g-positive"
// > size="xs"
// {{ errors ? 'Retry' : 'Update' }} [max]="100"
// </button> [value]="
// } @else { (local().stateInfo.installingInfo?.progress?.overall
// <tui-loader [style.width.rem]="2" [inheritColor]="true" /> | installingProgress) || 0
// } "
// } />
// </div> } @else {
// <ng-template tuiAccordionItemContent> @if (ready()) {
// <strong>What's new</strong> <button
// <p tuiButton
// safeLinks iconStart="@tui.arrow-big-up-dash"
// [innerHTML]="marketplacePkg.releaseNotes | markdown | dompurify" [appearance]="error() ? 'destructive' : 'primary'"
// ></p> (click.stop)="onClick()"
// <a >
// tuiLink {{ error() ? 'Retry' : 'Update' }}
// iconEnd="@tui.external-link" </button>
// routerLink="/marketplace" } @else {
// [queryParams]="{ url: url, id: marketplacePkg.id }" <tui-loader [style.width.rem]="2" [inheritColor]="true" />
// > }
// View listing }
// </a> <button tuiIconButton appearance="icon" [tuiChevron]="expanded()">
// </ng-template> Show more
// </tui-accordion-item> </button>
// `, </div>
// styles: [ </td>
// ` </tr>
// :host { <tr>
// display: block; <td colspan="5">
// --tui-background-neutral-1-hover: transparent; @if (error()) {
<p class="g-negative">{{ error() }}</p>
}
<tui-expand [expanded]="expanded()">
<p tuiTitle class="mobile">
<b>Package Hash</b>
<span tuiSubtitle>{{ item().gitHash }}</span>
</p>
<p tuiTitle class="mobile">
<b>Published</b>
<span tuiSubtitle>{{ item().s9pk.publishedAt | date }}</span>
</p>
<p tuiTitle>
<span>
<a
tuiLink
iconEnd="@tui.external-link"
routerLink="/portal/marketplace"
[queryParams]="{ url: parent.current()?.url, id: item().id }"
>
View listing
</a>
<b>What's new</b>
</span>
</p>
<p
safeLinks
[innerHTML]="item().releaseNotes | markdown | dompurify"
></p>
</tui-expand>
</td>
</tr>
`,
styles: `
@import '@taiga-ui/core/styles/taiga-ui-local';
// &:not(:last-child) { :host {
// border-bottom: 1px solid var(--tui-background-neutral-1); display: contents;
// } }
// }
// `,
// ],
// 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<DataModel>>(PatchDB)
// private readonly marketplaceService = inject(MarketplaceService)
// @Input({ required: true }) tui-icon {
// marketplacePkg!: MarketplacePkg font-size: 1rem;
}
// @Input({ required: true }) div {
// localPkg!: PackageDataEntry<InstalledState | UpdatingState> display: flex;
align-items: center;
gap: 0.25rem;
white-space: nowrap;
}
// @Input({ required: true }) tr:first-child {
// url!: string 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 { @media ($tui-mouse) {
// return this.marketplacePkg.id &:hover {
// } background: var(--tui-background-neutral-1);
}
}
}
// get errors(): string { td {
// return this.marketplaceService.updateErrors[this.pkgId] min-width: 0;
// } vertical-align: middle;
// get ready(): boolean { &:first-child {
// return !this.marketplaceService.updateQueue[this.pkgId] white-space: nowrap;
// } }
// async onClick() { &:last-child {
// const { id } = this.marketplacePkg text-align: right;
white-space: nowrap;
// delete this.marketplaceService.updateErrors[id] div {
// this.marketplaceService.updateQueue[id] = true justify-content: flex-end;
}
}
// if (hasCurrentDeps(id, await getAllPackages(this.patch))) { &[colspan]:only-child {
// const proceed = await this.alert() padding: 0 3rem;
text-align: left;
// if (proceed) { [tuiLink] {
// await this.update() float: right;
// } else { }
// delete this.marketplaceService.updateQueue[id] }
// } }
// } else {
// await this.update()
// }
// }
// private async update() { [tuiTitle] {
// const { id, version } = this.marketplacePkg margin: 1rem 0;
}
// try { .mobile {
// await this.marketplaceService.installPackage(id, version, this.url) display: none;
// delete this.marketplaceService.updateQueue[id] }
// } catch (e: any) {
// delete this.marketplaceService.updateQueue[id]
// this.marketplaceService.updateErrors[id] = e.message
// }
// }
// private async alert(): Promise<boolean> { :host-context(tui-root._mobile) {
// return new Promise(async resolve => { tr:first-child {
// this.dialogs display: grid;
// .open<boolean>(TUI_CONFIRM, { grid-template-columns: 1fr min-content;
// label: 'Warning', align-items: center;
// size: 's', padding: 0.5rem 0;
// data: { }
// content: `Services that depend on ${this.localPkg.stateInfo.manifest.title} will no longer work properly and may crash`,
// yes: 'Continue', td[colspan]:only-child {
// no: 'Cancel', padding: 0 0.5rem;
// }, }
// })
// .subscribe(response => resolve(response)) [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<DataModel>>(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<MarketplacePkg>()
readonly local =
input.required<PackageDataEntry<InstalledState | UpdatingState>>()
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<boolean> {
return firstValueFrom(
this.dialogs
.open<boolean>(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)),
)
}
}

View File

@@ -1,95 +1,251 @@
// import { CommonModule } from '@angular/common' import {
// import { ChangeDetectionStrategy, Component, inject } from '@angular/core' ChangeDetectionStrategy,
// import { Component,
// StoreIconComponentModule, inject,
// } from '@start9labs/marketplace' signal,
// import { TuiAvatar } from '@taiga-ui/kit' } from '@angular/core'
// import { TuiCell } from '@taiga-ui/layout' import { toSignal } from '@angular/core/rxjs-interop'
// import { PatchDB } from 'patch-db-client' import {
// import { combineLatest, map } from 'rxjs' Marketplace,
// import { FilterUpdatesPipe } from 'src/app/routes/portal/routes/updates/filter-updates.pipe' StoreIconComponentModule,
// import { UpdatesItemComponent } from 'src/app/routes/portal/routes/updates/item.component' StoreIdentity,
// import { ConfigService } from 'src/app/services/config.service' } from '@start9labs/marketplace'
// import { MarketplaceService } from 'src/app/services/marketplace.service' import { TuiTable } from '@taiga-ui/addon-table'
// import { import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
// DataModel, import { TuiButton, TuiNotification, TuiTitle } from '@taiga-ui/core'
// InstalledState, import {
// PackageDataEntry, TuiAvatar,
// UpdatingState, TuiBadgeNotification,
// } from 'src/app/services/patch-db/data-model' TuiFade,
// import { isInstalled, isUpdating } from 'src/app/utils/get-package-data' 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({ interface UpdatesData {
// template: ` hosts: StoreIdentity[]
// @if (data$ | async; as data) { marketplace: Marketplace
// @for (host of data.hosts; track host) { localPkgs: Record<string, PackageDataEntry<InstalledState | UpdatingState>>
// <h3 class="g-title"> errors: string[]
// <store-icon [url]="host.url" [marketplace]="mp" size="26px" /> }
// {{ host.name }}
// </h3>
// @if (data.errors.includes(host.url)) {
// <p class="g-negative">Request Failed</p>
// }
// @if (data.mp[host.url]?.packages | filterUpdates: data.local; as pkgs) {
// @for (pkg of pkgs; track pkg) {
// <updates-item
// [marketplacePkg]="pkg"
// [localPkg]="data.local[pkg.id]"
// [url]="host.url"
// />
// } @empty {
// <p>All services are up to date!</p>
// }
// } @else {
// @for (i of [0, 1, 2]; track i) {
// <section tuiCell>
// <tui-avatar class="tui-skeleton" />
// <span class="tui-skeleton">Loading update item</span>
// <span class="tui-skeleton" [style.margin-left]="'auto'">
// Loading actions
// </span>
// </section>
// }
// }
// }
// }
// `,
// 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)
// readonly mp = inject(ConfigService).marketplace @Component({
// readonly data$ = combineLatest({ template: `
// hosts: this.marketplaceService.getKnownHosts$(true), <ng-container *title>
// mp: this.marketplaceService.getMarketplace$(), <label>Updates</label>
// local: inject<PatchDB<DataModel>>(PatchDB) @if (current()) {
// .watch$('packageData') <button
// .pipe( tuiIconButton
// map(pkgs => iconStart="@tui.arrow-left"
// Object.entries(pkgs).reduce( (click)="current.set(null)"
// (acc, [id, val]) => { >
// if (isInstalled(val) || isUpdating(val)) Back
// return { ...acc, [id]: val } </button>
// return acc {{ current()?.name }}
// }, }
// {} as Record< </ng-container>
// string, <aside class="g-aside">
// PackageDataEntry<InstalledState | UpdatingState> @for (registry of data()?.hosts; track $index) {
// >, <button
// ), tuiCell
// ), [class.g-secondary]="current()?.url !== registry.url"
// ), (click)="current.set(registry)"
// errors: this.marketplaceService.getRequestErrors$(), >
// }) <tui-avatar>
// } <store-icon [url]="registry.url" [marketplace]="mp" />
</tui-avatar>
<span tuiTitle>
<b tuiFade>{{ registry.name }}</b>
<span tuiSubtitle tuiFade>{{ clear(registry.url) }}</span>
</span>
@if (
(
data()?.marketplace?.[registry.url]?.packages || []
| filterUpdates: data()?.localPkgs
).length;
as length
) {
<tui-badge-notification>
{{ length }}
</tui-badge-notification>
}
</button>
}
</aside>
<section class="g-subpage">
@if (data()?.errors?.includes(current()?.url || '')) {
<tui-notification appearance="negative">
Request Failed
</tui-notification>
}
<section class="g-card" [style.padding]="'0 1rem 1rem'">
<table tuiTable class="g-table">
<thead>
<tr>
<th tuiTh>Name</th>
<th tuiTh>Version</th>
<th tuiTh>Package Hash</th>
<th tuiTh>Published</th>
<th tuiTh></th>
</tr>
</thead>
<tbody>
@if (
data()?.marketplace?.[current()?.url || '']?.packages;
as packages
) {
@if (packages | filterUpdates: data()?.localPkgs; as updates) {
@for (pkg of updates; track $index) {
<updates-item
[item]="pkg"
[local]="data()?.localPkgs?.[pkg.id]!"
/>
} @empty {
<tr>
<td colspan="5">All services are up to date!</td>
</tr>
}
}
} @else {
<tr>
<td colspan="5" [tuiSkeleton]="true">Loading</td>
</tr>
<tr>
<td colspan="5" [tuiSkeleton]="true">Loading</td>
</tr>
}
</tbody>
</table>
</section>
</section>
`,
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<StoreIdentity | null>(null)
readonly data = toSignal<UpdatesData>(
combineLatest({
hosts: this.marketplaceService
.getKnownHosts$(true)
.pipe(tap(([store]) => !this.isMobile && this.current.set(store))),
marketplace: this.marketplaceService.marketplace$,
localPkgs: inject<PatchDB<DataModel>>(PatchDB)
.watch$('packageData')
.pipe(
map(pkgs =>
Object.entries(pkgs).reduce<
Record<string, PackageDataEntry<InstalledState | UpdatingState>>
>(
(acc, [id, val]) =>
isInstalled(val) || isUpdating(val)
? { ...acc, [id]: val }
: acc,
{},
),
),
),
errors: this.marketplaceService.getRequestErrors$(),
}),
)
clear(url: string): string {
return url.replace(/https?:\/\//, '').replace(/\/$/, '')
}
}

View File

@@ -1054,11 +1054,11 @@ export class MockApiService extends ApiService {
...Mock.LocalPkgs[params.id], ...Mock.LocalPkgs[params.id],
stateInfo: { stateInfo: {
// if installing // if installing
state: 'installing', // state: 'installing',
// if updating // if updating
// state: 'updating', state: 'updating',
// manifest: mockPatchData.packageData[params.id].stateInfo.manifest!, manifest: mockPatchData.packageData[params.id].stateInfo.manifest!,
// both // both
installingInfo: { installingInfo: {

View File

@@ -9,14 +9,15 @@ import {
map, map,
Observable, Observable,
pairwise, pairwise,
shareReplay,
startWith, startWith,
switchMap, switchMap,
} from 'rxjs' } from 'rxjs'
import { ConnectionService } from 'src/app/services/connection.service'
import { EOSService } from 'src/app/services/eos.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 { NotificationService } from 'src/app/services/notification.service'
import { DataModel } from 'src/app/services/patch-db/data-model' 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' import { getManifest } from 'src/app/utils/get-package-data'
@Injectable({ @Injectable({
@@ -54,35 +55,35 @@ export class BadgeService {
), ),
) )
// private readonly updates$ = combineLatest([ private readonly updates$ = combineLatest([
// this.marketplaceService.getMarketplace$(true), this.marketplaceService.marketplace$,
// this.local$, this.local$,
// ]).pipe( ]).pipe(
// map( map(
// ([marketplace, local]) => ([marketplace, local]) =>
// Object.entries(marketplace).reduce( Object.entries(marketplace).reduce(
// (list, [_, store]) => (list, [_, store]) =>
// store?.packages.reduce( store?.packages.reduce(
// (result, { id, version }) => (result, { id, version }) =>
// local[id] && local[id] &&
// this.exver.compareExver( this.exver.compareExver(
// version, version,
// getManifest(local[id]).version, getManifest(local[id]).version,
// ) === 1 ) === 1
// ? result.add(id) ? result.add(id)
// : result, : result,
// list, list,
// ) || list, ) || list,
// new Set<string>(), new Set<string>(),
// ).size, ).size,
// ), ),
// shareReplay(1), shareReplay(1),
// ) )
getCount(id: string): Observable<number> { getCount(id: string): Observable<number> {
switch (id) { switch (id) {
// case '/portal/updates': case '/portal/updates':
// return this.updates$ return this.updates$
case '/portal/system': case '/portal/system':
return this.system$ return this.system$
case '/portal/notifications': case '/portal/notifications':

View File

@@ -1,32 +1,39 @@
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { import {
StoreIdentity,
MarketplacePkg,
GetPackageRes, GetPackageRes,
Marketplace,
MarketplacePkg,
StoreData,
StoreDataWithUrl, StoreDataWithUrl,
StoreIdentity,
} from '@start9labs/marketplace' } from '@start9labs/marketplace'
import { Exver, sameUrl } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { PatchDB } from 'patch-db-client' import { PatchDB } from 'patch-db-client'
import { import {
BehaviorSubject, BehaviorSubject,
catchError, catchError,
combineLatest, combineLatest,
distinctUntilChanged,
filter, filter,
from, from,
map, map,
mergeMap,
Observable, Observable,
of, of,
shareReplay, pairwise,
switchMap,
distinctUntilChanged,
ReplaySubject, ReplaySubject,
scan,
shareReplay,
startWith,
switchMap,
tap,
} from 'rxjs' } from 'rxjs'
import { RR } from 'src/app/services/api/api.types' import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service' import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel, UIStore } from 'src/app/services/patch-db/data-model' 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 { ClientStorageService } from './client-storage.service'
import { T } from '@start9labs/start-sdk' import { ConfigService } from './config.service'
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@@ -66,7 +73,10 @@ export class MarketplaceService {
const { start9, community } = this.config.marketplace const { start9, community } = this.config.marketplace
let arr = [ let arr = [
toStoreIdentity(start9, hosts[start9]), toStoreIdentity(start9, hosts[start9]),
toStoreIdentity(community, hosts[community]), toStoreIdentity(community, {
...hosts[community],
name: 'Community Registry',
}),
] ]
return arr.concat( return arr.concat(
@@ -93,6 +103,32 @@ export class MarketplaceService {
private readonly requestErrors$ = new BehaviorSubject<string[]>([]) private readonly requestErrors$ = new BehaviorSubject<string[]>([])
readonly marketplace$: Observable<Marketplace> = this.knownHosts$.pipe(
startWith<StoreIdentity[]>([]),
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<StoreData | null, [string, StoreData | null]>(data => [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({ bufferSize: 1, refCount: true }),
)
constructor( constructor(
private readonly api: ApiService, private readonly api: ApiService,
private readonly patch: PatchDB<DataModel>, private readonly patch: PatchDB<DataModel>,
@@ -230,10 +266,6 @@ export class MarketplaceService {
} }
} }
// UI only
readonly updateErrors: Record<string, string> = {}
readonly updateQueue: Record<string, boolean> = {}
getRequestErrors$(): Observable<string[]> { getRequestErrors$(): Observable<string[]> {
return this.requestErrors$ return this.requestErrors$
} }
@@ -251,6 +283,19 @@ export class MarketplaceService {
await this.api.installPackage(params) await this.api.installPackage(params)
} }
private async updateStoreName(
url: string,
oldName: string | undefined,
newName: string,
): Promise<void> {
if (oldName !== newName) {
this.api.setDbValue<string>(
['marketplace', 'knownHosts', url, 'name'],
newName,
)
}
}
} }
function toStoreIdentity(url: string, uiStore: UIStore): StoreIdentity { function toStoreIdentity(url: string, uiStore: UIStore): StoreIdentity {

View File

@@ -16,11 +16,10 @@ export const SYSTEM_UTILITIES: Record<string, { icon: string; title: string }> =
icon: '@tui.upload', icon: '@tui.upload',
title: 'Sideload', title: 'Sideload',
}, },
// @TODO 040 '/portal/updates': {
// '/portal/updates': { icon: '@tui.globe',
// icon: '@tui.globe', title: 'Updates',
// title: 'Updates', },
// },
// @TODO 041 // @TODO 041
// '/portal/backups': { // '/portal/backups': {
// icon: '@tui.save', // icon: '@tui.save',

View File

@@ -69,6 +69,7 @@ hr {
min-height: fit-content; min-height: fit-content;
flex: 1; flex: 1;
padding: 1rem; padding: 1rem;
overflow: hidden;
} }
.g-aside { .g-aside {
@@ -76,7 +77,7 @@ hr {
top: 1px; top: 1px;
left: 1px; left: 1px;
margin: 1px; margin: 1px;
width: 16rem; width: 18rem;
padding: 0.5rem; padding: 0.5rem;
text-transform: capitalize; text-transform: capitalize;
box-shadow: 1px 0 var(--tui-border-normal); box-shadow: 1px 0 var(--tui-border-normal);