mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-31 04:23:40 +00:00
sideload wip, websockets, styling, multiple todos (#2865)
* sideload wip, websockets, styling, multiple todos * sideload * misc backend updates * chore: comments * prep for license and instructions display * comment for Matt * s9pk updates and 040 sdk * fix dependency error for actions * 0.4.0-beta.1 * beta.2 --------- Co-authored-by: Aiden McClelland <me@drbonez.dev> Co-authored-by: waterplea <alexander@inkin.ru> Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com>
This commit is contained in:
@@ -9,6 +9,7 @@ import { debounceTime, endWith, map, merge, Subject } from 'rxjs'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
// @TODO Alex
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'refresh-alert',
|
||||
|
||||
@@ -6,16 +6,7 @@ import {
|
||||
provideSetupLogsService,
|
||||
} from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import {
|
||||
catchError,
|
||||
defer,
|
||||
EMPTY,
|
||||
from,
|
||||
map,
|
||||
startWith,
|
||||
switchMap,
|
||||
tap,
|
||||
} from 'rxjs'
|
||||
import { catchError, defer, from, map, startWith, switchMap, tap } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@@ -36,18 +27,25 @@ export default class InitializingPage {
|
||||
defer(() => from(this.api.initFollowProgress())).pipe(
|
||||
switchMap(({ guid, progress }) =>
|
||||
this.api
|
||||
.openWebsocket$<T.FullProgress>(guid, {})
|
||||
.openWebsocket$<T.FullProgress>(guid, {
|
||||
closeObserver: {
|
||||
next: () => {
|
||||
this.state.syncState()
|
||||
},
|
||||
},
|
||||
})
|
||||
.pipe(startWith(progress)),
|
||||
),
|
||||
map(formatProgress),
|
||||
tap<{ total: number; message: string }>(({ total }) => {
|
||||
tap(({ total }) => {
|
||||
if (total === 1) {
|
||||
this.state.syncState()
|
||||
}
|
||||
}),
|
||||
catchError(e => {
|
||||
catchError((e, caught$) => {
|
||||
console.error(e)
|
||||
return EMPTY
|
||||
this.state.syncState()
|
||||
return caught$
|
||||
}),
|
||||
),
|
||||
{ initialValue: { total: 0, message: '' } },
|
||||
|
||||
@@ -42,7 +42,6 @@ export class LoginPage {
|
||||
}
|
||||
await this.api.login({
|
||||
password: this.password,
|
||||
metadata: { platforms: [] }, // @TODO do we really need platforms now?
|
||||
ephemeral: window.location.host === 'localhost',
|
||||
})
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ export abstract class Control<
|
||||
return this.control.spec
|
||||
}
|
||||
|
||||
// TODO: Properly handle already set immutable value
|
||||
// @TODO Alex: Properly handle already set immutable value
|
||||
get readOnly(): boolean {
|
||||
return (
|
||||
!!this.value && !!this.control.control?.pristine && this.control.immutable
|
||||
|
||||
@@ -91,7 +91,7 @@ import { HeaderStatusComponent } from './status.component'
|
||||
}
|
||||
|
||||
&:has([data-status='success']) {
|
||||
--status: var(--tui-status-positive);
|
||||
--status: transparent;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ import { InterfaceComponent } from './interface.component'
|
||||
text-align: right;
|
||||
grid-area: 1 / 2 / 3 / 3;
|
||||
place-content: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mobile {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { MappedServiceInterface } from './interface.utils'
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
max-width: 56rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
|
||||
import { TuiBadge } from '@taiga-ui/kit'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'interface-status',
|
||||
template: `
|
||||
<tui-badge
|
||||
size="l"
|
||||
[iconStart]="public() ? '@tui.globe' : '@tui.lock'"
|
||||
[style.vertical-align.rem]="-0.125"
|
||||
[style.margin]="'0 0.25rem -0.25rem'"
|
||||
[appearance]="public() ? 'positive' : 'negative'"
|
||||
>
|
||||
{{ public() ? 'Public' : 'Private' }}
|
||||
</tui-badge>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiBadge],
|
||||
})
|
||||
export class InterfaceStatusComponent {
|
||||
readonly public = input(false)
|
||||
}
|
||||
@@ -80,7 +80,7 @@ const FILTER = ['/portal/services', '/portal/system', '/portal/marketplace']
|
||||
:host {
|
||||
display: none;
|
||||
backdrop-filter: blur(1rem);
|
||||
// TODO: Theme
|
||||
// TODO Theme
|
||||
--tui-background-elevation-1: #333;
|
||||
--tui-background-base: #fff;
|
||||
--tui-border-normal: var(--tui-background-neutral-1);
|
||||
|
||||
@@ -24,7 +24,7 @@ import { HeaderComponent } from './components/header/header.component'
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// TODO: Theme
|
||||
// @TODO Theme
|
||||
background: url(/assets/img/background_dark.jpeg) fixed center/cover;
|
||||
}
|
||||
|
||||
|
||||
@@ -102,7 +102,7 @@ export class BackupsUpcomingComponent {
|
||||
readonly targets = toSignal(from(this.api.getBackupTargets({})))
|
||||
readonly current = toSignal(
|
||||
inject<PatchDB<DataModel>>(PatchDB)
|
||||
// @TODO remove "as any" once this feature is real
|
||||
// @TODO 041 remove "as any" once this feature is real
|
||||
.watch$('serverInfo', 'statusInfo', 'currentBackup' as any, 'job')
|
||||
.pipe(map(job => job || {})),
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { MarketplacePkg } from '@start9labs/marketplace'
|
||||
import { MarketplacePkgBase } from '@start9labs/marketplace'
|
||||
import {
|
||||
Exver,
|
||||
ErrorService,
|
||||
@@ -103,7 +103,7 @@ export class MarketplaceControlsComponent {
|
||||
private readonly marketplaceService = inject(MarketplaceService)
|
||||
|
||||
@Input({ required: true })
|
||||
pkg!: MarketplacePkg
|
||||
pkg!: MarketplacePkgBase
|
||||
|
||||
@Input()
|
||||
localPkg!: PackageDataEntry | null
|
||||
|
||||
@@ -22,10 +22,9 @@ import {
|
||||
TuiButton,
|
||||
TuiDialogContext,
|
||||
TuiDialogService,
|
||||
TuiIcon,
|
||||
TuiLoader,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiRadioList, TuiStringifyContentPipe } from '@taiga-ui/kit'
|
||||
import { TuiRadioList } from '@taiga-ui/kit'
|
||||
import {
|
||||
BehaviorSubject,
|
||||
combineLatest,
|
||||
@@ -53,7 +52,7 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
|
||||
@if (!(pkg.dependencyMetadata | empty)) {
|
||||
<marketplace-dependencies [pkg]="pkg" (open)="open($event)" />
|
||||
}
|
||||
<marketplace-additional [pkg]="pkg">
|
||||
<marketplace-additional [pkg]="pkg" (static)="onStatic($event)">
|
||||
@if (versions$ | async; as versions) {
|
||||
<marketplace-additional-item
|
||||
(click)="versions.length ? selectVersion(pkg, version) : 0"
|
||||
@@ -172,10 +171,8 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
|
||||
AboutModule,
|
||||
SharedPipesModule,
|
||||
FormsModule,
|
||||
TuiStringifyContentPipe,
|
||||
TuiRadioList,
|
||||
TuiLoader,
|
||||
TuiIcon,
|
||||
FlavorsComponent,
|
||||
],
|
||||
})
|
||||
@@ -228,6 +225,10 @@ export class MarketplacePreviewComponent {
|
||||
this.router.navigate([], { queryParams: { id } })
|
||||
}
|
||||
|
||||
onStatic(type: 'License' | 'Instructions') {
|
||||
// @TODO Alex need to display License or Instructions. This requires an API request, check out next/minor
|
||||
}
|
||||
|
||||
selectVersion(
|
||||
{ version }: MarketplacePkg,
|
||||
template: TemplateRef<TuiDialogContext>,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { TUI_CONFIRM } from '@taiga-ui/kit'
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { MarketplacePkg } from '@start9labs/marketplace'
|
||||
import { MarketplacePkg, MarketplacePkgBase } from '@start9labs/marketplace'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { defaultIfEmpty, firstValueFrom } from 'rxjs'
|
||||
@@ -60,7 +60,7 @@ export class MarketplaceAlertsService {
|
||||
})
|
||||
}
|
||||
|
||||
async alertInstall({ alerts }: MarketplacePkg): Promise<boolean> {
|
||||
async alertInstall({ alerts }: MarketplacePkgBase): Promise<boolean> {
|
||||
const content = alerts.install
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,19 +1,27 @@
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import {
|
||||
catchError,
|
||||
defer,
|
||||
filter,
|
||||
ignoreElements,
|
||||
Observable,
|
||||
repeat,
|
||||
retry,
|
||||
shareReplay,
|
||||
startWith,
|
||||
switchMap,
|
||||
take,
|
||||
tap,
|
||||
} from 'rxjs'
|
||||
import { ServerMetrics } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ConnectionService } from 'src/app/services/connection.service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class MetricsService extends Observable<ServerMetrics> {
|
||||
private readonly connection = inject(ConnectionService)
|
||||
private readonly api = inject(ApiService)
|
||||
|
||||
private readonly metrics$ = defer(() =>
|
||||
@@ -22,8 +30,10 @@ export class MetricsService extends Observable<ServerMetrics> {
|
||||
switchMap(({ guid, metrics }) =>
|
||||
this.api.openWebsocket$<ServerMetrics>(guid).pipe(startWith(metrics)),
|
||||
),
|
||||
// @TODO Alex how to handle failure and reconnection here? Simple retry() will not work. Seems like we need a general solution for reconnecting websockets: patchDB, logs, metrics, progress, and any future. Reconnection should depend on server state, then we need to get a new guid, then reconnect. Similar to how patchDB websocket currently behaves on disconnect/reconnect.
|
||||
retry(),
|
||||
catchError(() =>
|
||||
this.connection.pipe(filter(Boolean), take(1), ignoreElements()),
|
||||
),
|
||||
repeat(),
|
||||
shareReplay(1),
|
||||
)
|
||||
|
||||
|
||||
@@ -56,7 +56,7 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core'
|
||||
|
||||
:host {
|
||||
height: 100%;
|
||||
min-height: 7.5rem;
|
||||
min-height: 9rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
@@ -16,11 +16,6 @@ import { TimeService } from 'src/app/services/time.service'
|
||||
selector: 'metrics-time',
|
||||
template: `
|
||||
@if (now(); as time) {
|
||||
@if (!time.synced) {
|
||||
<tui-notification appearance="warning">
|
||||
<ng-container *ngTemplateOutlet="hint" />
|
||||
</tui-notification>
|
||||
}
|
||||
<div tuiCell>
|
||||
<div tuiTitle [style.text-align]="'center'">
|
||||
<div tuiSubtitle class="g-secondary">
|
||||
@@ -36,6 +31,11 @@ import { TimeService } from 'src/app/services/time.service'
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
@if (!time.synced) {
|
||||
<tui-notification size="s" appearance="warning">
|
||||
<ng-container *ngTemplateOutlet="hint" />
|
||||
</tui-notification>
|
||||
}
|
||||
} @else {
|
||||
Loading...
|
||||
}
|
||||
@@ -61,10 +61,12 @@ import { TimeService } from 'src/app/services/time.service'
|
||||
styles: `
|
||||
:host {
|
||||
height: 100%;
|
||||
min-height: var(--tui-height-l);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
|
||||
[tuiCell],
|
||||
@@ -72,6 +74,10 @@ import { TimeService } from 'src/app/services/time.service'
|
||||
[tuiSubtitle] {
|
||||
margin: 0;
|
||||
justify-content: center;
|
||||
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,6 +85,10 @@ import { TimeService } from 'src/app/services/time.service'
|
||||
display: none;
|
||||
}
|
||||
|
||||
tui-notification {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
tui-notification {
|
||||
display: none;
|
||||
|
||||
@@ -30,6 +30,7 @@ import { TimeService } from 'src/app/services/time.service'
|
||||
styles: `
|
||||
:host {
|
||||
height: 100%;
|
||||
min-height: var(--tui-height-l);
|
||||
display: flex;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiTitle } from '@taiga-ui/core'
|
||||
|
||||
interface ActionItem {
|
||||
readonly name: string
|
||||
@@ -12,7 +12,6 @@ interface ActionItem {
|
||||
@Component({
|
||||
selector: '[action]',
|
||||
template: `
|
||||
<tui-icon [icon]="action.icon || '@tui.circle-play'" />
|
||||
<div tuiTitle>
|
||||
<strong>{{ action.name }}</strong>
|
||||
<div tuiSubtitle>{{ action.description }}</div>
|
||||
@@ -23,7 +22,7 @@ interface ActionItem {
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [TuiIcon, TuiTitle],
|
||||
imports: [TuiTitle],
|
||||
host: {
|
||||
'[disabled]': '!!disabled',
|
||||
},
|
||||
|
||||
@@ -1,27 +1,27 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { tuiPure } from '@taiga-ui/cdk'
|
||||
import { TuiButton } from '@taiga-ui/core'
|
||||
import { DependencyInfo } from 'src/app/routes/portal/routes/services/types/dependency-info'
|
||||
import { map } from 'rxjs'
|
||||
import { ControlsService } from 'src/app/services/controls.service'
|
||||
import { DepErrorService } from 'src/app/services/dep-error.service'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
import { PrimaryStatus } from 'src/app/services/pkg-status-rendering.service'
|
||||
import { getManifest } from 'src/app/utils/get-package-data'
|
||||
|
||||
@Component({
|
||||
selector: 'service-actions',
|
||||
selector: 'service-controls',
|
||||
template: `
|
||||
@if (['running', 'starting', 'restarting'].includes(status)) {
|
||||
<button
|
||||
tuiButton
|
||||
appearance="secondary-destructive"
|
||||
iconStart="@tui.square"
|
||||
(click)="actions.stop(manifest)"
|
||||
(click)="controls.stop(manifest())"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
@@ -31,7 +31,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
|
||||
<button
|
||||
tuiButton
|
||||
iconStart="@tui.rotate-cw"
|
||||
(click)="actions.restart(manifest)"
|
||||
(click)="controls.restart(manifest())"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
@@ -41,7 +41,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
|
||||
<button
|
||||
tuiButton
|
||||
iconStart="@tui.play"
|
||||
(click)="actions.start(manifest, hasUnmet(dependencies))"
|
||||
(click)="controls.start(manifest(), !!hasUnmet)"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
@@ -78,24 +78,26 @@ import { getManifest } from 'src/app/utils/get-package-data'
|
||||
standalone: true,
|
||||
imports: [TuiButton],
|
||||
})
|
||||
export class ServiceActionsComponent {
|
||||
export class ServiceControlsComponent {
|
||||
private readonly errors = inject(DepErrorService)
|
||||
|
||||
@Input({ required: true })
|
||||
pkg!: PackageDataEntry
|
||||
|
||||
@Input({ required: true })
|
||||
status!: PrimaryStatus
|
||||
|
||||
// TODO
|
||||
dependencies: readonly DependencyInfo[] = []
|
||||
readonly manifest = computed(() => getManifest(this.pkg))
|
||||
|
||||
readonly actions = inject(ControlsService)
|
||||
readonly controls = inject(ControlsService)
|
||||
|
||||
get manifest(): T.Manifest {
|
||||
return getManifest(this.pkg)
|
||||
}
|
||||
|
||||
@tuiPure
|
||||
hasUnmet(dependencies: readonly DependencyInfo[]): boolean {
|
||||
return dependencies.some(dep => !!dep.errorText)
|
||||
}
|
||||
readonly hasUnmet = computed(() =>
|
||||
this.errors.getPkgDepErrors$(this.manifest().id).pipe(
|
||||
map(errors =>
|
||||
Object.keys(this.pkg.currentDependencies)
|
||||
.map(id => errors[id])
|
||||
.some(Boolean),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -5,11 +5,11 @@ import {
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { TuiButton, TuiLink } from '@taiga-ui/core'
|
||||
import { TuiBadge } from '@taiga-ui/kit'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
import { MappedInterface } from '../types/mapped-interface'
|
||||
|
||||
@Component({
|
||||
selector: 'tr[serviceInterface]',
|
||||
@@ -113,7 +113,10 @@ export class ServiceInterfaceComponent {
|
||||
private readonly config = inject(ConfigService)
|
||||
|
||||
@Input({ required: true })
|
||||
info!: MappedInterface
|
||||
info!: T.ServiceInterface & {
|
||||
public: boolean
|
||||
routerLink: string
|
||||
}
|
||||
|
||||
@Input({ required: true })
|
||||
pkg!: PackageDataEntry
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { TuiLoader } from '@taiga-ui/core'
|
||||
import { getProgressText } from 'src/app/routes/portal/routes/services/pipes/install-progress.pipe'
|
||||
import { InstallingInfo } from 'src/app/services/patch-db/data-model'
|
||||
import {
|
||||
PrimaryRendering,
|
||||
PrimaryStatus,
|
||||
} from 'src/app/services/pkg-status-rendering.service'
|
||||
import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
|
||||
|
||||
@Component({
|
||||
selector: 'service-status',
|
||||
@@ -17,7 +18,7 @@ import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
|
||||
<tui-loader size="s" [inheritColor]="true" />
|
||||
Installing
|
||||
<span class="loading-dots"></span>
|
||||
{{ installingInfo.progress.overall | installingProgressString }}
|
||||
{{ getText(installingInfo.progress.overall) }}
|
||||
</h3>
|
||||
} @else {
|
||||
<h3 [class]="class">
|
||||
@@ -84,7 +85,7 @@ import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
|
||||
host: { class: 'g-card' },
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [InstallingProgressDisplayPipe, TuiLoader],
|
||||
imports: [TuiLoader],
|
||||
})
|
||||
export class ServiceStatusComponent {
|
||||
@Input({ required: true })
|
||||
@@ -120,4 +121,8 @@ export class ServiceStatusComponent {
|
||||
get rendering() {
|
||||
return PrimaryRendering[this.status]
|
||||
}
|
||||
|
||||
getText(progress: T.Progress): string {
|
||||
return getProgressText(progress)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,14 +21,7 @@ import { ServicesService } from './services.service'
|
||||
<th tuiTh [requiredSort]="true" [sorter]="name">Name</th>
|
||||
<th tuiTh>Version</th>
|
||||
<th tuiTh [requiredSort]="true" [sorter]="uptime">Uptime</th>
|
||||
<th
|
||||
tuiTh
|
||||
[requiredSort]="true"
|
||||
[sorter]="status"
|
||||
[style.width.rem]="13"
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
<th tuiTh [requiredSort]="true" [sorter]="status">Status</th>
|
||||
<th [style.width.rem]="8" [style.text-indent.rem]="1.5">Controls</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { tuiPure } from '@taiga-ui/cdk'
|
||||
import { TuiIcon, TuiLoader } from '@taiga-ui/core'
|
||||
import { getProgressText } from 'src/app/routes/portal/routes/services/pipes/install-progress.pipe'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
import { renderPkgStatus } from 'src/app/services/pkg-status-rendering.service'
|
||||
import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
@@ -31,6 +26,7 @@ import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
height: 3rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
:host-context(tui-root._mobile) {
|
||||
@@ -46,11 +42,8 @@ import { InstallingProgressDisplayPipe } from '../pipes/install-progress.pipe'
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiIcon, TuiLoader],
|
||||
providers: [InstallingProgressDisplayPipe],
|
||||
})
|
||||
export class StatusComponent {
|
||||
private readonly pipe = inject(InstallingProgressDisplayPipe)
|
||||
|
||||
@Input()
|
||||
pkg!: PackageDataEntry
|
||||
|
||||
@@ -72,7 +65,7 @@ export class StatusComponent {
|
||||
|
||||
get status(): string {
|
||||
if (this.pkg.stateInfo.installingInfo) {
|
||||
return `Installing...${this.pipe.transform(this.pkg.stateInfo.installingInfo.progress.overall)}`
|
||||
return `Installing...${getProgressText(this.pkg.stateInfo.installingInfo.progress.overall)}`
|
||||
}
|
||||
|
||||
switch (this.getStatus(this.pkg).primary) {
|
||||
|
||||
@@ -1,22 +1,6 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
|
||||
// TODO drop these pipes
|
||||
@Pipe({
|
||||
standalone: true,
|
||||
name: 'installingProgressString',
|
||||
})
|
||||
export class InstallingProgressDisplayPipe implements PipeTransform {
|
||||
transform(progress: T.Progress): string {
|
||||
if (progress === true) return 'finalizing'
|
||||
if (progress === false || progress === null || !progress.total)
|
||||
return 'unknown %'
|
||||
const percentage = Math.round((100 * progress.done) / progress.total)
|
||||
|
||||
return percentage < 99 ? String(percentage) + '%' : 'finalizing'
|
||||
}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
standalone: true,
|
||||
name: 'installingProgress',
|
||||
@@ -28,3 +12,12 @@ export class InstallingProgressPipe implements PipeTransform {
|
||||
return Math.floor((100 * progress.done) / progress.total)
|
||||
}
|
||||
}
|
||||
|
||||
export function getProgressText(progress: T.Progress): string {
|
||||
if (progress === true) return 'finalizing'
|
||||
if (!progress || !progress.total) return 'unknown %'
|
||||
|
||||
const percentage = Math.round((100 * progress.done) / progress.total)
|
||||
|
||||
return percentage < 99 ? `${percentage}%` : 'finalizing'
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-width: 32rem;
|
||||
max-width: 36rem;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
`,
|
||||
|
||||
@@ -50,7 +50,7 @@ const OTHER = 'Other Custom Actions'
|
||||
`,
|
||||
styles: `
|
||||
section {
|
||||
max-width: 54rem;
|
||||
max-width: 42rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin-bottom: 2rem;
|
||||
@@ -74,18 +74,20 @@ export default class ServiceActionsRoute {
|
||||
mainStatus: pkg.status.main,
|
||||
icon: pkg.icon,
|
||||
manifest: getManifest(pkg),
|
||||
actions: Object.keys(pkg.actions).reduce<
|
||||
Record<string, ReadonlyArray<T.ActionMetadata & { id: string }>>
|
||||
>(
|
||||
(acc, id) => {
|
||||
const action = { id, ...pkg.actions[id] }
|
||||
const group = pkg.actions[id].group || OTHER
|
||||
const current = acc[group] || []
|
||||
actions: Object.entries(pkg.actions)
|
||||
.filter(([_, val]) => val.visibility !== 'hidden')
|
||||
.reduce<
|
||||
Record<string, ReadonlyArray<T.ActionMetadata & { id: string }>>
|
||||
>(
|
||||
(acc, [id]) => {
|
||||
const action = { id, ...pkg.actions[id] }
|
||||
const group = pkg.actions[id].group || OTHER
|
||||
const current = acc[group] || []
|
||||
|
||||
return { ...acc, [group]: current.concat(action) }
|
||||
},
|
||||
{ [OTHER]: [] },
|
||||
),
|
||||
return { ...acc, [group]: current.concat(action) }
|
||||
},
|
||||
{ [OTHER]: [] },
|
||||
),
|
||||
})),
|
||||
),
|
||||
)
|
||||
@@ -110,7 +112,7 @@ const REBUILD = {
|
||||
icon: '@tui.wrench',
|
||||
name: 'Rebuild Service',
|
||||
description:
|
||||
'Rebuilds the service container. It is harmless and only takes a few seconds to complete, but it should only be necessary if a StartOS bug is preventing dependencies, interfaces, or actions from synchronizing.',
|
||||
'Rebuilds the service container. Only necessary in there is a bug in StartOS',
|
||||
}
|
||||
|
||||
const UNINSTALL = {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { TuiBadge, TuiBreadcrumbs } from '@taiga-ui/kit'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
|
||||
import { getAddresses } from 'src/app/routes/portal/components/interfaces/interface.utils'
|
||||
import { InterfaceStatusComponent } from 'src/app/routes/portal/components/interfaces/status.component'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { TitleDirective } from 'src/app/services/title.service'
|
||||
@@ -24,7 +25,7 @@ import { TitleDirective } from 'src/app/services/title.service'
|
||||
<ng-container *title>
|
||||
<a routerLink="../.." tuiIconButton iconStart="@tui.arrow-left">Back</a>
|
||||
{{ interface()?.name }}
|
||||
<ng-container *ngTemplateOutlet="badge" />
|
||||
<interface-status [public]="!!interface()?.public" />
|
||||
</ng-container>
|
||||
<tui-breadcrumbs size="l" [style.margin-block-end.rem]="1">
|
||||
<a *tuiItem tuiLink appearance="action-grayscale" routerLink="../..">
|
||||
@@ -32,7 +33,7 @@ import { TitleDirective } from 'src/app/services/title.service'
|
||||
</a>
|
||||
<span *tuiItem class="g-primary">
|
||||
{{ interface()?.name }}
|
||||
<ng-container *ngTemplateOutlet="badge" />
|
||||
<interface-status [public]="!!interface()?.public" />
|
||||
</span>
|
||||
</tui-breadcrumbs>
|
||||
@if (interface(); as serviceInterface) {
|
||||
@@ -41,16 +42,6 @@ import { TitleDirective } from 'src/app/services/title.service'
|
||||
[serviceInterface]="serviceInterface"
|
||||
/>
|
||||
}
|
||||
<ng-template #badge>
|
||||
<tui-badge
|
||||
[iconStart]="interface()?.public ? '@tui.globe' : '@tui.lock'"
|
||||
[style.vertical-align.rem]="-0.125"
|
||||
[style.margin]="'0 0.25rem -0.25rem'"
|
||||
[appearance]="interface()?.public ? 'positive' : 'negative'"
|
||||
>
|
||||
{{ interface()?.public ? 'Public' : 'Private' }}
|
||||
</tui-badge>
|
||||
</ng-template>
|
||||
`,
|
||||
styles: `
|
||||
:host-context(tui-root._mobile) tui-breadcrumbs {
|
||||
@@ -70,6 +61,7 @@ import { TitleDirective } from 'src/app/services/title.service'
|
||||
TuiLink,
|
||||
TuiBadge,
|
||||
NgTemplateOutlet,
|
||||
InterfaceStatusComponent,
|
||||
],
|
||||
})
|
||||
export default class ServiceInterfaceRoute {
|
||||
|
||||
@@ -19,7 +19,7 @@ import {
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { getInstalledPrimaryStatus } from 'src/app/services/pkg-status-rendering.service'
|
||||
import { ServiceActionRequestsComponent } from '../components/action-requests.component'
|
||||
import { ServiceActionsComponent } from '../components/actions.component'
|
||||
import { ServiceControlsComponent } from '../components/controls.component'
|
||||
import { ServiceDependenciesComponent } from '../components/dependencies.component'
|
||||
import { ServiceErrorComponent } from '../components/error.component'
|
||||
import { ServiceHealthChecksComponent } from '../components/health-checks.component'
|
||||
@@ -38,7 +38,7 @@ import { ServiceStatusComponent } from '../components/status.component'
|
||||
<p class="g-secondary" [appUptime]="started"></p>
|
||||
}
|
||||
@if (installed() && connected()) {
|
||||
<service-actions [pkg]="pkg()" [status]="status()" />
|
||||
<service-controls [pkg]="pkg()" [status]="status()" />
|
||||
}
|
||||
</service-status>
|
||||
|
||||
@@ -90,7 +90,7 @@ import { ServiceStatusComponent } from '../components/status.component'
|
||||
CommonModule,
|
||||
ServiceProgressComponent,
|
||||
ServiceStatusComponent,
|
||||
ServiceActionsComponent,
|
||||
ServiceControlsComponent,
|
||||
ServiceInterfacesComponent,
|
||||
ServiceHealthChecksComponent,
|
||||
ServiceDependenciesComponent,
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface DependencyInfo {
|
||||
id: string
|
||||
title: string | null
|
||||
icon: string | null
|
||||
version: string
|
||||
errorText: string
|
||||
actionText: string
|
||||
action: () => any
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
|
||||
export type MappedInterface = T.ServiceInterface & {
|
||||
public: boolean
|
||||
// TODO implement addresses
|
||||
addresses: any
|
||||
routerLink: string
|
||||
}
|
||||
|
||||
export type MappedAddress = {
|
||||
name: string
|
||||
url: string
|
||||
isDomain: boolean
|
||||
isOnion: boolean
|
||||
acme: string | null
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Component, inject, Input } from '@angular/core'
|
||||
import { Router, RouterLink } from '@angular/router'
|
||||
import {
|
||||
AboutModule,
|
||||
AdditionalModule,
|
||||
MarketplaceDependenciesComponent,
|
||||
MarketplacePackageHeroComponent,
|
||||
MarketplacePkgBase,
|
||||
} from '@start9labs/marketplace'
|
||||
import {
|
||||
ErrorService,
|
||||
@@ -13,66 +13,39 @@ import {
|
||||
LoadingService,
|
||||
SharedPipesModule,
|
||||
} from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { TuiLet } from '@taiga-ui/cdk'
|
||||
import { TuiButton } from '@taiga-ui/core'
|
||||
import { TuiProgressBar } from '@taiga-ui/kit'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { combineLatest, filter, firstValueFrom, map } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ClientStorageService } from 'src/app/services/client-storage.service'
|
||||
import { MarketplaceControlsComponent } from '../marketplace/components/controls.component'
|
||||
import { filter, first, map } from 'rxjs'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { getManifest } from 'src/app/utils/get-package-data'
|
||||
import { InstallingProgressPipe } from 'src/app/routes/portal/routes/services/pipes/install-progress.pipe'
|
||||
import { SideloadService } from './sideload.service'
|
||||
import { MarketplacePkgSideload } from './sideload.utils'
|
||||
|
||||
@Component({
|
||||
selector: 'sideload-package',
|
||||
template: `
|
||||
<div class="outer-container">
|
||||
<ng-content />
|
||||
@if (progress$ | async; as progress) {
|
||||
@for (phase of progress.phases; track $index) {
|
||||
<p>
|
||||
{{ phase.name }}
|
||||
@if (phase.progress | installingProgress; as progress) {
|
||||
: {{ progress }}%
|
||||
}
|
||||
</p>
|
||||
<progress
|
||||
tuiProgressBar
|
||||
size="xs"
|
||||
[style.color]="
|
||||
phase.progress === true
|
||||
? 'var(--tui-text-positive)'
|
||||
: 'var(--tui-text-action)'
|
||||
"
|
||||
[attr.value]="(phase.progress | installingProgress) / 100 || null"
|
||||
></progress>
|
||||
}
|
||||
} @else {
|
||||
<marketplace-package-hero
|
||||
*tuiLet="button$ | async as button"
|
||||
[pkg]="package"
|
||||
>
|
||||
<div class="inner-container">
|
||||
@if (button !== null && button !== 'Install') {
|
||||
<a tuiButton [routerLink]="'/portal/services/' + package.id">
|
||||
View installed
|
||||
</a>
|
||||
}
|
||||
@if (button) {
|
||||
<button tuiButton (click)="upload()">{{ button }}</button>
|
||||
}
|
||||
</div>
|
||||
</marketplace-package-hero>
|
||||
<!-- @TODO Matt do we want this here? How do we turn s9pk into MarketplacePkg? -->
|
||||
<!-- <marketplace-about [pkg]="package" />-->
|
||||
<!-- @if (!(package.dependencyMetadata | empty)) {-->
|
||||
<!-- <marketplace-dependencies [pkg]="package" (open)="open($event)" />-->
|
||||
<!-- }-->
|
||||
<!-- <marketplace-additional [pkg]="package" />-->
|
||||
}
|
||||
<marketplace-package-hero [pkg]="pkg">
|
||||
<marketplace-controls
|
||||
slot="controls"
|
||||
class="controls-wrapper"
|
||||
[pkg]="pkg"
|
||||
[localPkg]="local$ | async"
|
||||
[localFlavor]="!!(flavor$ | async)"
|
||||
/>
|
||||
</marketplace-package-hero>
|
||||
<div class="package-details">
|
||||
<div class="package-details-main">
|
||||
<marketplace-about [pkg]="pkg" />
|
||||
@if (!(pkg.dependencyMetadata | empty)) {
|
||||
<marketplace-dependencies [pkg]="pkg" />
|
||||
}
|
||||
</div>
|
||||
<div class="package-details-additional">
|
||||
<marketplace-additional [pkg]="pkg" (static)="onStatic($event)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
@@ -83,95 +56,97 @@ import { SideloadService } from './sideload.service'
|
||||
width: 100%;
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
max-width: 80%;
|
||||
margin: auto;
|
||||
padding: 2.5rem 4rem 2rem 4rem;
|
||||
}
|
||||
}
|
||||
|
||||
.inner-container {
|
||||
.package-details {
|
||||
-moz-column-gap: 2rem;
|
||||
column-gap: 2rem;
|
||||
|
||||
&-main {
|
||||
grid-column: span 12 / span 12;
|
||||
}
|
||||
|
||||
&-additional {
|
||||
grid-column: span 12 / span 12;
|
||||
}
|
||||
|
||||
@media (min-width: 1536px) {
|
||||
grid-template-columns: repeat(12, minmax(0, 1fr));
|
||||
&-main {
|
||||
grid-column: span 8 / span 8;
|
||||
}
|
||||
&-additional {
|
||||
grid-column: span 4 / span 4;
|
||||
margin-top: 0px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.controls-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
margin: -0.5rem 0 1.5rem -1px;
|
||||
gap: 0.5rem;
|
||||
height: 4.5rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterLink,
|
||||
SharedPipesModule,
|
||||
AboutModule,
|
||||
AdditionalModule,
|
||||
TuiButton,
|
||||
TuiLet,
|
||||
MarketplacePackageHeroComponent,
|
||||
MarketplaceDependenciesComponent,
|
||||
InstallingProgressPipe,
|
||||
TuiProgressBar,
|
||||
MarketplaceControlsComponent,
|
||||
],
|
||||
})
|
||||
export class SideloadPackageComponent {
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly router = inject(Router)
|
||||
private readonly exver = inject(Exver)
|
||||
private readonly sideloadService = inject(SideloadService)
|
||||
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||
|
||||
readonly progress$ = this.sideloadService.progress$
|
||||
readonly button$ = combineLatest([
|
||||
inject(ClientStorageService).showDevTools$,
|
||||
inject<PatchDB<DataModel>>(PatchDB)
|
||||
.watch$('packageData')
|
||||
.pipe(
|
||||
map(local =>
|
||||
local[this.package.id]
|
||||
? this.exver.compareExver(
|
||||
getManifest(local[this.package.id]).version,
|
||||
this.package.version,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
]).pipe(
|
||||
map(([devtools, version]) => {
|
||||
switch (version) {
|
||||
case null:
|
||||
return 'Install'
|
||||
case 1:
|
||||
return 'Update'
|
||||
case -1:
|
||||
return devtools ? 'Downgrade' : ''
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}),
|
||||
)
|
||||
// @Input({ required: true })
|
||||
// pkg!: MarketplacePkgSideload
|
||||
|
||||
// @Alex why do I need to initialize pkg below? I would prefer to do the above, but it's not working
|
||||
@Input({ required: true })
|
||||
package!: T.Manifest & { icon: string }
|
||||
pkg: MarketplacePkgSideload = {} as MarketplacePkgSideload
|
||||
|
||||
@Input({ required: true })
|
||||
file!: File
|
||||
|
||||
readonly local$ = this.patch.watch$('packageData', this.pkg.id).pipe(
|
||||
filter(Boolean),
|
||||
map(pkg =>
|
||||
this.exver.getFlavor(getManifest(pkg).version) === this.pkg.flavor
|
||||
? pkg
|
||||
: null,
|
||||
),
|
||||
first(),
|
||||
)
|
||||
|
||||
readonly flavor$ = this.local$.pipe(map(pkg => !pkg))
|
||||
|
||||
onStatic(type: 'License' | 'Instructions') {
|
||||
// @TODO Matt display License or Instructions
|
||||
}
|
||||
|
||||
async upload() {
|
||||
const loader = this.loader.open('Starting upload').subscribe()
|
||||
|
||||
try {
|
||||
const { upload, progress } = await this.api.sideloadPackage()
|
||||
|
||||
this.sideloadService.followProgress(progress)
|
||||
const { upload } = await this.api.sideloadPackage()
|
||||
this.api.uploadPackage(upload, this.file).catch(console.error)
|
||||
await firstValueFrom(this.progress$.pipe(filter(Boolean)))
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
open(id: string) {
|
||||
this.router.navigate(['/marketplace'], { queryParams: { id } })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,11 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
ChangeDetectorRef,
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { MarketplacePkgBase } from '@start9labs/marketplace'
|
||||
import { tuiIsString } from '@taiga-ui/cdk'
|
||||
import { TuiButton } from '@taiga-ui/core'
|
||||
import {
|
||||
@@ -17,16 +16,16 @@ import {
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { TitleDirective } from 'src/app/services/title.service'
|
||||
import { SideloadPackageComponent } from './package.component'
|
||||
import { parseS9pk } from './sideload.utils'
|
||||
import { MarketplacePkgSideload, validateS9pk } from './sideload.utils'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<ng-container *title>Sideload</ng-container>
|
||||
@if (file && package()) {
|
||||
<sideload-package [package]="package()!" [file]="file!">
|
||||
@if (file && package(); as pkg) {
|
||||
<sideload-package [pkg]="pkg" [file]="file!">
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="secondary"
|
||||
appearance="neutral"
|
||||
iconStart="@tui.x"
|
||||
[style.border-radius.%]="100"
|
||||
[style.justify-self]="'end'"
|
||||
@@ -55,7 +54,10 @@ import { parseS9pk } from './sideload.utils'
|
||||
<tui-avatar appearance="secondary" src="@tui.cloud-upload" />
|
||||
<p>Upload .s9pk package file</p>
|
||||
@if (isTor) {
|
||||
<p class="g-positive">Tip: switch to LAN for faster uploads</p>
|
||||
<p class="g-warning">
|
||||
Warning: package upload will be slow over Tor. Switch to local
|
||||
for a better experience.
|
||||
</p>
|
||||
}
|
||||
<button tuiButton>Upload</button>
|
||||
</div>
|
||||
@@ -69,7 +71,7 @@ import { parseS9pk } from './sideload.utils'
|
||||
`
|
||||
label {
|
||||
height: 100%;
|
||||
max-width: 40rem;
|
||||
max-width: 42rem;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@@ -91,11 +93,10 @@ import { parseS9pk } from './sideload.utils'
|
||||
],
|
||||
})
|
||||
export default class SideloadComponent {
|
||||
private readonly cdr = inject(ChangeDetectorRef)
|
||||
readonly isTor = inject(ConfigService).isTor()
|
||||
|
||||
file: File | null = null
|
||||
readonly package = signal<(T.Manifest & { icon: string }) | null>(null)
|
||||
readonly package = signal<MarketplacePkgSideload | null>(null)
|
||||
readonly error = signal('')
|
||||
|
||||
clear() {
|
||||
@@ -105,12 +106,11 @@ export default class SideloadComponent {
|
||||
}
|
||||
|
||||
async onFile(file: File | null) {
|
||||
const parsed = file ? await parseS9pk(file) : ''
|
||||
|
||||
this.file = file
|
||||
|
||||
const parsed = file ? await validateS9pk(file) : ''
|
||||
|
||||
this.package.set(tuiIsString(parsed) ? null : parsed)
|
||||
this.error.set(tuiIsString(parsed) ? parsed : '')
|
||||
// @TODO Alex figure out why it is needed even though we use signals
|
||||
this.cdr.markForCheck()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import {
|
||||
catchError,
|
||||
EMPTY,
|
||||
endWith,
|
||||
shareReplay,
|
||||
Subject,
|
||||
switchMap,
|
||||
tap,
|
||||
} from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class SideloadService {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly guid$ = new Subject<string>()
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly router = inject(Router)
|
||||
|
||||
readonly progress$ = this.guid$.pipe(
|
||||
switchMap(guid =>
|
||||
this.api
|
||||
.openWebsocket$<T.FullProgress>(guid, {
|
||||
closeObserver: {
|
||||
next: event => {
|
||||
if (event.code !== 1000) {
|
||||
this.errorService.handleError(event.reason)
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
.pipe(
|
||||
tap(p => {
|
||||
if (p.overall === true) {
|
||||
this.router.navigate([''], { replaceUrl: true })
|
||||
}
|
||||
}),
|
||||
endWith(null),
|
||||
),
|
||||
),
|
||||
catchError(e => {
|
||||
this.errorService.handleError('Websocket connection broken. Try again.')
|
||||
return EMPTY
|
||||
}),
|
||||
shareReplay(1),
|
||||
)
|
||||
|
||||
followProgress(guid: string) {
|
||||
this.guid$.next(guid)
|
||||
}
|
||||
}
|
||||
@@ -1,31 +1,22 @@
|
||||
import { S9pk, T } from '@start9labs/start-sdk'
|
||||
import cbor from 'cbor'
|
||||
import { MarketplacePkgBase } from '@start9labs/marketplace'
|
||||
import { S9pk, ExtendedVersion } from '@start9labs/start-sdk'
|
||||
|
||||
const MAGIC = new Uint8Array([59, 59])
|
||||
const VERSION_1 = new Uint8Array([1])
|
||||
const VERSION_2 = new Uint8Array([2])
|
||||
|
||||
interface Positions {
|
||||
[key: string]: [bigint, bigint] // [position, length]
|
||||
}
|
||||
|
||||
export async function parseS9pk(
|
||||
export async function validateS9pk(
|
||||
file: File,
|
||||
): Promise<(T.Manifest & { icon: string }) | string> {
|
||||
): Promise<MarketplacePkgSideload | string> {
|
||||
const magic = new Uint8Array(await blobToBuffer(file.slice(0, 2)))
|
||||
const version = new Uint8Array(await blobToBuffer(file.slice(2, 3)))
|
||||
|
||||
if (compare(magic, MAGIC)) {
|
||||
try {
|
||||
if (compare(version, VERSION_1)) {
|
||||
return await parseS9pkV1(file)
|
||||
return 'Version 1 s9pk detected. This package format is deprecated. You can sideload a V1 s9pk via start-cli if necessary.'
|
||||
} else if (compare(version, VERSION_2)) {
|
||||
const s9pk = await S9pk.deserialize(file, null)
|
||||
|
||||
return {
|
||||
...s9pk.manifest,
|
||||
icon: await s9pk.icon(),
|
||||
}
|
||||
return await parseS9pk(file)
|
||||
} else {
|
||||
console.error(version)
|
||||
|
||||
@@ -43,92 +34,21 @@ export async function parseS9pk(
|
||||
return 'Invalid package file'
|
||||
}
|
||||
|
||||
async function parseS9pkV1(file: File) {
|
||||
const positions: Positions = {}
|
||||
// magic=2bytes, version=1bytes, pubkey=32bytes, signature=64bytes, toc_length=4bytes = 103byte is starting point
|
||||
let start = 103
|
||||
let end = start + 1 // 104
|
||||
const tocLength = new DataView(
|
||||
await blobToBuffer(file.slice(99, 103) ?? new Blob()),
|
||||
).getUint32(0, false)
|
||||
await getPositions(start, end, file, positions, tocLength as any)
|
||||
|
||||
const data = await blobToBuffer(
|
||||
file.slice(
|
||||
Number(positions['manifest'][0]),
|
||||
Number(positions['manifest'][0]) + Number(positions['manifest'][1]),
|
||||
),
|
||||
)
|
||||
async function parseS9pk(file: File): Promise<MarketplacePkgSideload> {
|
||||
const s9pk = await S9pk.deserialize(file, null)
|
||||
|
||||
return {
|
||||
...(await cbor.decode(data, true)),
|
||||
icon: await blobToDataURL(
|
||||
file.slice(
|
||||
Number(positions['icon'][0]),
|
||||
Number(positions['icon'][0]) + Number(positions['icon'][1]),
|
||||
'',
|
||||
),
|
||||
),
|
||||
...s9pk.manifest,
|
||||
dependencyMetadata: await s9pk.dependencyMetadata(),
|
||||
gitHash: '',
|
||||
icon: await s9pk.icon(),
|
||||
sourceVersion: s9pk.manifest.canMigrateFrom,
|
||||
flavor: ExtendedVersion.parse(s9pk.manifest.version).flavor,
|
||||
license: await s9pk.license(),
|
||||
instructions: await s9pk.instructions(),
|
||||
}
|
||||
}
|
||||
|
||||
async function getPositions(
|
||||
initialStart: number,
|
||||
initialEnd: number,
|
||||
file: Blob,
|
||||
positions: Positions,
|
||||
tocLength: number,
|
||||
) {
|
||||
let start = initialStart
|
||||
let end = initialEnd
|
||||
const titleLength = new Uint8Array(
|
||||
await blobToBuffer(file.slice(start, end)),
|
||||
)[0]
|
||||
const tocTitle = await file.slice(end, end + titleLength).text()
|
||||
start = end + titleLength
|
||||
end = start + 8
|
||||
const chapterPosition = new DataView(
|
||||
await blobToBuffer(file.slice(start, end)),
|
||||
).getBigUint64(0, false)
|
||||
start = end
|
||||
end = start + 8
|
||||
const chapterLength = new DataView(
|
||||
await blobToBuffer(file.slice(start, end)),
|
||||
).getBigUint64(0, false)
|
||||
|
||||
positions[tocTitle] = [chapterPosition, chapterLength]
|
||||
start = end
|
||||
end = start + 1
|
||||
if (end <= tocLength + (initialStart - 1)) {
|
||||
await getPositions(start, end, file, positions, tocLength)
|
||||
}
|
||||
}
|
||||
|
||||
async function readBlobAsDataURL(
|
||||
f: Blob | File,
|
||||
): Promise<string | ArrayBuffer | null> {
|
||||
const reader = new FileReader()
|
||||
return new Promise((resolve, reject) => {
|
||||
reader.onloadend = () => {
|
||||
resolve(reader.result)
|
||||
}
|
||||
reader.readAsDataURL(f)
|
||||
reader.onerror = _ => reject(new Error('error reading blob'))
|
||||
})
|
||||
}
|
||||
|
||||
async function blobToDataURL(data: Blob | File): Promise<string> {
|
||||
const res = await readBlobAsDataURL(data)
|
||||
if (res instanceof ArrayBuffer) {
|
||||
throw new Error('readBlobAsDataURL response should not be an array buffer')
|
||||
}
|
||||
if (res == null) {
|
||||
throw new Error('readBlobAsDataURL response should not be null')
|
||||
}
|
||||
if (typeof res === 'string') return res
|
||||
throw new Error('no possible blob to data url resolution found')
|
||||
}
|
||||
|
||||
async function blobToBuffer(data: Blob | File): Promise<ArrayBuffer> {
|
||||
const res = await readBlobToArrayBuffer(data)
|
||||
if (res instanceof String) {
|
||||
@@ -158,3 +78,8 @@ async function readBlobToArrayBuffer(
|
||||
function compare(a: Uint8Array, b: Uint8Array) {
|
||||
return a.length === b.length && a.every((value, index) => value === b[index])
|
||||
}
|
||||
|
||||
export type MarketplacePkgSideload = MarketplacePkgBase & {
|
||||
license: string
|
||||
instructions: string
|
||||
}
|
||||
|
||||
@@ -93,7 +93,7 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
max-width: 40rem;
|
||||
max-width: 36rem;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
||||
@@ -140,7 +140,7 @@ export default class SystemDomainsComponent {
|
||||
|
||||
this.formDialog.open(FormComponent, options)
|
||||
}
|
||||
// @TODO figure out how to get types here
|
||||
// @TODO 041 figure out how to get types here
|
||||
private getNetworkStrategy(strategy: any) {
|
||||
return strategy.selection === 'local'
|
||||
? { ipStrategy: strategy.value.ipStrategy }
|
||||
@@ -162,7 +162,7 @@ export default class SystemDomainsComponent {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
// @TODO figure out how to get types here
|
||||
// @TODO 041 figure out how to get types here
|
||||
private async claimDomain({ strategy }: any): Promise<boolean> {
|
||||
const loader = this.loader.open('Saving...').subscribe()
|
||||
const networkStrategy = this.getNetworkStrategy(strategy)
|
||||
@@ -177,7 +177,7 @@ export default class SystemDomainsComponent {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
// @TODO figure out how to get types here
|
||||
// @TODO 041 figure out how to get types here
|
||||
private async save({ provider, strategy, hostname }: any): Promise<boolean> {
|
||||
const loader = this.loader.open('Saving...').subscribe()
|
||||
const name = provider.selection
|
||||
|
||||
@@ -97,7 +97,7 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
max-width: 40rem;
|
||||
max-width: 36rem;
|
||||
}
|
||||
|
||||
form header,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { PatchDB } from 'patch-db-client'
|
||||
import { map } from 'rxjs'
|
||||
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
|
||||
import { getAddresses } from 'src/app/routes/portal/components/interfaces/interface.utils'
|
||||
import { InterfaceStatusComponent } from 'src/app/routes/portal/components/interfaces/status.component'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { TitleDirective } from 'src/app/services/title.service'
|
||||
@@ -34,10 +35,14 @@ const iface: T.ServiceInterface = {
|
||||
<ng-container *title>
|
||||
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">Back</a>
|
||||
StartOS UI
|
||||
<interface-status [public]="!!ui()?.public" />
|
||||
</ng-container>
|
||||
<header tuiHeader>
|
||||
<hgroup tuiTitle>
|
||||
<h3>{{ iface.name }}</h3>
|
||||
<h3>
|
||||
{{ iface.name }}
|
||||
<interface-status [public]="!!ui()?.public" />
|
||||
</h3>
|
||||
<p tuiSubtitle>{{ iface.description }}</p>
|
||||
</hgroup>
|
||||
</header>
|
||||
@@ -54,6 +59,7 @@ const iface: T.ServiceInterface = {
|
||||
TitleDirective,
|
||||
TuiHeader,
|
||||
TuiTitle,
|
||||
InterfaceStatusComponent,
|
||||
],
|
||||
})
|
||||
export default class StartOsUiComponent {
|
||||
|
||||
@@ -62,7 +62,7 @@ export default class SystemProxiesComponent {
|
||||
this.formDialog.open(FormComponent, options)
|
||||
}
|
||||
|
||||
// @TODO fix type to be WireguardSpec
|
||||
// @TODO 041 fix type to be WireguardSpec
|
||||
private async save({ name, config }: any): Promise<boolean> {
|
||||
const loader = this.loader.open('Saving...').subscribe()
|
||||
|
||||
|
||||
@@ -1,38 +1,45 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { PlatformType } from 'src/app/services/api/api.types'
|
||||
|
||||
@Pipe({
|
||||
name: 'platformInfo',
|
||||
standalone: true,
|
||||
})
|
||||
export class PlatformInfoPipe implements PipeTransform {
|
||||
transform(platforms: readonly PlatformType[]): {
|
||||
transform(userAgent: string | null): {
|
||||
name: string
|
||||
icon: string
|
||||
} {
|
||||
const info = {
|
||||
name: '',
|
||||
icon: '@tui.smartphone',
|
||||
if (!userAgent) {
|
||||
return {
|
||||
name: 'CLI',
|
||||
icon: '@tui.terminal',
|
||||
}
|
||||
}
|
||||
|
||||
if (platforms.includes('cli')) {
|
||||
info.name = 'CLI'
|
||||
info.icon = '@tui.terminal'
|
||||
} else if (platforms.includes('desktop')) {
|
||||
info.name = 'Desktop/Laptop'
|
||||
info.icon = '@tui.monitor'
|
||||
} else if (platforms.includes('android')) {
|
||||
info.name = 'Android Device'
|
||||
} else if (platforms.includes('iphone')) {
|
||||
info.name = 'iPhone'
|
||||
} else if (platforms.includes('ipad')) {
|
||||
info.name = 'iPad'
|
||||
} else if (platforms.includes('ios')) {
|
||||
info.name = 'iOS Device'
|
||||
} else {
|
||||
info.name = 'Unknown Device'
|
||||
if (/Android/i.test(userAgent)) {
|
||||
return {
|
||||
name: 'Android Device',
|
||||
icon: '@tui.smartphone',
|
||||
}
|
||||
}
|
||||
|
||||
return info
|
||||
if (/iPhone/i.test(userAgent)) {
|
||||
return {
|
||||
name: 'iPhone',
|
||||
icon: '@tui.smartphone',
|
||||
}
|
||||
}
|
||||
|
||||
if (/iPad/i.test(userAgent)) {
|
||||
return {
|
||||
name: 'iPad',
|
||||
icon: '@tui.smartphone',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name: 'Desktop/Laptop',
|
||||
icon: '@tui.monitor',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { TuiTable } from '@taiga-ui/addon-table'
|
||||
import { TuiLet } from '@taiga-ui/cdk'
|
||||
import { TuiButton, TuiTitle } from '@taiga-ui/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
|
||||
@@ -33,10 +33,10 @@ import { PlatformInfoPipe } from './platform-info.pipe'
|
||||
<span tuiFade class="agent">{{ session.userAgent }}</span>
|
||||
</label>
|
||||
</td>
|
||||
@if (session.metadata.platforms | platformInfo; as info) {
|
||||
@if (session.userAgent | platformInfo; as platform) {
|
||||
<td class="platform">
|
||||
<tui-icon [icon]="info.icon" />
|
||||
{{ info.name }}
|
||||
<tui-icon [icon]="platform.icon" />
|
||||
{{ platform.name }}
|
||||
</td>
|
||||
}
|
||||
<td class="date">{{ session.lastActive | date: 'medium' }}</td>
|
||||
|
||||
@@ -103,7 +103,7 @@ import { wifiSpec } from './wifi.const'
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
max-width: 40rem;
|
||||
max-width: 36rem;
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
||||
@@ -935,26 +935,17 @@ export namespace Mock {
|
||||
loggedIn: '2021-07-14T20:49:17.774Z',
|
||||
lastActive: '2021-07-14T20:49:17.774Z',
|
||||
userAgent: 'AppleWebKit/{WebKit Rev} (KHTML, like Gecko)',
|
||||
metadata: {
|
||||
platforms: ['iphone', 'mobileweb', 'mobile', 'ios'],
|
||||
},
|
||||
},
|
||||
klndsfjhbwsajkdnaksj: {
|
||||
loggedIn: '2021-07-14T20:49:17.774Z',
|
||||
lastActive: '2019-07-14T20:49:17.774Z',
|
||||
userAgent: 'AppleWebKit/{WebKit Rev} (KHTML, like Gecko)',
|
||||
metadata: {
|
||||
platforms: ['cli'],
|
||||
},
|
||||
},
|
||||
b7b1a9cef4284f00af9e9dda6e676177: {
|
||||
loggedIn: '2021-07-14T20:49:17.774Z',
|
||||
lastActive: '2021-06-14T20:49:17.774Z',
|
||||
userAgent:
|
||||
'Mozilla/5.0 (Windows NT 6.1; Win64; x64; rv:47.0) Gecko/20100101 Firefox/47.0',
|
||||
metadata: {
|
||||
platforms: ['desktop'],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -1500,7 +1491,6 @@ export namespace Mock {
|
||||
},
|
||||
{
|
||||
spec: ISB.InputSpec.of({
|
||||
/* TODO: Convert range for this value ([0, 2])*/
|
||||
union: ISB.Value.union(
|
||||
{
|
||||
name: 'Preference',
|
||||
@@ -1560,19 +1550,18 @@ export namespace Mock {
|
||||
},
|
||||
disabled: ['option2'],
|
||||
})),
|
||||
'favorite-number':
|
||||
/* TODO: Convert range for this value ((-100,100])*/ ISB.Value.number(
|
||||
{
|
||||
name: 'Favorite Number',
|
||||
description: 'Your favorite number of all time',
|
||||
warning:
|
||||
'Once you set this number, it can never be changed without severe consequences.',
|
||||
required: false,
|
||||
default: 7,
|
||||
integer: false,
|
||||
units: 'BTC',
|
||||
},
|
||||
),
|
||||
'favorite-number': ISB.Value.number({
|
||||
name: 'Favorite Number',
|
||||
description: 'Your favorite number of all time',
|
||||
warning:
|
||||
'Once you set this number, it can never be changed without severe consequences.',
|
||||
required: false,
|
||||
default: 7,
|
||||
integer: false,
|
||||
units: 'BTC',
|
||||
min: -100,
|
||||
max: 100,
|
||||
}),
|
||||
rpcsettings: ISB.Value.object(
|
||||
{
|
||||
name: 'RPC Settings',
|
||||
@@ -1906,7 +1895,7 @@ export namespace Mock {
|
||||
name: 'View Properties',
|
||||
description: 'view important information about Bitcoin',
|
||||
warning: null,
|
||||
visibility: 'enabled',
|
||||
visibility: 'hidden',
|
||||
allowedStatuses: 'any',
|
||||
hasInput: false,
|
||||
group: null,
|
||||
|
||||
@@ -31,7 +31,6 @@ export namespace RR {
|
||||
|
||||
export type LoginReq = {
|
||||
password: string
|
||||
metadata: SessionMetadata
|
||||
ephemeral?: boolean
|
||||
} // auth.login - unauthed
|
||||
export type loginRes = null
|
||||
@@ -421,30 +420,8 @@ export type Session = {
|
||||
loggedIn: string
|
||||
lastActive: string
|
||||
userAgent: string
|
||||
metadata: SessionMetadata
|
||||
}
|
||||
|
||||
export type SessionMetadata = {
|
||||
platforms: PlatformType[]
|
||||
}
|
||||
|
||||
export type PlatformType =
|
||||
| 'cli'
|
||||
| 'ios'
|
||||
| 'ipad'
|
||||
| 'iphone'
|
||||
| 'android'
|
||||
| 'phablet'
|
||||
| 'tablet'
|
||||
| 'cordova'
|
||||
| 'capacitor'
|
||||
| 'electron'
|
||||
| 'pwa'
|
||||
| 'mobile'
|
||||
| 'mobileweb'
|
||||
| 'desktop'
|
||||
| 'hybrid'
|
||||
|
||||
export type BackupTarget = DiskBackupTarget | CifsBackupTarget
|
||||
|
||||
export interface DiskBackupTarget {
|
||||
@@ -604,7 +581,7 @@ export type DependencyErrorTransitive = {
|
||||
type: 'transitive'
|
||||
}
|
||||
|
||||
// **** @TODO 041 ****
|
||||
// @TODO 041
|
||||
|
||||
// export namespace RR041 {
|
||||
// // ** domains **
|
||||
|
||||
@@ -252,7 +252,7 @@ export const mockPatchData: DataModel = {
|
||||
name: 'View Properties',
|
||||
description: 'view important information about Bitcoin',
|
||||
warning: null,
|
||||
visibility: 'enabled',
|
||||
visibility: 'hidden',
|
||||
allowedStatuses: 'any',
|
||||
hasInput: false,
|
||||
group: null,
|
||||
|
||||
@@ -357,11 +357,11 @@ export function listUnique(spec: IST.ValueSpecList): ValidatorFn {
|
||||
const objSpec = spec.spec
|
||||
let display1: string
|
||||
let display2: string
|
||||
let uniqueMessage = isObject(objSpec)
|
||||
let uniqueMessage = isListObject(objSpec)
|
||||
? uniqueByMessageWrapper(objSpec.uniqueBy, objSpec)
|
||||
: ''
|
||||
|
||||
if (isObject(objSpec) && objSpec.displayAs) {
|
||||
if (isListObject(objSpec) && objSpec.displayAs) {
|
||||
display1 = `"${(Mustache as any).render(
|
||||
objSpec.displayAs,
|
||||
list[idx],
|
||||
@@ -390,7 +390,6 @@ function listItemEquals(
|
||||
val1: any,
|
||||
val2: any,
|
||||
): boolean {
|
||||
// TODO: fix types
|
||||
switch (spec.spec.type) {
|
||||
case 'text':
|
||||
return val1 == val2
|
||||
@@ -402,45 +401,6 @@ function listItemEquals(
|
||||
}
|
||||
}
|
||||
|
||||
function itemEquals(spec: IST.ValueSpec, val1: any, val2: any): boolean {
|
||||
switch (spec.type) {
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
case 'number':
|
||||
case 'toggle':
|
||||
case 'select':
|
||||
return val1 == val2
|
||||
case 'object':
|
||||
// TODO: 'unique-by' does not exist on ValueSpecObject, fix types
|
||||
return objEquals(
|
||||
(spec as any)['unique-by'],
|
||||
spec as IST.ValueSpecObject,
|
||||
val1,
|
||||
val2,
|
||||
)
|
||||
case 'union':
|
||||
// TODO: 'unique-by' does not exist onIST.ValueSpecUnion, fix types
|
||||
return unionEquals(
|
||||
(spec as any)['unique-by'],
|
||||
spec as IST.ValueSpecUnion,
|
||||
val1,
|
||||
val2,
|
||||
)
|
||||
case 'list':
|
||||
if (val1.length !== val2.length) {
|
||||
return false
|
||||
}
|
||||
for (let idx = 0; idx < val1.length; idx++) {
|
||||
if (listItemEquals(spec, val1[idx], val2[idx])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function listObjEquals(
|
||||
uniqueBy: IST.UniqueBy,
|
||||
spec: IST.ListValueSpecObject,
|
||||
@@ -450,17 +410,17 @@ function listObjEquals(
|
||||
if (!uniqueBy) {
|
||||
return false
|
||||
} else if (typeof uniqueBy === 'string') {
|
||||
return itemEquals(spec.spec[uniqueBy], val1[uniqueBy], val2[uniqueBy])
|
||||
return uniqueByEquals(spec.spec[uniqueBy], val1[uniqueBy], val2[uniqueBy])
|
||||
} else if ('any' in uniqueBy) {
|
||||
for (let subSpec of uniqueBy.any) {
|
||||
if (listObjEquals(subSpec, spec, val1, val2)) {
|
||||
for (let unique of uniqueBy.any) {
|
||||
if (listObjEquals(unique, spec, val1, val2)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
} else if ('all' in uniqueBy) {
|
||||
for (let subSpec of uniqueBy.all) {
|
||||
if (!listObjEquals(subSpec, spec, val1, val2)) {
|
||||
for (let unique of uniqueBy.all) {
|
||||
if (!listObjEquals(unique, spec, val1, val2)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -469,66 +429,29 @@ function listObjEquals(
|
||||
return false
|
||||
}
|
||||
|
||||
function objEquals(
|
||||
uniqueBy: IST.UniqueBy,
|
||||
spec: IST.ValueSpecObject,
|
||||
val1: any,
|
||||
val2: any,
|
||||
): boolean {
|
||||
if (!uniqueBy) {
|
||||
return false
|
||||
} else if (typeof uniqueBy === 'string') {
|
||||
// TODO: fix types
|
||||
return itemEquals((spec as any)[uniqueBy], val1[uniqueBy], val2[uniqueBy])
|
||||
} else if ('any' in uniqueBy) {
|
||||
for (let subSpec of uniqueBy.any) {
|
||||
if (objEquals(subSpec, spec, val1, val2)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
} else if ('all' in uniqueBy) {
|
||||
for (let subSpec of uniqueBy.all) {
|
||||
if (!objEquals(subSpec, spec, val1, val2)) {
|
||||
function uniqueByEquals(spec: IST.ValueSpec, val1: any, val2: any): boolean {
|
||||
switch (spec.type) {
|
||||
case 'text':
|
||||
case 'textarea':
|
||||
case 'number':
|
||||
case 'toggle':
|
||||
case 'select':
|
||||
case 'color':
|
||||
case 'datetime':
|
||||
return val1 == val2
|
||||
case 'list':
|
||||
if (val1.length !== val2.length) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function unionEquals(
|
||||
uniqueBy: IST.UniqueBy,
|
||||
spec: IST.ValueSpecUnion,
|
||||
val1: any,
|
||||
val2: any,
|
||||
): boolean {
|
||||
const variantSpec = spec.variants[val1.selection].spec
|
||||
if (!uniqueBy) {
|
||||
return false
|
||||
} else if (typeof uniqueBy === 'string') {
|
||||
if (uniqueBy === 'selection') {
|
||||
return val1.selection === val2.selection
|
||||
} else {
|
||||
return itemEquals(variantSpec[uniqueBy], val1[uniqueBy], val2[uniqueBy])
|
||||
}
|
||||
} else if ('any' in uniqueBy) {
|
||||
for (let subSpec of uniqueBy.any) {
|
||||
if (unionEquals(subSpec, spec, val1, val2)) {
|
||||
return true
|
||||
for (let idx = 0; idx < val1.length; idx++) {
|
||||
if (listItemEquals(spec, val1[idx], val2[idx])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
} else if ('all' in uniqueBy) {
|
||||
for (let subSpec of uniqueBy.all) {
|
||||
if (!unionEquals(subSpec, spec, val1, val2)) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function uniqueByMessageWrapper(
|
||||
@@ -573,7 +496,7 @@ function uniqueByMessage(
|
||||
: '(' + ret + ')'
|
||||
}
|
||||
|
||||
function isObject(
|
||||
function isListObject(
|
||||
spec: IST.ListValueSpecOf<any>,
|
||||
): spec is IST.ListValueSpecObject {
|
||||
// only lists of objects have uniqueBy
|
||||
|
||||
@@ -48,7 +48,6 @@ export class MarketplaceService {
|
||||
this.registryUrl$.pipe(
|
||||
switchMap(url => this.fetchRegistry$(url)),
|
||||
filter(Boolean),
|
||||
// @TODO is updateStoreName needed?
|
||||
map(registry => {
|
||||
registry.info.categories = {
|
||||
all: {
|
||||
@@ -217,7 +216,7 @@ export class MarketplaceService {
|
||||
map(packages => {
|
||||
return Object.entries(packages).flatMap(([id, pkgInfo]) =>
|
||||
Object.keys(pkgInfo.best).map(version =>
|
||||
this.convertToMarketplacePkg(
|
||||
this.convertRegistryPkgToMarketplacePkg(
|
||||
id,
|
||||
version,
|
||||
this.exver.getFlavor(version),
|
||||
@@ -239,12 +238,12 @@ export class MarketplaceService {
|
||||
this.api.getRegistryPackage(url, id, version ? `=${version}` : null),
|
||||
).pipe(
|
||||
map(pkgInfo =>
|
||||
this.convertToMarketplacePkg(id, version, flavor, pkgInfo),
|
||||
this.convertRegistryPkgToMarketplacePkg(id, version, flavor, pkgInfo),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private convertToMarketplacePkg(
|
||||
private convertRegistryPkgToMarketplacePkg(
|
||||
id: string,
|
||||
version: string | null | undefined,
|
||||
flavor: string | null,
|
||||
|
||||
@@ -40,7 +40,7 @@ hr {
|
||||
top left,
|
||||
top right;
|
||||
|
||||
// TODO: Theme
|
||||
// @TODO Theme
|
||||
background-color: color-mix(
|
||||
in hsl,
|
||||
var(--tui-background-base) 90%,
|
||||
|
||||
Reference in New Issue
Block a user