mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-04-01 21:13:09 +00:00
Merge branch 'next/minor' of github.com:Start9Labs/start-os into next/major
This commit is contained in:
@@ -18,11 +18,14 @@ export class MockApiService implements ApiService {
|
||||
capacity: 73264762332,
|
||||
used: null,
|
||||
startOs: {
|
||||
version: '0.2.17',
|
||||
full: true,
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
'1234-5678-9876-5432': {
|
||||
hostname: 'adjective-noun',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '0.2.17',
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
},
|
||||
},
|
||||
guid: null,
|
||||
},
|
||||
@@ -41,11 +44,14 @@ export class MockApiService implements ApiService {
|
||||
capacity: 73264762332,
|
||||
used: null,
|
||||
startOs: {
|
||||
version: '0.3.3',
|
||||
full: true,
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
'1234-5678-9876-5432': {
|
||||
hostname: 'adjective-noun',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '0.2.17',
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
},
|
||||
},
|
||||
guid: null,
|
||||
},
|
||||
@@ -64,11 +70,14 @@ export class MockApiService implements ApiService {
|
||||
capacity: 73264762332,
|
||||
used: null,
|
||||
startOs: {
|
||||
version: '0.3.2',
|
||||
full: true,
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
'1234-5678-9876-5432': {
|
||||
hostname: 'adjective-noun',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '0.2.17',
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
},
|
||||
},
|
||||
guid: 'guid-guid-guid-guid',
|
||||
},
|
||||
|
||||
@@ -41,4 +41,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
ElementRef,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { AbstractMarketplaceService } from '../../services/marketplace.service'
|
||||
import { MarketplacePkg } from '../../types'
|
||||
import { map } from 'rxjs'
|
||||
import { Exver } from '@start9labs/shared'
|
||||
// @TODO Alex use Taiga modal
|
||||
import { ModalController } from '@ionic/angular'
|
||||
|
||||
@Component({
|
||||
selector: 'release-notes',
|
||||
templateUrl: './release-notes.component.html',
|
||||
styleUrls: ['./release-notes.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ReleaseNotesComponent {
|
||||
@Input() pkg!: MarketplacePkg
|
||||
|
||||
private selected: string | null = null
|
||||
|
||||
readonly notes$ = this.marketplaceService.getSelectedStore$().pipe(
|
||||
map(s => {
|
||||
return Object.entries(this.pkg.otherVersions)
|
||||
.filter(
|
||||
([v, _]) =>
|
||||
this.exver.getFlavor(v) === this.pkg.flavor &&
|
||||
this.exver.compareExver(this.pkg.version, v) === 1,
|
||||
)
|
||||
.reduce(
|
||||
(obj, [version, info]) => ({
|
||||
...obj,
|
||||
[version]: info.releaseNotes,
|
||||
}),
|
||||
{
|
||||
[`${this.pkg.version} (current)`]: this.pkg.releaseNotes,
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
constructor(
|
||||
private readonly marketplaceService: AbstractMarketplaceService,
|
||||
private readonly exver: Exver,
|
||||
private readonly modalCtrl: ModalController,
|
||||
) {}
|
||||
|
||||
async dismiss() {
|
||||
return this.modalCtrl.dismiss()
|
||||
}
|
||||
|
||||
isSelected(key: string): boolean {
|
||||
return this.selected === key
|
||||
}
|
||||
|
||||
setSelected(selected: string) {
|
||||
this.selected = this.isSelected(selected) ? null : selected
|
||||
}
|
||||
|
||||
getDocSize(key: string, { nativeElement }: ElementRef<HTMLElement>) {
|
||||
return this.isSelected(key) ? nativeElement.scrollHeight : 0
|
||||
}
|
||||
|
||||
asIsOrder(a: any, b: any) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { TuiAccordion } from '@taiga-ui/kit'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
EmverPipesModule,
|
||||
ExverPipesModule,
|
||||
MarkdownPipeModule,
|
||||
SafeLinksDirective,
|
||||
} from '@start9labs/shared'
|
||||
@@ -12,11 +12,12 @@ import {
|
||||
FilterVersionsPipe,
|
||||
ReleaseNotesComponent,
|
||||
} from './release-notes.component'
|
||||
import { ReleaseNotesComponent } from './release-notes.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
EmverPipesModule,
|
||||
ExverPipesModule,
|
||||
MarkdownPipeModule,
|
||||
NgDompurifyModule,
|
||||
SafeLinksDirective,
|
||||
@@ -28,4 +29,4 @@ import {
|
||||
declarations: [ReleaseNotesComponent],
|
||||
exports: [ReleaseNotesComponent],
|
||||
})
|
||||
export class ReleaseNotesModule {}
|
||||
export class ReleaseNotesComponentModule {}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Input,
|
||||
Output,
|
||||
} from '@angular/core'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-categories',
|
||||
@@ -14,7 +15,7 @@ import {
|
||||
})
|
||||
export class CategoriesComponent {
|
||||
@Input()
|
||||
categories?: string[]
|
||||
categories!: Map<string, T.Category>
|
||||
|
||||
@Input()
|
||||
category = ''
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<div class="background">
|
||||
<img
|
||||
[src]="determineIcon(marketplace)"
|
||||
alt="{{ pkg.manifest.title }} Icon"
|
||||
alt="{{ pkg.title }} Icon"
|
||||
/>
|
||||
</div>
|
||||
<!-- darkening overlay -->
|
||||
@@ -15,14 +15,32 @@
|
||||
<img
|
||||
[src]="determineIcon(marketplace)"
|
||||
class="icon"
|
||||
alt="{{ pkg.manifest.title }} Icon"
|
||||
alt="{{ pkg.title }} Icon"
|
||||
/>
|
||||
<div class="detail">
|
||||
<span class="detail-title" ticker>
|
||||
{{ pkg.manifest.title }}
|
||||
{{ pkg.title }}
|
||||
</span>
|
||||
<span class="detail-description">
|
||||
{{ pkg.manifest.description.short }}
|
||||
{{ pkg.description.short }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- @TODO Alex -->
|
||||
<!-- <ion-item
|
||||
class="service-card"
|
||||
[routerLink]="['/marketplace', pkg.id]"
|
||||
[queryParams]="{ flavor: pkg.flavor, version: pkg.version }"
|
||||
>
|
||||
<ion-thumbnail slot="start">
|
||||
<img alt="" [src]="pkg.icon | trustUrl" />
|
||||
</ion-thumbnail>
|
||||
<ion-label>
|
||||
<h2 class="montserrat">
|
||||
<strong>{{ pkg.title }}</strong>
|
||||
</h2>
|
||||
<h3>{{ pkg.description.short }}</h3>
|
||||
<ng-content></ng-content>
|
||||
</ion-label>
|
||||
</ion-item> -->
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Inject,
|
||||
Input,
|
||||
Pipe,
|
||||
PipeTransform,
|
||||
} from '@angular/core'
|
||||
import { AbstractMarketplaceService } from '../../services/marketplace.service'
|
||||
import { PolymorpheusContent } from '@taiga-ui/polymorpheus'
|
||||
import { TuiDialogContext, TuiDialogService } from '@taiga-ui/core'
|
||||
import { MarketplacePkg } from '../../types'
|
||||
import { Observable } from 'rxjs'
|
||||
import { Emver } from '@start9labs/shared'
|
||||
import { KeyValue } from '@angular/common'
|
||||
|
||||
@Component({
|
||||
selector: 'release-notes',
|
||||
templateUrl: './release-notes.component.html',
|
||||
styleUrls: ['./release-notes.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ReleaseNotesComponent {
|
||||
constructor(
|
||||
private readonly emver: Emver,
|
||||
private readonly marketplaceService: AbstractMarketplaceService,
|
||||
@Inject(TuiDialogService) private readonly dialogs: TuiDialogService,
|
||||
) {}
|
||||
|
||||
@Input({ required: true })
|
||||
pkg!: MarketplacePkg
|
||||
|
||||
notes$!: Observable<Record<string, string>>
|
||||
|
||||
ngOnChanges() {
|
||||
this.notes$ = this.marketplaceService.fetchReleaseNotes$(
|
||||
this.pkg.manifest.id,
|
||||
)
|
||||
}
|
||||
|
||||
asIsOrder(a: KeyValue<string, string>, b: KeyValue<string, string>) {
|
||||
const a1 = a.key.split('.')
|
||||
const b1 = b.key.split('.')
|
||||
// contingency in case there's a 4th or 5th version
|
||||
const len = Math.min(a1.length, b1.length)
|
||||
// look through each version number and compare.
|
||||
for (let i = 0; i < len; i++) {
|
||||
const a2 = +a1[i] || 0
|
||||
const b2 = +b1[i] || 0
|
||||
|
||||
if (a2 !== b2) {
|
||||
// sort descending
|
||||
return a2 > b2 ? -1 : 1
|
||||
}
|
||||
}
|
||||
return a1.length - b1.length
|
||||
}
|
||||
|
||||
async showReleaseNotes(content: PolymorpheusContent<TuiDialogContext>) {
|
||||
this.dialogs
|
||||
.open(content, {
|
||||
label: 'Previous Release Notes',
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'filterVersions',
|
||||
standalone: true,
|
||||
})
|
||||
export class FilterVersionsPipe implements PipeTransform {
|
||||
transform(
|
||||
notes: Record<string, string>,
|
||||
pkgVersion: string,
|
||||
): Record<string, string> {
|
||||
delete notes[pkgVersion]
|
||||
return notes
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { MarketplacePkg } from '../../../types'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { ReleaseNotesComponent } from '../../../modals/release-notes/release-notes.component'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-about',
|
||||
@@ -10,4 +12,15 @@ import { MarketplacePkg } from '../../../types'
|
||||
export class AboutComponent {
|
||||
@Input({ required: true })
|
||||
pkg!: MarketplacePkg
|
||||
|
||||
constructor(private readonly modalCtrl: ModalController) {}
|
||||
|
||||
async presentModalNotes() {
|
||||
const modal = await this.modalCtrl.create({
|
||||
componentProps: { pkg: this.pkg },
|
||||
component: ReleaseNotesComponent,
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
></marketplace-additional-item>
|
||||
<!-- git hash -->
|
||||
<marketplace-additional-item
|
||||
*ngIf="pkg.manifest.gitHash as gitHash; else noHash"
|
||||
*ngIf="pkg.gitHash as gitHash; else noHash"
|
||||
(click)="copyService.copy(gitHash)"
|
||||
[data]="gitHash"
|
||||
label="Git Hash"
|
||||
@@ -29,7 +29,7 @@
|
||||
<!-- license -->
|
||||
<marketplace-additional-item
|
||||
(click)="presentModalMd('License')"
|
||||
[data]="pkg.manifest.license"
|
||||
[data]="pkg.license"
|
||||
label="License"
|
||||
icon="@tui.chevron-right"
|
||||
class="item-pointer"
|
||||
@@ -46,29 +46,29 @@
|
||||
<ng-content />
|
||||
<!-- links -->
|
||||
<marketplace-additional-link
|
||||
*ngIf="pkg.manifest.marketingSite"
|
||||
[url]="pkg.manifest.marketingSite"
|
||||
*ngIf="pkg.marketingSite"
|
||||
[url]="pkg.marketingSite"
|
||||
label="Marketing Site"
|
||||
icon="@tui.external-link"
|
||||
class="item-pointer"
|
||||
></marketplace-additional-link>
|
||||
<marketplace-additional-link
|
||||
*ngIf="pkg.manifest.upstreamRepo"
|
||||
[url]="pkg.manifest.upstreamRepo"
|
||||
*ngIf="pkg.upstreamRepo"
|
||||
[url]="pkg.upstreamRepo"
|
||||
label="Source Repository"
|
||||
icon="@tui.external-link"
|
||||
class="item-pointer"
|
||||
></marketplace-additional-link>
|
||||
<marketplace-additional-link
|
||||
*ngIf="pkg.manifest.wrapperRepo"
|
||||
[url]="pkg.manifest.wrapperRepo"
|
||||
*ngIf="pkg.wrapperRepo"
|
||||
[url]="pkg.wrapperRepo"
|
||||
label="Wrapper Repository"
|
||||
icon="@tui.external-link"
|
||||
class="item-pointer"
|
||||
></marketplace-additional-link>
|
||||
<marketplace-additional-link
|
||||
*ngIf="pkg.manifest.supportSite"
|
||||
[url]="pkg.manifest.supportSite"
|
||||
*ngIf="pkg.supportSite"
|
||||
[url]="pkg.supportSite"
|
||||
label="Support Site"
|
||||
icon="@tui.external-link"
|
||||
class="item-pointer"
|
||||
@@ -76,3 +76,98 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- <ion-item-divider>Additional Info</ion-item-divider>
|
||||
<ion-grid *ngIf="pkg">
|
||||
<ion-row>
|
||||
<ion-col responsiveCol sizeXs="12" sizeMd="6">
|
||||
<ion-item-group>
|
||||
<ion-item
|
||||
*ngIf="pkg.gitHash as gitHash; else noHash"
|
||||
button
|
||||
detail="false"
|
||||
(click)="copy(gitHash)"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>Git Hash</h2>
|
||||
<p>{{ gitHash }}</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="copy-outline"></ion-icon>
|
||||
</ion-item>
|
||||
<ng-template #noHash>
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>Git Hash</h2>
|
||||
<p>Unknown</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-template>
|
||||
<ion-item button detail="false" (click)="presentAlertVersions()">
|
||||
<ion-label>
|
||||
<h2>Other Versions</h2>
|
||||
<p>Click to view other versions</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="chevron-forward"></ion-icon>
|
||||
</ion-item>
|
||||
<ion-item button detail="false" (click)="presentModalMd('license')">
|
||||
<ion-label>
|
||||
<h2>License</h2>
|
||||
<p>{{ pkg.license }}</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="chevron-forward"></ion-icon>
|
||||
</ion-item>
|
||||
<ion-item
|
||||
button
|
||||
detail="false"
|
||||
(click)="presentModalMd('instructions')"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>Instructions</h2>
|
||||
<p>Click to view instructions</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="chevron-forward"></ion-icon>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-col>
|
||||
<ion-col responsiveCol sizeXs="12" sizeMd="6">
|
||||
<ion-item-group>
|
||||
<ion-item
|
||||
[href]="pkg.upstreamRepo"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
detail="false"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>Source Repository</h2>
|
||||
<p>{{ pkg.upstreamRepo }}</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-item>
|
||||
<ion-item
|
||||
[href]="pkg.wrapperRepo"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
detail="false"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>Wrapper Repository</h2>
|
||||
<p>{{ pkg.wrapperRepo }}</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-item>
|
||||
<ion-item
|
||||
[href]="pkg.supportSite"
|
||||
[disabled]="!pkg.supportSite"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
detail="false"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>Support Site</h2>
|
||||
<p>{{ pkg.supportSite || 'Not provided' }}</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid> -->
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import { CopyService, MarkdownComponent } from '@start9labs/shared'
|
||||
import { CopyService, Exver, MarkdownComponent } from '@start9labs/shared'
|
||||
import { MarketplacePkg } from '../../../types'
|
||||
import { AbstractMarketplaceService } from '../../../services/marketplace.service'
|
||||
|
||||
@@ -38,7 +38,7 @@ export class AdditionalComponent {
|
||||
size: 'l',
|
||||
data: {
|
||||
content: this.marketplaceService.fetchStatic$(
|
||||
this.pkg.manifest.id,
|
||||
this.pkg.id,
|
||||
label.toLowerCase(),
|
||||
this.url,
|
||||
),
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<ion-item-divider>Alternative Implementations</ion-item-divider>
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col *ngFor="let pkg of pkgs" responsiveCol sizeSm="12" sizeMd="6">
|
||||
<ion-item
|
||||
[routerLink]="['/marketplace', pkg.id]"
|
||||
[queryParams]="{ flavor: pkg.flavor }"
|
||||
>
|
||||
<ion-thumbnail slot="start">
|
||||
<img alt="" style="border-radius: 100%" [src]="pkg.icon | trustUrl" />
|
||||
</ion-thumbnail>
|
||||
<ion-label>
|
||||
<h2>
|
||||
{{ pkg.title }}
|
||||
</h2>
|
||||
<p>{{ pkg.version }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { MarketplacePkg } from '../../../types'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-flavors',
|
||||
templateUrl: 'flavors.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FlavorsComponent {
|
||||
@Input()
|
||||
pkgs!: MarketplacePkg[]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { ResponsiveColModule, SharedPipesModule } from '@start9labs/shared'
|
||||
import { FlavorsComponent } from './flavors.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
IonicModule,
|
||||
SharedPipesModule,
|
||||
ResponsiveColModule,
|
||||
],
|
||||
declarations: [FlavorsComponent],
|
||||
exports: [FlavorsComponent],
|
||||
})
|
||||
export class FlavorsModule {}
|
||||
@@ -26,11 +26,11 @@ export class FilterPackagesPipe implements PipeTransform {
|
||||
distance: 16,
|
||||
keys: [
|
||||
{
|
||||
name: 'manifest.title',
|
||||
name: 'title',
|
||||
weight: 1,
|
||||
},
|
||||
{
|
||||
name: 'manifest.id',
|
||||
name: 'id',
|
||||
weight: 0.5,
|
||||
},
|
||||
],
|
||||
@@ -42,19 +42,19 @@ export class FilterPackagesPipe implements PipeTransform {
|
||||
useExtendedSearch: true,
|
||||
keys: [
|
||||
{
|
||||
name: 'manifest.title',
|
||||
name: 'title',
|
||||
weight: 1,
|
||||
},
|
||||
{
|
||||
name: 'manifest.id',
|
||||
name: 'id',
|
||||
weight: 0.5,
|
||||
},
|
||||
{
|
||||
name: 'manifest.description.short',
|
||||
name: 'description.short',
|
||||
weight: 0.4,
|
||||
},
|
||||
{
|
||||
name: 'manifest.description.long',
|
||||
name: 'description.long',
|
||||
weight: 0.1,
|
||||
},
|
||||
],
|
||||
@@ -71,7 +71,8 @@ export class FilterPackagesPipe implements PipeTransform {
|
||||
.filter(p => category === 'all' || p.categories.includes(category!))
|
||||
.sort((a, b) => {
|
||||
return (
|
||||
new Date(b.publishedAt).valueOf() - new Date(a.publishedAt).valueOf()
|
||||
new Date(b.s9pk.publishedAt).valueOf() -
|
||||
new Date(a.s9pk.publishedAt).valueOf()
|
||||
)
|
||||
})
|
||||
.map(a => ({ ...a }))
|
||||
|
||||
@@ -8,8 +8,6 @@ export * from './pages/list/item/item.component'
|
||||
export * from './pages/list/item/item.module'
|
||||
export * from './pages/list/search/search.component'
|
||||
export * from './pages/list/search/search.module'
|
||||
export * from './pages/release-notes/release-notes.component'
|
||||
export * from './pages/release-notes/release-notes.module'
|
||||
export * from './pages/show/about/about.component'
|
||||
export * from './pages/show/about/about.module'
|
||||
export * from './pages/show/additional/additional-link.component'
|
||||
@@ -20,6 +18,8 @@ export * from './pages/show/dependencies/dependencies.component'
|
||||
export * from './pages/show/dependencies/dependency-item.component'
|
||||
export * from './pages/show/screenshots/screenshots.component'
|
||||
export * from './pages/show/hero/hero.component'
|
||||
export * from './pages/show/flavors/flavors.component'
|
||||
export * from './pages/show/flavors/flavors.module'
|
||||
|
||||
export * from './pipes/filter-packages.pipe'
|
||||
|
||||
|
||||
@@ -20,18 +20,13 @@ export abstract class AbstractMarketplaceService {
|
||||
|
||||
abstract getPackage$(
|
||||
id: string,
|
||||
version: string,
|
||||
version: string | null,
|
||||
flavor: string | null,
|
||||
url?: string,
|
||||
): Observable<MarketplacePkg> // could be {} so need to check in show page
|
||||
|
||||
abstract fetchReleaseNotes$(
|
||||
id: string,
|
||||
url?: string,
|
||||
): Observable<Record<string, string>>
|
||||
): Observable<MarketplacePkg>
|
||||
|
||||
abstract fetchStatic$(
|
||||
id: string,
|
||||
type: string,
|
||||
url?: string,
|
||||
pkg: MarketplacePkg,
|
||||
type: 'LICENSE.md' | 'instructions.md',
|
||||
): Observable<string>
|
||||
}
|
||||
|
||||
@@ -1,49 +1,39 @@
|
||||
import { Url } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
|
||||
export type StoreURL = string
|
||||
export type StoreName = string
|
||||
|
||||
export interface StoreIdentity {
|
||||
url: StoreURL
|
||||
name?: StoreName
|
||||
export type GetPackageReq = {
|
||||
id: string
|
||||
version: string | null
|
||||
otherVersions: 'short'
|
||||
}
|
||||
export type GetPackageRes = T.GetPackageResponse & {
|
||||
otherVersions: { [version: string]: T.PackageInfoShort }
|
||||
}
|
||||
export type Marketplace = Record<StoreURL, StoreData | null>
|
||||
|
||||
export interface StoreData {
|
||||
info: StoreInfo
|
||||
export type GetPackagesReq = {
|
||||
id: null
|
||||
version: null
|
||||
otherVersions: 'short'
|
||||
}
|
||||
|
||||
export type GetPackagesRes = {
|
||||
[id: T.PackageId]: GetPackageRes
|
||||
}
|
||||
|
||||
export type StoreIdentity = {
|
||||
url: string
|
||||
name?: string
|
||||
}
|
||||
|
||||
export type Marketplace = Record<string, StoreData | null>
|
||||
|
||||
export type StoreData = {
|
||||
info: T.RegistryInfo
|
||||
packages: MarketplacePkg[]
|
||||
}
|
||||
|
||||
export interface StoreInfo {
|
||||
name: StoreName
|
||||
categories: string[]
|
||||
}
|
||||
|
||||
export type StoreIdentityWithData = StoreData & StoreIdentity
|
||||
|
||||
export interface MarketplacePkg {
|
||||
icon: Url
|
||||
license: Url
|
||||
screenshots?: string[]
|
||||
instructions: Url
|
||||
manifest: T.Manifest
|
||||
categories: string[]
|
||||
versions: string[]
|
||||
dependencyMetadata: {
|
||||
[id: string]: DependencyMetadata
|
||||
export type MarketplacePkg = T.PackageVersionInfo &
|
||||
Omit<GetPackageRes, 'best'> & {
|
||||
id: T.PackageId
|
||||
version: string
|
||||
flavor: string | null
|
||||
}
|
||||
publishedAt: string
|
||||
}
|
||||
|
||||
export interface DependencyMetadata {
|
||||
title: string
|
||||
icon: Url
|
||||
optional: boolean
|
||||
hidden: boolean
|
||||
}
|
||||
|
||||
export interface Dependency {
|
||||
description: string | null
|
||||
optional: boolean
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export class AppComponent {
|
||||
let route = 'home'
|
||||
|
||||
if (inProgress) {
|
||||
route = inProgress.complete ? 'success' : 'loading'
|
||||
route = inProgress.status === 'complete' ? '/success' : '/loading'
|
||||
}
|
||||
|
||||
await this.router.navigate([route])
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { ServerBackupSelectModal } from './server-backup-select.page'
|
||||
import { PasswordPageModule } from '../password/password.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [ServerBackupSelectModal],
|
||||
imports: [CommonModule, FormsModule, IonicModule, PasswordPageModule],
|
||||
exports: [ServerBackupSelectModal],
|
||||
})
|
||||
export class ServerBackupSelectModule {}
|
||||
@@ -0,0 +1,24 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Select Server to Restore</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<ion-item *ngFor="let server of servers" button (click)="select(server)">
|
||||
<ion-label>
|
||||
<h2>
|
||||
<b>Local Hostname</b>
|
||||
: {{ server.hostname }}.local
|
||||
</h2>
|
||||
<h2>
|
||||
<b>StartOS Version</b>
|
||||
: {{ server.version }}
|
||||
</h2>
|
||||
<h2>
|
||||
<b>Created</b>
|
||||
: {{ server.timestamp | date : 'medium' }}
|
||||
</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { StartOSDiskInfoWithId } from 'src/app/services/api/api.service'
|
||||
import { PasswordPage } from '../password/password.page'
|
||||
|
||||
@Component({
|
||||
selector: 'server-backup-select',
|
||||
templateUrl: 'server-backup-select.page.html',
|
||||
styleUrls: ['server-backup-select.page.scss'],
|
||||
})
|
||||
export class ServerBackupSelectModal {
|
||||
@Input() servers: StartOSDiskInfoWithId[] = []
|
||||
|
||||
constructor(private readonly modalController: ModalController) {}
|
||||
|
||||
cancel() {
|
||||
this.modalController.dismiss()
|
||||
}
|
||||
|
||||
async select(server: StartOSDiskInfoWithId): Promise<void> {
|
||||
this.presentModalPassword(server)
|
||||
}
|
||||
|
||||
private async presentModalPassword(
|
||||
server: StartOSDiskInfoWithId,
|
||||
): Promise<void> {
|
||||
const modal = await this.modalController.create({
|
||||
component: PasswordPage,
|
||||
componentProps: { passwordHash: server.passwordHash },
|
||||
})
|
||||
modal.onDidDismiss().then(res => {
|
||||
if (res.role === 'success') {
|
||||
this.modalController.dismiss(
|
||||
{
|
||||
serverId: server.id,
|
||||
recoveryPassword: res.data.password,
|
||||
},
|
||||
'success',
|
||||
)
|
||||
}
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,30 @@
|
||||
import * as jose from 'node-jose'
|
||||
import {
|
||||
DiskInfo,
|
||||
DiskListResponse,
|
||||
PartitionInfo,
|
||||
StartOSDiskInfo,
|
||||
Log,
|
||||
SetupStatus,
|
||||
FollowLogsRes,
|
||||
FollowLogsReq,
|
||||
} from '@start9labs/shared'
|
||||
import { Observable } from 'rxjs'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||
import { Observable } from 'rxjs'
|
||||
|
||||
export abstract class ApiService {
|
||||
pubkey?: jose.JWK.Key
|
||||
|
||||
abstract getSetupStatus(): Promise<SetupStatus | null> // setup.status
|
||||
abstract getStatus(): Promise<T.SetupStatusRes | null> // setup.status
|
||||
abstract getPubKey(): Promise<void> // setup.get-pubkey
|
||||
abstract getDrives(): Promise<DiskListResponse> // setup.disk.list
|
||||
abstract verifyCifs(cifs: CifsRecoverySource): Promise<StartOSDiskInfo> // setup.cifs.verify
|
||||
abstract attach(importInfo: AttachReq): Promise<void> // setup.attach
|
||||
abstract execute(setupInfo: ExecuteReq): Promise<void> // setup.execute
|
||||
abstract complete(): Promise<CompleteRes> // setup.complete
|
||||
abstract verifyCifs(
|
||||
cifs: T.VerifyCifsParams,
|
||||
): Promise<Record<string, StartOSDiskInfo>> // setup.cifs.verify
|
||||
abstract attach(importInfo: T.AttachParams): Promise<T.SetupProgress> // setup.attach
|
||||
abstract execute(setupInfo: T.SetupExecuteParams): Promise<T.SetupProgress> // setup.execute
|
||||
abstract complete(): Promise<T.SetupResult> // setup.complete
|
||||
abstract exit(): Promise<void> // setup.exit
|
||||
abstract followServerLogs(params: FollowLogsReq): Promise<FollowLogsRes> // setup.logs.follow
|
||||
abstract openLogsWebsocket$(
|
||||
config: WebSocketSubjectConfig<Log>,
|
||||
): Observable<Log>
|
||||
abstract openProgressWebsocket$(guid: string): Observable<T.FullProgress>
|
||||
|
||||
async encrypt(toEncrypt: string): Promise<Encrypted> {
|
||||
async encrypt(toEncrypt: string): Promise<T.EncryptedWire> {
|
||||
if (!this.pubkey) throw new Error('No pubkey found!')
|
||||
const encrypted = await jose.JWE.createEncrypt(this.pubkey!)
|
||||
.update(toEncrypt)
|
||||
@@ -37,66 +35,13 @@ export abstract class ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
type Encrypted = {
|
||||
encrypted: string
|
||||
export type WebsocketConfig<T> = Omit<WebSocketSubjectConfig<T>, 'url'>
|
||||
|
||||
export type StartOSDiskInfoWithId = StartOSDiskInfo & {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type AttachReq = {
|
||||
guid: string
|
||||
startOsPassword: Encrypted
|
||||
}
|
||||
|
||||
export type ExecuteReq = {
|
||||
startOsLogicalname: string
|
||||
startOsPassword: Encrypted
|
||||
recoverySource: RecoverySource | null
|
||||
recoveryPassword: Encrypted | null
|
||||
}
|
||||
|
||||
export type CompleteRes = {
|
||||
torAddress: string
|
||||
lanAddress: string
|
||||
rootCa: string
|
||||
}
|
||||
|
||||
export type DiskBackupTarget = {
|
||||
vendor: string | null
|
||||
model: string | null
|
||||
logicalname: string | null
|
||||
label: string | null
|
||||
capacity: number
|
||||
used: number | null
|
||||
startOs: StartOSDiskInfo | null
|
||||
}
|
||||
|
||||
export type CifsBackupTarget = {
|
||||
hostname: string
|
||||
path: string
|
||||
username: string
|
||||
mountable: boolean
|
||||
startOs: StartOSDiskInfo | null
|
||||
}
|
||||
|
||||
export type DiskRecoverySource = {
|
||||
type: 'disk'
|
||||
logicalname: string // partition logicalname
|
||||
}
|
||||
|
||||
export type BackupRecoverySource = {
|
||||
type: 'backup'
|
||||
target: CifsRecoverySource | DiskRecoverySource
|
||||
}
|
||||
export type RecoverySource = BackupRecoverySource | DiskMigrateSource
|
||||
|
||||
export type DiskMigrateSource = {
|
||||
type: 'migrate'
|
||||
guid: string
|
||||
}
|
||||
|
||||
export type CifsRecoverySource = {
|
||||
type: 'cifs'
|
||||
hostname: string
|
||||
path: string
|
||||
username: string
|
||||
password: Encrypted | null
|
||||
export type StartOSDiskInfoFull = StartOSDiskInfoWithId & {
|
||||
partition: PartitionInfo
|
||||
drive: DiskInfo
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { Inject, Injectable } from '@angular/core'
|
||||
import {
|
||||
DiskListResponse,
|
||||
StartOSDiskInfo,
|
||||
@@ -12,26 +12,35 @@ import {
|
||||
FollowLogsRes,
|
||||
FollowLogsReq,
|
||||
} from '@start9labs/shared'
|
||||
import {
|
||||
ApiService,
|
||||
CifsRecoverySource,
|
||||
DiskRecoverySource,
|
||||
AttachReq,
|
||||
ExecuteReq,
|
||||
CompleteRes,
|
||||
} from './api.service'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { ApiService } from './api.service'
|
||||
import * as jose from 'node-jose'
|
||||
import { webSocket, WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||
import { Observable } from 'rxjs'
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
import { webSocket } from 'rxjs/webSocket'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class LiveApiService extends ApiService {
|
||||
private readonly http = inject(HttpService)
|
||||
constructor(
|
||||
private readonly http: HttpService,
|
||||
@Inject(DOCUMENT) private readonly document: Document,
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
async getSetupStatus() {
|
||||
return this.rpcRequest<SetupStatus | null>({
|
||||
openProgressWebsocket$(guid: string): Observable<T.FullProgress> {
|
||||
const { location } = this.document.defaultView!
|
||||
const host = location.host
|
||||
|
||||
return webSocket({
|
||||
url: `ws://${host}/ws/rpc/${guid}`,
|
||||
})
|
||||
}
|
||||
|
||||
async getStatus(): Promise<T.SetupStatusRes | null> {
|
||||
return this.rpcRequest<T.SetupStatusRes | null>({
|
||||
method: 'setup.status',
|
||||
params: {},
|
||||
})
|
||||
@@ -44,7 +53,7 @@ export class LiveApiService extends ApiService {
|
||||
* this wil all public/private key, which means that there is no information loss
|
||||
* through the network.
|
||||
*/
|
||||
async getPubKey() {
|
||||
async getPubKey(): Promise<void> {
|
||||
const response: jose.JWK.Key = await this.rpcRequest({
|
||||
method: 'setup.get-pubkey',
|
||||
params: {},
|
||||
@@ -53,29 +62,31 @@ export class LiveApiService extends ApiService {
|
||||
this.pubkey = response
|
||||
}
|
||||
|
||||
async getDrives() {
|
||||
async getDrives(): Promise<DiskListResponse> {
|
||||
return this.rpcRequest<DiskListResponse>({
|
||||
method: 'setup.disk.list',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async verifyCifs(source: CifsRecoverySource) {
|
||||
async verifyCifs(
|
||||
source: T.VerifyCifsParams,
|
||||
): Promise<Record<string, StartOSDiskInfo>> {
|
||||
source.path = source.path.replace('/\\/g', '/')
|
||||
return this.rpcRequest<StartOSDiskInfo>({
|
||||
return this.rpcRequest<Record<string, StartOSDiskInfo>>({
|
||||
method: 'setup.cifs.verify',
|
||||
params: source,
|
||||
})
|
||||
}
|
||||
|
||||
async attach(params: AttachReq) {
|
||||
await this.rpcRequest<void>({
|
||||
async attach(params: T.AttachParams): Promise<T.SetupProgress> {
|
||||
return this.rpcRequest<T.SetupProgress>({
|
||||
method: 'setup.attach',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
async execute(setupInfo: ExecuteReq) {
|
||||
async execute(setupInfo: T.SetupExecuteParams): Promise<T.SetupProgress> {
|
||||
if (setupInfo.recoverySource?.type === 'backup') {
|
||||
if (isCifsSource(setupInfo.recoverySource.target)) {
|
||||
setupInfo.recoverySource.target.path =
|
||||
@@ -83,22 +94,14 @@ export class LiveApiService extends ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
await this.rpcRequest<void>({
|
||||
return this.rpcRequest<T.SetupProgress>({
|
||||
method: 'setup.execute',
|
||||
params: setupInfo,
|
||||
})
|
||||
}
|
||||
|
||||
async followServerLogs(params: FollowLogsReq): Promise<FollowLogsRes> {
|
||||
return this.rpcRequest({ method: 'setup.logs.follow', params })
|
||||
}
|
||||
|
||||
openLogsWebsocket$({ url }: WebSocketSubjectConfig<Log>): Observable<Log> {
|
||||
return webSocket(`http://start.local/ws/${url}`)
|
||||
}
|
||||
|
||||
async complete() {
|
||||
const res = await this.rpcRequest<CompleteRes>({
|
||||
async complete(): Promise<T.SetupResult> {
|
||||
const res = await this.rpcRequest<T.SetupResult>({
|
||||
method: 'setup.complete',
|
||||
params: {},
|
||||
})
|
||||
@@ -109,7 +112,7 @@ export class LiveApiService extends ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
async exit() {
|
||||
async exit(): Promise<void> {
|
||||
await this.rpcRequest<void>({
|
||||
method: 'setup.exit',
|
||||
params: {},
|
||||
@@ -130,7 +133,7 @@ export class LiveApiService extends ApiService {
|
||||
}
|
||||
|
||||
function isCifsSource(
|
||||
source: CifsRecoverySource | DiskRecoverySource | null,
|
||||
): source is CifsRecoverySource {
|
||||
return !!(source as CifsRecoverySource)?.hostname
|
||||
source: T.BackupTargetFS | null,
|
||||
): source is T.Cifs & { type: 'cifs' } {
|
||||
return !!(source as T.Cifs)?.hostname
|
||||
}
|
||||
|
||||
@@ -1,32 +1,151 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import {
|
||||
DiskListResponse,
|
||||
StartOSDiskInfo,
|
||||
encodeBase64,
|
||||
FollowLogsReq,
|
||||
FollowLogsRes,
|
||||
getSetupStatusMock,
|
||||
Log,
|
||||
pauseFor,
|
||||
} from '@start9labs/shared'
|
||||
import {
|
||||
ApiService,
|
||||
AttachReq,
|
||||
CifsRecoverySource,
|
||||
CompleteRes,
|
||||
ExecuteReq,
|
||||
} from './api.service'
|
||||
import { ApiService } from './api.service'
|
||||
import * as jose from 'node-jose'
|
||||
import { interval, map, Observable } from 'rxjs'
|
||||
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import {
|
||||
Observable,
|
||||
concatMap,
|
||||
delay,
|
||||
from,
|
||||
interval,
|
||||
map,
|
||||
mergeScan,
|
||||
of,
|
||||
startWith,
|
||||
switchMap,
|
||||
switchScan,
|
||||
takeWhile,
|
||||
} from 'rxjs'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class MockApiService extends ApiService {
|
||||
async getSetupStatus() {
|
||||
return getSetupStatusMock()
|
||||
// fullProgress$(): Observable<T.FullProgress> {
|
||||
// const phases = [
|
||||
// {
|
||||
// name: 'Preparing Data',
|
||||
// progress: null,
|
||||
// },
|
||||
// {
|
||||
// name: 'Transferring Data',
|
||||
// progress: null,
|
||||
// },
|
||||
// {
|
||||
// name: 'Finalizing Setup',
|
||||
// progress: null,
|
||||
// },
|
||||
// ]
|
||||
|
||||
// return from(phases).pipe(
|
||||
// switchScan((acc, val, i) => {}, { overall: null, phases }),
|
||||
// )
|
||||
// }
|
||||
|
||||
// namedProgress$(namedProgress: T.NamedProgress): Observable<T.NamedProgress> {
|
||||
// return of(namedProgress).pipe(startWith(namedProgress))
|
||||
// }
|
||||
|
||||
// progress$(progress: T.Progress): Observable<T.Progress> {}
|
||||
|
||||
// websocket
|
||||
|
||||
openProgressWebsocket$(guid: string): Observable<T.FullProgress> {
|
||||
return of(PROGRESS)
|
||||
// const numPhases = PROGRESS.phases.length
|
||||
|
||||
// return of(PROGRESS).pipe(
|
||||
// switchMap(full =>
|
||||
// from(PROGRESS.phases).pipe(
|
||||
// mergeScan((full, phase, i) => {
|
||||
// if (
|
||||
// !phase.progress ||
|
||||
// typeof phase.progress !== 'object' ||
|
||||
// !phase.progress.total
|
||||
// ) {
|
||||
// full.phases[i].progress = true
|
||||
|
||||
// if (
|
||||
// full.overall &&
|
||||
// typeof full.overall === 'object' &&
|
||||
// full.overall.total
|
||||
// ) {
|
||||
// const step = full.overall.total / numPhases
|
||||
// full.overall.done += step
|
||||
// }
|
||||
|
||||
// return of(full).pipe(delay(2000))
|
||||
// } else {
|
||||
// const total = phase.progress.total
|
||||
// const step = total / 4
|
||||
// let done = phase.progress.done
|
||||
|
||||
// return interval(1000).pipe(
|
||||
// takeWhile(() => done < total),
|
||||
// map(() => {
|
||||
// done += step
|
||||
|
||||
// console.error(done)
|
||||
|
||||
// if (
|
||||
// full.overall &&
|
||||
// typeof full.overall === 'object' &&
|
||||
// full.overall.total
|
||||
// ) {
|
||||
// const step = full.overall.total / numPhases / 4
|
||||
|
||||
// full.overall.done += step
|
||||
// }
|
||||
|
||||
// if (done === total) {
|
||||
// full.phases[i].progress = true
|
||||
|
||||
// if (i === numPhases - 1) {
|
||||
// full.overall = true
|
||||
// }
|
||||
// }
|
||||
// return full
|
||||
// }),
|
||||
// )
|
||||
// }
|
||||
// }, full),
|
||||
// ),
|
||||
// ),
|
||||
// )
|
||||
}
|
||||
|
||||
async getPubKey() {
|
||||
private statusIndex = 0
|
||||
async getStatus(): Promise<T.SetupStatusRes | null> {
|
||||
await pauseFor(1000)
|
||||
|
||||
this.statusIndex++
|
||||
|
||||
switch (this.statusIndex) {
|
||||
case 2:
|
||||
return {
|
||||
status: 'running',
|
||||
progress: PROGRESS,
|
||||
guid: 'progress-guid',
|
||||
}
|
||||
case 3:
|
||||
return {
|
||||
status: 'complete',
|
||||
torAddress: 'https://asdafsadasdasasdasdfasdfasdf.onion',
|
||||
lanAddress: 'https://adjective-noun.local',
|
||||
rootCa: encodeBase64(rootCA),
|
||||
}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async getPubKey(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
|
||||
// randomly generated
|
||||
@@ -42,7 +161,7 @@ export class MockApiService extends ApiService {
|
||||
})
|
||||
}
|
||||
|
||||
async getDrives() {
|
||||
async getDrives(): Promise<DiskListResponse> {
|
||||
await pauseFor(1000)
|
||||
return [
|
||||
{
|
||||
@@ -56,11 +175,14 @@ export class MockApiService extends ApiService {
|
||||
capacity: 1979120929996,
|
||||
used: null,
|
||||
startOs: {
|
||||
version: '0.2.17',
|
||||
full: true,
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
'1234-5678-9876-5432': {
|
||||
hostname: 'adjective-noun',
|
||||
version: '0.2.17',
|
||||
timestamp: new Date().toISOString(),
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
},
|
||||
},
|
||||
guid: null,
|
||||
},
|
||||
@@ -79,11 +201,14 @@ export class MockApiService extends ApiService {
|
||||
capacity: 73264762332,
|
||||
used: null,
|
||||
startOs: {
|
||||
version: '0.3.3',
|
||||
full: true,
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
'1234-5678-9876-5432': {
|
||||
hostname: 'adjective-noun',
|
||||
version: '0.2.17',
|
||||
timestamp: new Date().toISOString(),
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
},
|
||||
},
|
||||
guid: null,
|
||||
},
|
||||
@@ -102,11 +227,14 @@ export class MockApiService extends ApiService {
|
||||
capacity: 73264762332,
|
||||
used: null,
|
||||
startOs: {
|
||||
version: '0.3.2',
|
||||
full: true,
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
'1234-5678-9876-5432': {
|
||||
hostname: 'adjective-noun',
|
||||
version: '0.2.17',
|
||||
timestamp: new Date().toISOString(),
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
},
|
||||
},
|
||||
guid: 'guid-guid-guid-guid',
|
||||
},
|
||||
@@ -117,43 +245,41 @@ export class MockApiService extends ApiService {
|
||||
]
|
||||
}
|
||||
|
||||
async verifyCifs(params: CifsRecoverySource) {
|
||||
async verifyCifs(
|
||||
params: T.VerifyCifsParams,
|
||||
): Promise<Record<string, StartOSDiskInfo>> {
|
||||
await pauseFor(1000)
|
||||
return {
|
||||
version: '0.3.0',
|
||||
full: true,
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: '',
|
||||
}
|
||||
}
|
||||
|
||||
async attach(params: AttachReq) {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
|
||||
async execute(setupInfo: ExecuteReq) {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
|
||||
async followServerLogs(params: FollowLogsReq): Promise<FollowLogsRes> {
|
||||
await pauseFor(1000)
|
||||
return {
|
||||
startCursor: 'fakestartcursor',
|
||||
guid: 'fake-guid',
|
||||
}
|
||||
}
|
||||
|
||||
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
|
||||
return interval(500).pipe(
|
||||
map(() => ({
|
||||
'9876-5432-1234-5678': {
|
||||
hostname: 'adjective-noun',
|
||||
version: '0.3.6',
|
||||
timestamp: new Date().toISOString(),
|
||||
message: 'fake log entry',
|
||||
})),
|
||||
)
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async complete(): Promise<CompleteRes> {
|
||||
async attach(params: T.AttachParams): Promise<T.SetupProgress> {
|
||||
await pauseFor(1000)
|
||||
|
||||
return {
|
||||
progress: PROGRESS,
|
||||
guid: 'progress-guid',
|
||||
}
|
||||
}
|
||||
|
||||
async execute(setupInfo: T.SetupExecuteParams): Promise<T.SetupProgress> {
|
||||
await pauseFor(1000)
|
||||
|
||||
return {
|
||||
progress: PROGRESS,
|
||||
guid: 'progress-guid',
|
||||
}
|
||||
}
|
||||
|
||||
async complete(): Promise<T.SetupResult> {
|
||||
await pauseFor(1000)
|
||||
return {
|
||||
torAddress: 'https://asdafsadasdasasdasdfasdfasdf.onion',
|
||||
@@ -162,7 +288,7 @@ export class MockApiService extends ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
async exit() {
|
||||
async exit(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
}
|
||||
@@ -189,3 +315,8 @@ Rf3ZOPm9QP92YpWyYDkfAU04xdDo1vR0MYjKPkl4LjRqSU/tcCJnPMbJiwq+bWpX
|
||||
2WJoEBXB/p15Kn6JxjI0ze2SnSI48JZ8it4fvxrhOo0VoLNIuCuNXJOwU17Rdl1W
|
||||
YJidaq7je6k18AdgPA0Kh8y1XtfUH3fTaVw4
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
const PROGRESS = {
|
||||
overall: null,
|
||||
phases: [],
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { ApiService, RecoverySource } from './api.service'
|
||||
import { ApiService } from './api.service'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -8,8 +9,7 @@ export class StateService {
|
||||
private readonly api = inject(ApiService)
|
||||
|
||||
setupType?: 'fresh' | 'restore' | 'attach' | 'transfer'
|
||||
recoverySource?: RecoverySource
|
||||
recoveryPassword?: string
|
||||
recoverySource?: T.RecoverySource<string>
|
||||
|
||||
async importDrive(guid: string, password: string): Promise<void> {
|
||||
await this.api.attach({
|
||||
@@ -25,9 +25,13 @@ export class StateService {
|
||||
await this.api.execute({
|
||||
startOsLogicalname: storageLogicalname,
|
||||
startOsPassword: await this.api.encrypt(password),
|
||||
recoverySource: this.recoverySource || null,
|
||||
recoveryPassword: this.recoveryPassword
|
||||
? await this.api.encrypt(this.recoveryPassword)
|
||||
recoverySource: this.recoverySource
|
||||
? this.recoverySource.type === 'migrate'
|
||||
? this.recoverySource
|
||||
: {
|
||||
...this.recoverySource,
|
||||
password: await this.api.encrypt(this.recoverySource.password),
|
||||
}
|
||||
: null,
|
||||
})
|
||||
}
|
||||
|
||||
BIN
web/projects/shared/assets/img/service-icons/fallback.png
Normal file
BIN
web/projects/shared/assets/img/service-icons/fallback.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,10 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" height="1.5em" viewBox="0 0 24 24" width="1.5em">
|
||||
<path
|
||||
vector-effect="non-scaling-stroke"
|
||||
clip-rule="evenodd"
|
||||
d="M7.2395 3.45152L4.01497 3.49988C3.46275 3.50816 3.00837 3.06721 3.00008 2.51499C2.9918 1.96277 3.43275 1.50839 3.98497 1.50011L9.98497 1.41012C10.0214 1.40957 10.0575 1.41098 10.093 1.41425C10.6089 1.4111 11.1258 1.60635 11.5194 2L16 6.48053C16.781 7.26158 16.781 8.52791 16 9.30895L12.5492 12.7597L10.3089 15C9.52787 15.7811 8.26154 15.781 7.48049 15L5.24023 12.7597L2.99997 10.5195C2.21892 9.73843 2.21892 8.4721 2.99997 7.69105L7.2395 3.45152ZM10.1052 3.41422L4.41418 9.10527L5.80892 10.5H11.9805L14.5858 7.89474L10.1052 3.41422ZM20.4206 13.5445L20.4223 13.5453C20.6348 10.9945 18.7416 9.37791 17.9715 8.83085C17.9293 8.80059 17.8726 8.79856 17.8281 8.82545C17.7836 8.85234 17.7676 8.8632 17.7761 8.91357C17.9704 10.0886 16.2673 11.5428 16.2673 11.5428C15.8623 11.9726 15.5886 12.3895 15.4811 12.9331C15.2176 14.2662 16.0981 15.5842 17.4524 15.8519C18.8068 16.1196 20.1167 15.2659 20.3802 13.9329C20.4067 13.7992 20.4166 13.6763 20.4206 13.5445ZM3 18C2.44772 18 2 18.4477 2 19V21C2 21.5523 2.44772 22 3 22H21C21.5523 22 22 21.5523 22 21V19C22 18.4477 21.5523 18 21 18H3Z"
|
||||
fill="currentColor"
|
||||
stroke="none"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
13
web/projects/shared/src/components/loading/loading.module.ts
Normal file
13
web/projects/shared/src/components/loading/loading.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { TuiLoaderModule } from '@taiga-ui/core'
|
||||
import { tuiAsDialog } from '@taiga-ui/cdk'
|
||||
import { LoadingComponent } from './loading.component'
|
||||
import { LoadingService } from './loading.service'
|
||||
|
||||
@NgModule({
|
||||
imports: [TuiLoaderModule],
|
||||
declarations: [LoadingComponent],
|
||||
exports: [LoadingComponent],
|
||||
providers: [tuiAsDialog(LoadingService)],
|
||||
})
|
||||
export class LoadingModule {}
|
||||
@@ -1,12 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import {
|
||||
EmverComparesPipe,
|
||||
EmverDisplayPipe,
|
||||
EmverSatisfiesPipe,
|
||||
} from './emver.pipe'
|
||||
|
||||
@NgModule({
|
||||
declarations: [EmverComparesPipe, EmverDisplayPipe, EmverSatisfiesPipe],
|
||||
exports: [EmverComparesPipe, EmverDisplayPipe, EmverSatisfiesPipe],
|
||||
})
|
||||
export class EmverPipesModule {}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { Emver } from '../../services/emver.service'
|
||||
|
||||
@Pipe({
|
||||
name: 'satisfiesEmver',
|
||||
})
|
||||
export class EmverSatisfiesPipe implements PipeTransform {
|
||||
constructor(private readonly emver: Emver) {}
|
||||
|
||||
transform(versionUnderTest?: string, range?: string): boolean {
|
||||
return (
|
||||
!!versionUnderTest &&
|
||||
!!range &&
|
||||
this.emver.satisfies(versionUnderTest, range)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'compareEmver',
|
||||
})
|
||||
export class EmverComparesPipe implements PipeTransform {
|
||||
constructor(private readonly emver: Emver) {}
|
||||
|
||||
transform(first: string, second: string): SemverResult {
|
||||
try {
|
||||
return this.emver.compare(first, second) as SemverResult
|
||||
} catch (e) {
|
||||
console.error(`emver comparison failed`, e, first, second)
|
||||
return 'comparison-impossible'
|
||||
}
|
||||
}
|
||||
}
|
||||
// left compared to right - if 1, version on left is higher; if 0, values the same; if -1, version on left is lower
|
||||
type SemverResult = 0 | 1 | -1 | 'comparison-impossible'
|
||||
|
||||
@Pipe({
|
||||
name: 'displayEmver',
|
||||
})
|
||||
export class EmverDisplayPipe implements PipeTransform {
|
||||
constructor() {}
|
||||
|
||||
transform(version: string): string {
|
||||
return displayEmver(version)
|
||||
}
|
||||
}
|
||||
|
||||
export function displayEmver(version: string): string {
|
||||
const vs = version.split('.')
|
||||
if (vs.length === 4) return `${vs[0]}.${vs[1]}.${vs[2]}~${vs[3]}`
|
||||
return version
|
||||
}
|
||||
8
web/projects/shared/src/pipes/exver/exver.module.ts
Normal file
8
web/projects/shared/src/pipes/exver/exver.module.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { ExverComparesPipe, ExverSatisfiesPipe } from './exver.pipe'
|
||||
|
||||
@NgModule({
|
||||
declarations: [ExverComparesPipe, ExverSatisfiesPipe],
|
||||
exports: [ExverComparesPipe, ExverSatisfiesPipe],
|
||||
})
|
||||
export class ExverPipesModule {}
|
||||
35
web/projects/shared/src/pipes/exver/exver.pipe.ts
Normal file
35
web/projects/shared/src/pipes/exver/exver.pipe.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { Exver } from '../../services/exver.service'
|
||||
|
||||
@Pipe({
|
||||
name: 'satisfiesExver',
|
||||
})
|
||||
export class ExverSatisfiesPipe implements PipeTransform {
|
||||
constructor(private readonly exver: Exver) {}
|
||||
|
||||
transform(versionUnderTest?: string, range?: string): boolean {
|
||||
return (
|
||||
!!versionUnderTest &&
|
||||
!!range &&
|
||||
this.exver.satisfies(versionUnderTest, range)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'compareExver',
|
||||
})
|
||||
export class ExverComparesPipe implements PipeTransform {
|
||||
constructor(private readonly exver: Exver) {}
|
||||
|
||||
transform(first: string, second: string): SemverResult {
|
||||
try {
|
||||
return this.exver.compareExver(first, second) as SemverResult
|
||||
} catch (e) {
|
||||
console.error(`exver comparison failed`, e, first, second)
|
||||
return 'comparison-impossible'
|
||||
}
|
||||
}
|
||||
}
|
||||
// left compared to right - if 1, version on left is higher; if 0, values the same; if -1, version on left is lower
|
||||
type SemverResult = 0 | 1 | -1 | 'comparison-impossible'
|
||||
@@ -8,6 +8,8 @@ export * from './classes/rpc-error'
|
||||
export * from './components/initializing/logs-window.component'
|
||||
export * from './components/initializing/initializing.component'
|
||||
export * from './components/loading/loading.component'
|
||||
export * from './components/loading/loading.component'
|
||||
export * from './components/loading/loading.module'
|
||||
export * from './components/loading/loading.service'
|
||||
export * from './components/markdown/markdown.component'
|
||||
export * from './components/markdown/markdown.component.module'
|
||||
@@ -20,8 +22,8 @@ export * from './directives/safe-links.directive'
|
||||
|
||||
export * from './mocks/get-setup-status'
|
||||
|
||||
export * from './pipes/emver/emver.module'
|
||||
export * from './pipes/emver/emver.pipe'
|
||||
export * from './pipes/exver/exver.module'
|
||||
export * from './pipes/exver/exver.pipe'
|
||||
export * from './pipes/markdown/markdown.module'
|
||||
export * from './pipes/markdown/markdown.pipe'
|
||||
export * from './pipes/shared/shared.module'
|
||||
@@ -33,7 +35,7 @@ export * from './pipes/unit-conversion/unit-conversion.pipe'
|
||||
|
||||
export * from './services/copy.service'
|
||||
export * from './services/download-html.service'
|
||||
export * from './services/emver.service'
|
||||
export * from './services/exver.service'
|
||||
export * from './services/error.service'
|
||||
export * from './services/http.service'
|
||||
export * from './services/setup.service'
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import * as emver from '@start9labs/emver'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class Emver {
|
||||
constructor() {}
|
||||
|
||||
compare(lhs?: string, rhs?: string): number | null {
|
||||
if (!lhs || !rhs) return null
|
||||
return emver.compare(lhs, rhs)
|
||||
}
|
||||
|
||||
satisfies(version: string, range: string): boolean {
|
||||
return emver.satisfies(version, range)
|
||||
}
|
||||
}
|
||||
43
web/projects/shared/src/services/exver.service.ts
Normal file
43
web/projects/shared/src/services/exver.service.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { VersionRange, ExtendedVersion, Version } from '@start9labs/start-sdk'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class Exver {
|
||||
constructor() {}
|
||||
|
||||
compareExver(lhs: string, rhs: string): number | null {
|
||||
if (!lhs || !rhs) return null
|
||||
try {
|
||||
return ExtendedVersion.parse(lhs).compareForSort(
|
||||
ExtendedVersion.parse(rhs),
|
||||
)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
greaterThanOrEqual(lhs: string, rhs: string): boolean | null {
|
||||
if (!lhs || !rhs) return null
|
||||
try {
|
||||
return ExtendedVersion.parse(lhs).greaterThanOrEqual(
|
||||
ExtendedVersion.parse(rhs),
|
||||
)
|
||||
} catch (e) {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
compareOsVersion(current: string, other: string) {
|
||||
return Version.parse(current).compare(Version.parse(other))
|
||||
}
|
||||
|
||||
satisfies(version: string, range: string): boolean {
|
||||
return VersionRange.parse(range).satisfiedBy(ExtendedVersion.parse(version))
|
||||
}
|
||||
|
||||
getFlavor(version: string): string | null {
|
||||
return ExtendedVersion.parse(version).flavor
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export type FetchLogsRes = {
|
||||
export interface Log {
|
||||
timestamp: string
|
||||
message: string
|
||||
bootId: string
|
||||
}
|
||||
|
||||
export type DiskListResponse = DiskInfo[]
|
||||
@@ -37,13 +38,14 @@ export interface PartitionInfo {
|
||||
label: string | null
|
||||
capacity: number
|
||||
used: number | null
|
||||
startOs: StartOSDiskInfo | null
|
||||
startOs: Record<string, StartOSDiskInfo>
|
||||
guid: string | null
|
||||
}
|
||||
|
||||
export type StartOSDiskInfo = {
|
||||
hostname: string
|
||||
version: string
|
||||
full: boolean
|
||||
timestamp: string
|
||||
passwordHash: string | null
|
||||
wrappedKey: string | null
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ export enum Method {
|
||||
POST = 'POST',
|
||||
}
|
||||
|
||||
type ParamPrimitive = string | number | boolean
|
||||
|
||||
export interface HttpOptions {
|
||||
method: Method
|
||||
url: string
|
||||
@@ -12,7 +14,7 @@ export interface HttpOptions {
|
||||
[header: string]: string | string[]
|
||||
}
|
||||
params?: {
|
||||
[param: string]: string | string[]
|
||||
[param: string]: ParamPrimitive | ParamPrimitive[]
|
||||
}
|
||||
responseType?: 'json' | 'text' | 'arrayBuffer'
|
||||
body?: any
|
||||
@@ -28,7 +30,7 @@ export interface HttpAngularOptions {
|
||||
[header: string]: string | string[]
|
||||
}
|
||||
params?: {
|
||||
[param: string]: string | string[]
|
||||
[param: string]: ParamPrimitive | ParamPrimitive[]
|
||||
}
|
||||
responseType?: 'json' | 'text' | 'arrayBuffer'
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { APP_INITIALIZER, Provider } from '@angular/core'
|
||||
import { APP_INITIALIZER, inject, Provider } from '@angular/core'
|
||||
import { UntypedFormBuilder } from '@angular/forms'
|
||||
import { Router } from '@angular/router'
|
||||
import {
|
||||
@@ -30,6 +30,7 @@ import { DateTransformerService } from './services/date-transformer.service'
|
||||
import { DatetimeTransformerService } from './services/datetime-transformer.service'
|
||||
import { MarketplaceService } from './services/marketplace.service'
|
||||
import { ThemeSwitcherService } from './services/theme-switcher.service'
|
||||
import { StorageService } from './services/storage.service'
|
||||
|
||||
const {
|
||||
useMocks,
|
||||
@@ -64,9 +65,14 @@ export const APP_PROVIDERS: Provider[] = [
|
||||
provide: ApiService,
|
||||
useClass: useMocks ? MockApiService : LiveApiService,
|
||||
},
|
||||
{
|
||||
provide: PatchDB,
|
||||
deps: [PatchDbSource, PATCH_CACHE],
|
||||
useClass: PatchDB,
|
||||
},
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
deps: [AuthService, ClientStorageService, Router],
|
||||
deps: [StorageService, AuthService, ClientStorageService, Router],
|
||||
useFactory: appInitializer,
|
||||
multi: true,
|
||||
},
|
||||
@@ -86,14 +92,27 @@ export const APP_PROVIDERS: Provider[] = [
|
||||
provide: AbstractCategoryService,
|
||||
useClass: CategoryService,
|
||||
},
|
||||
{
|
||||
provide: TUI_DIALOGS_CLOSE,
|
||||
useFactory: () =>
|
||||
inject(StateService).pipe(
|
||||
pairwise(),
|
||||
filter(
|
||||
([prev, curr]) =>
|
||||
prev === 'running' && (curr === 'error' || curr === 'initializing'),
|
||||
),
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
export function appInitializer(
|
||||
storage: StorageService,
|
||||
auth: AuthService,
|
||||
localStorage: ClientStorageService,
|
||||
router: Router,
|
||||
): () => void {
|
||||
return () => {
|
||||
storage.migrate036()
|
||||
auth.init()
|
||||
localStorage.init()
|
||||
router.initialNavigation()
|
||||
|
||||
167
web/projects/ui/src/app/components/form.component.ts
Normal file
167
web/projects/ui/src/app/components/form.component.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
OnInit,
|
||||
} from '@angular/core'
|
||||
import { FormGroup, ReactiveFormsModule } from '@angular/forms'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
|
||||
import {
|
||||
tuiMarkControlAsTouchedAndValidate,
|
||||
TuiValueChangesModule,
|
||||
} from '@taiga-ui/cdk'
|
||||
import { TuiDialogContext, TuiModeModule } from '@taiga-ui/core'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { TuiDialogFormService } from '@taiga-ui/kit'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { compare, Operation } from 'fast-json-patch'
|
||||
import { FormModule } from 'src/app/components/form/form.module'
|
||||
import { InvalidService } from 'src/app/components/form/invalid.service'
|
||||
import { FormService } from 'src/app/services/form.service'
|
||||
|
||||
export interface ActionButton<T> {
|
||||
text: string
|
||||
handler?: (value: T) => Promise<boolean | void> | void
|
||||
link?: string
|
||||
}
|
||||
|
||||
export interface FormContext<T> {
|
||||
spec: CT.InputSpec
|
||||
buttons: ActionButton<T>[]
|
||||
value?: T
|
||||
patch?: Operation[]
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-form',
|
||||
template: `
|
||||
<form
|
||||
[formGroup]="form"
|
||||
(submit.capture.prevent)="(0)"
|
||||
(reset.capture.prevent.stop)="onReset()"
|
||||
(tuiValueChanges)="markAsDirty()"
|
||||
>
|
||||
<form-group [spec]="spec"></form-group>
|
||||
<footer tuiMode="onDark">
|
||||
<ng-content></ng-content>
|
||||
<ng-container *ngFor="let button of buttons; let last = last">
|
||||
<button
|
||||
*ngIf="button.handler; else link"
|
||||
tuiButton
|
||||
[appearance]="last ? 'primary' : 'flat'"
|
||||
[type]="last ? 'submit' : 'button'"
|
||||
(click)="onClick(button.handler)"
|
||||
>
|
||||
{{ button.text }}
|
||||
</button>
|
||||
<ng-template #link>
|
||||
<a
|
||||
tuiButton
|
||||
appearance="flat"
|
||||
[routerLink]="button.link"
|
||||
(click)="close()"
|
||||
>
|
||||
{{ button.text }}
|
||||
</a>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</footer>
|
||||
</form>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 1rem 0;
|
||||
margin: 1rem 0 -1rem;
|
||||
gap: 1rem;
|
||||
background: var(--tui-elevation-01);
|
||||
border-top: 1px solid var(--tui-base-02);
|
||||
}
|
||||
`,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
TuiValueChangesModule,
|
||||
TuiButtonModule,
|
||||
TuiModeModule,
|
||||
FormModule,
|
||||
],
|
||||
providers: [InvalidService],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FormComponent<T extends Record<string, any>> implements OnInit {
|
||||
private readonly dialogFormService = inject(TuiDialogFormService)
|
||||
private readonly formService = inject(FormService)
|
||||
private readonly invalidService = inject(InvalidService)
|
||||
private readonly context = inject<TuiDialogContext<void, FormContext<T>>>(
|
||||
POLYMORPHEUS_CONTEXT,
|
||||
{ optional: true },
|
||||
)
|
||||
|
||||
@Input() spec = this.context?.data.spec || {}
|
||||
@Input() buttons = this.context?.data.buttons || []
|
||||
@Input() patch = this.context?.data.patch || []
|
||||
@Input() value?: T = this.context?.data.value
|
||||
|
||||
form = new FormGroup({})
|
||||
|
||||
ngOnInit() {
|
||||
this.dialogFormService.markAsPristine()
|
||||
this.form = this.formService.createForm(this.spec, this.value)
|
||||
this.process(this.patch)
|
||||
}
|
||||
|
||||
onReset() {
|
||||
const { value } = this.form
|
||||
|
||||
this.form = this.formService.createForm(this.spec)
|
||||
this.process(compare(this.form.value, value))
|
||||
tuiMarkControlAsTouchedAndValidate(this.form)
|
||||
this.markAsDirty()
|
||||
}
|
||||
|
||||
async onClick(handler: Required<ActionButton<T>>['handler']) {
|
||||
tuiMarkControlAsTouchedAndValidate(this.form)
|
||||
this.invalidService.scrollIntoView()
|
||||
|
||||
if (this.form.valid && (await handler(this.form.value as T))) {
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
|
||||
markAsDirty() {
|
||||
this.dialogFormService.markAsDirty()
|
||||
}
|
||||
|
||||
close() {
|
||||
this.context?.$implicit.complete()
|
||||
}
|
||||
|
||||
private process(patch: Operation[]) {
|
||||
patch.forEach(({ op, path }) => {
|
||||
const control = this.form.get(path.substring(1).split('/'))
|
||||
|
||||
if (!control || !control.parent) return
|
||||
|
||||
if (op !== 'remove') {
|
||||
control.markAsDirty()
|
||||
control.markAsTouched()
|
||||
}
|
||||
|
||||
control.parent.markAsDirty()
|
||||
control.parent.markAsTouched()
|
||||
})
|
||||
}
|
||||
}
|
||||
30
web/projects/ui/src/app/components/form/control.directive.ts
Normal file
30
web/projects/ui/src/app/components/form/control.directive.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Directive, ElementRef, inject, OnDestroy, OnInit } from '@angular/core'
|
||||
import { ControlContainer, NgControl } from '@angular/forms'
|
||||
import { InvalidService } from './invalid.service'
|
||||
|
||||
@Directive({
|
||||
selector: 'form-control, form-array, form-object',
|
||||
})
|
||||
export class ControlDirective implements OnInit, OnDestroy {
|
||||
private readonly invalidService = inject(InvalidService, { optional: true })
|
||||
private readonly element: ElementRef<HTMLElement> = inject(ElementRef)
|
||||
private readonly control =
|
||||
inject(NgControl, { optional: true }) ||
|
||||
inject(ControlContainer, { optional: true })
|
||||
|
||||
get invalid(): boolean {
|
||||
return !!this.control?.invalid
|
||||
}
|
||||
|
||||
scrollIntoView() {
|
||||
this.element.nativeElement.scrollIntoView({ behavior: 'smooth' })
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.invalidService?.add(this)
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.invalidService?.remove(this)
|
||||
}
|
||||
}
|
||||
35
web/projects/ui/src/app/components/form/control.ts
Normal file
35
web/projects/ui/src/app/components/form/control.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { inject } from '@angular/core'
|
||||
import { FormControlComponent } from './form-control/form-control.component'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
|
||||
export abstract class Control<Spec extends CT.ValueSpec, Value> {
|
||||
private readonly control: FormControlComponent<Spec, Value> =
|
||||
inject(FormControlComponent)
|
||||
|
||||
get invalid(): boolean {
|
||||
return this.control.touched && this.control.invalid
|
||||
}
|
||||
|
||||
get spec(): Spec {
|
||||
return this.control.spec
|
||||
}
|
||||
|
||||
// TODO: Properly handle already set immutable value
|
||||
get readOnly(): boolean {
|
||||
return (
|
||||
!!this.value && !!this.control.control?.pristine && this.control.immutable
|
||||
)
|
||||
}
|
||||
|
||||
get value(): Value | null {
|
||||
return this.control.value
|
||||
}
|
||||
|
||||
set value(value: Value | null) {
|
||||
this.control.onInput(value)
|
||||
}
|
||||
|
||||
onFocus(focused: boolean) {
|
||||
this.control.onFocus(focused)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<div class="label">
|
||||
{{ spec.name }}
|
||||
<tui-tooltip
|
||||
*ngIf="spec.description || spec.disabled"
|
||||
[content]="spec | hint"
|
||||
></tui-tooltip>
|
||||
<button
|
||||
tuiLink
|
||||
type="button"
|
||||
class="add"
|
||||
[disabled]="!canAdd"
|
||||
(click)="add()"
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
<tui-error [error]="order | tuiFieldError | async"></tui-error>
|
||||
|
||||
<ng-container *ngFor="let item of array.control.controls; let index = index">
|
||||
<form-object
|
||||
*ngIf="spec.spec.type === 'object'; else control"
|
||||
class="object"
|
||||
[class.object_open]="!!open.get(item)"
|
||||
[formGroup]="$any(item)"
|
||||
[spec]="$any(spec.spec)"
|
||||
[@tuiHeightCollapse]="animation"
|
||||
[@tuiFadeIn]="animation"
|
||||
[open]="!!open.get(item)"
|
||||
(openChange)="open.set(item, $event)"
|
||||
>
|
||||
{{ item.value | mustache : $any(spec.spec).displayAs }}
|
||||
<ng-container *ngTemplateOutlet="remove"></ng-container>
|
||||
</form-object>
|
||||
<ng-template #control>
|
||||
<form-control
|
||||
class="control"
|
||||
tuiTextfieldSize="m"
|
||||
[tuiTextfieldLabelOutside]="true"
|
||||
[tuiTextfieldIcon]="remove"
|
||||
[formControl]="$any(item)"
|
||||
[spec]="$any(spec.spec)"
|
||||
[@tuiHeightCollapse]="animation"
|
||||
[@tuiFadeIn]="animation"
|
||||
></form-control>
|
||||
</ng-template>
|
||||
<ng-template #remove>
|
||||
<button
|
||||
tuiIconButton
|
||||
type="button"
|
||||
class="remove"
|
||||
iconLeft="tuiIconTrash"
|
||||
appearance="icon"
|
||||
size="m"
|
||||
title="Remove"
|
||||
(click.stop)="removeAt(index)"
|
||||
></button>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,50 @@
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
font-size: 1.25rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.add {
|
||||
font-size: 1rem;
|
||||
padding: 0 1rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.object {
|
||||
display: block;
|
||||
position: relative;
|
||||
|
||||
&_open::after,
|
||||
&:last-child::after {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&:after {
|
||||
@include transition(opacity);
|
||||
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -0.5rem;
|
||||
height: 1px;
|
||||
left: 3rem;
|
||||
right: 1rem;
|
||||
background: var(--tui-clear);
|
||||
}
|
||||
}
|
||||
|
||||
.remove {
|
||||
margin-left: auto;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.control {
|
||||
display: block;
|
||||
margin: 0.5rem 0;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Component, HostBinding, inject, Input } from '@angular/core'
|
||||
import { AbstractControl, FormArrayName } from '@angular/forms'
|
||||
import { TUI_PARENT_ANIMATION, TuiDestroyService } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TUI_ANIMATION_OPTIONS,
|
||||
TuiDialogService,
|
||||
tuiFadeIn,
|
||||
tuiHeightCollapse,
|
||||
} from '@taiga-ui/core'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { filter, takeUntil } from 'rxjs'
|
||||
import { FormService } from 'src/app/services/form.service'
|
||||
import { ERRORS } from '../form-group/form-group.component'
|
||||
|
||||
@Component({
|
||||
selector: 'form-array',
|
||||
templateUrl: './form-array.component.html',
|
||||
styleUrls: ['./form-array.component.scss'],
|
||||
animations: [tuiFadeIn, tuiHeightCollapse, TUI_PARENT_ANIMATION],
|
||||
providers: [TuiDestroyService],
|
||||
})
|
||||
export class FormArrayComponent {
|
||||
@Input()
|
||||
spec!: CT.ValueSpecList
|
||||
|
||||
@HostBinding('@tuiParentAnimation')
|
||||
readonly animation = { value: '', ...inject(TUI_ANIMATION_OPTIONS) }
|
||||
readonly order = ERRORS
|
||||
readonly array = inject(FormArrayName)
|
||||
readonly open = new Map<AbstractControl, boolean>()
|
||||
|
||||
private warned = false
|
||||
private readonly formService = inject(FormService)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly destroy$ = inject(TuiDestroyService)
|
||||
|
||||
get canAdd(): boolean {
|
||||
return (
|
||||
!this.spec.disabled &&
|
||||
(!this.spec.maxLength ||
|
||||
this.spec.maxLength >= this.array.control.controls.length)
|
||||
)
|
||||
}
|
||||
|
||||
add() {
|
||||
if (!this.warned && this.spec.warning) {
|
||||
this.dialogs
|
||||
.open<boolean>(TUI_PROMPT, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: { content: this.spec.warning, yes: 'Ok', no: 'Cancel' },
|
||||
})
|
||||
.pipe(filter(Boolean), takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.addItem()
|
||||
})
|
||||
} else {
|
||||
this.addItem()
|
||||
}
|
||||
|
||||
this.warned = true
|
||||
}
|
||||
|
||||
removeAt(index: number) {
|
||||
this.dialogs
|
||||
.open<boolean>(TUI_PROMPT, {
|
||||
label: 'Confirm',
|
||||
size: 's',
|
||||
data: {
|
||||
content: 'Are you sure you want to delete this entry?',
|
||||
yes: 'Delete',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean), takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.removeItem(index)
|
||||
})
|
||||
}
|
||||
|
||||
private removeItem(index: number) {
|
||||
this.open.delete(this.array.control.at(index))
|
||||
this.array.control.removeAt(index)
|
||||
}
|
||||
|
||||
private addItem() {
|
||||
this.array.control.insert(0, this.formService.getListItem(this.spec))
|
||||
this.open.set(this.array.control.at(0), true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<tui-input
|
||||
[maskito]="mask"
|
||||
[tuiTextfieldCustomContent]="color"
|
||||
[tuiTextfieldCleaner]="false"
|
||||
[tuiHintContent]="spec | hint"
|
||||
[readOnly]="readOnly"
|
||||
[disabled]="!!spec.disabled"
|
||||
[pseudoInvalid]="invalid"
|
||||
[(ngModel)]="value"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
</tui-input>
|
||||
<ng-template #color>
|
||||
<div class="wrapper" [style.color]="value">
|
||||
<input
|
||||
*ngIf="!readOnly && !spec.disabled"
|
||||
type="color"
|
||||
class="color"
|
||||
tabindex="-1"
|
||||
[(ngModel)]="value"
|
||||
(click.stop)="(0)"
|
||||
/>
|
||||
<tui-icon
|
||||
icon="tuiIconPaintLarge"
|
||||
tuiAppearance="icon"
|
||||
class="icon"
|
||||
></tui-icon>
|
||||
</div>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,33 @@
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
pointer-events: auto;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
height: 0.3rem;
|
||||
width: 1.4rem;
|
||||
bottom: 0.125rem;
|
||||
background: currentColor;
|
||||
border-radius: 0.125rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.color {
|
||||
@include fullsize();
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.icon {
|
||||
@include fullsize();
|
||||
pointer-events: none;
|
||||
|
||||
input:hover + & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
import { MaskitoOptions } from '@maskito/core'
|
||||
|
||||
@Component({
|
||||
selector: 'form-color',
|
||||
templateUrl: './form-color.component.html',
|
||||
styleUrls: ['./form-color.component.scss'],
|
||||
})
|
||||
export class FormColorComponent extends Control<CT.ValueSpecColor, string> {
|
||||
readonly mask: MaskitoOptions = {
|
||||
mask: ['#', ...Array(6).fill(/[0-9a-f]/i)],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<ng-container [ngSwitch]="spec.type">
|
||||
<form-color *ngSwitchCase="'color'"></form-color>
|
||||
<form-datetime *ngSwitchCase="'datetime'"></form-datetime>
|
||||
<form-multiselect *ngSwitchCase="'multiselect'"></form-multiselect>
|
||||
<form-number *ngSwitchCase="'number'"></form-number>
|
||||
<form-select *ngSwitchCase="'select'"></form-select>
|
||||
<form-text *ngSwitchCase="'text'"></form-text>
|
||||
<form-textarea *ngSwitchCase="'textarea'"></form-textarea>
|
||||
<form-toggle *ngSwitchCase="'toggle'"></form-toggle>
|
||||
</ng-container>
|
||||
<tui-error [error]="order | tuiFieldError | async"></tui-error>
|
||||
<ng-template
|
||||
*ngIf="spec.warning || immutable"
|
||||
#warning
|
||||
let-completeWith="completeWith"
|
||||
>
|
||||
{{ spec.warning }}
|
||||
<p *ngIf="immutable">This value cannot be changed once set!</p>
|
||||
<div class="buttons">
|
||||
<button
|
||||
tuiButton
|
||||
type="button"
|
||||
appearance="secondary"
|
||||
size="s"
|
||||
(click)="completeWith(true)"
|
||||
>
|
||||
Rollback
|
||||
</button>
|
||||
<button
|
||||
tuiButton
|
||||
type="button"
|
||||
appearance="flat"
|
||||
size="s"
|
||||
(click)="completeWith(false)"
|
||||
>
|
||||
Accept
|
||||
</button>
|
||||
</div>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,11 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 0.5rem;
|
||||
|
||||
:first-child {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
TemplateRef,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import { AbstractTuiNullableControl } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiAlertService,
|
||||
TuiDialogContext,
|
||||
TuiNotification,
|
||||
} from '@taiga-ui/core'
|
||||
import { filter, takeUntil } from 'rxjs'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { ERRORS } from '../form-group/form-group.component'
|
||||
import { FORM_CONTROL_PROVIDERS } from './form-control.providers'
|
||||
|
||||
@Component({
|
||||
selector: 'form-control',
|
||||
templateUrl: './form-control.component.html',
|
||||
styleUrls: ['./form-control.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: FORM_CONTROL_PROVIDERS,
|
||||
})
|
||||
export class FormControlComponent<
|
||||
T extends CT.ValueSpec,
|
||||
V,
|
||||
> extends AbstractTuiNullableControl<V> {
|
||||
@Input()
|
||||
spec!: T
|
||||
|
||||
@ViewChild('warning')
|
||||
warning?: TemplateRef<TuiDialogContext<boolean>>
|
||||
|
||||
warned = false
|
||||
focused = false
|
||||
readonly order = ERRORS
|
||||
private readonly alerts = inject(TuiAlertService)
|
||||
|
||||
get immutable(): boolean {
|
||||
return 'immutable' in this.spec && this.spec.immutable
|
||||
}
|
||||
|
||||
onFocus(focused: boolean) {
|
||||
this.focused = focused
|
||||
this.updateFocused(focused)
|
||||
}
|
||||
|
||||
onInput(value: V | null) {
|
||||
const previous = this.value
|
||||
|
||||
if (!this.warned && this.warning) {
|
||||
this.alerts
|
||||
.open<boolean>(this.warning, {
|
||||
label: 'Warning',
|
||||
status: TuiNotification.Warning,
|
||||
hasCloseButton: false,
|
||||
autoClose: false,
|
||||
})
|
||||
.pipe(filter(Boolean), takeUntil(this.destroy$))
|
||||
.subscribe(() => {
|
||||
this.value = previous
|
||||
})
|
||||
}
|
||||
|
||||
this.warned = true
|
||||
this.value = value === '' ? null : value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { forwardRef, Provider } from '@angular/core'
|
||||
import { TUI_VALIDATION_ERRORS } from '@taiga-ui/kit'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { FormControlComponent } from './form-control.component'
|
||||
|
||||
interface ValidatorsPatternError {
|
||||
actualValue: string
|
||||
requiredPattern: string | RegExp
|
||||
}
|
||||
|
||||
export const FORM_CONTROL_PROVIDERS: Provider[] = [
|
||||
{
|
||||
provide: TUI_VALIDATION_ERRORS,
|
||||
deps: [forwardRef(() => FormControlComponent)],
|
||||
useFactory: (control: FormControlComponent<CT.ValueSpec, string>) => ({
|
||||
required: 'Required',
|
||||
pattern: ({ requiredPattern }: ValidatorsPatternError) =>
|
||||
('patterns' in control.spec &&
|
||||
control.spec.patterns.find(
|
||||
({ regex }) => String(regex) === String(requiredPattern),
|
||||
)?.description) ||
|
||||
'Invalid format',
|
||||
}),
|
||||
},
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
<ng-container [ngSwitch]="spec.inputmode" [tuiHintContent]="spec.description">
|
||||
<tui-input-time
|
||||
*ngSwitchCase="'time'"
|
||||
[tuiHintContent]="spec | hint"
|
||||
[disabled]="!!spec.disabled"
|
||||
[readOnly]="readOnly"
|
||||
[pseudoInvalid]="invalid"
|
||||
[ngModel]="getTime(value)"
|
||||
(ngModelChange)="value = $event?.toString() || null"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
</tui-input-time>
|
||||
<tui-input-date
|
||||
*ngSwitchCase="'date'"
|
||||
[tuiHintContent]="spec | hint"
|
||||
[disabled]="!!spec.disabled"
|
||||
[readOnly]="readOnly"
|
||||
[pseudoInvalid]="invalid"
|
||||
[min]="spec.min ? (spec.min | tuiMapper : getLimit)[0] : min"
|
||||
[max]="spec.max ? (spec.max | tuiMapper : getLimit)[0] : max"
|
||||
[(ngModel)]="value"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
</tui-input-date>
|
||||
<tui-input-date-time
|
||||
*ngSwitchCase="'datetime-local'"
|
||||
[tuiHintContent]="spec | hint"
|
||||
[disabled]="!!spec.disabled"
|
||||
[readOnly]="readOnly"
|
||||
[pseudoInvalid]="invalid"
|
||||
[min]="spec.min ? (spec.min | tuiMapper : getLimit) : min"
|
||||
[max]="spec.max ? (spec.max | tuiMapper : getLimit) : max"
|
||||
[(ngModel)]="value"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
</tui-input-date-time>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Component } from '@angular/core'
|
||||
import {
|
||||
TUI_FIRST_DAY,
|
||||
TUI_LAST_DAY,
|
||||
TuiDay,
|
||||
tuiPure,
|
||||
TuiTime,
|
||||
} from '@taiga-ui/cdk'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
selector: 'form-datetime',
|
||||
templateUrl: './form-datetime.component.html',
|
||||
})
|
||||
export class FormDatetimeComponent extends Control<
|
||||
CT.ValueSpecDatetime,
|
||||
string
|
||||
> {
|
||||
readonly min = TUI_FIRST_DAY
|
||||
readonly max = TUI_LAST_DAY
|
||||
|
||||
@tuiPure
|
||||
getTime(value: string | null) {
|
||||
return value ? TuiTime.fromString(value) : null
|
||||
}
|
||||
|
||||
getLimit(limit: string): [TuiDay, TuiTime] {
|
||||
return [
|
||||
TuiDay.jsonParse(limit.slice(0, 10)),
|
||||
limit.length === 10
|
||||
? new TuiTime(0, 0)
|
||||
: TuiTime.fromString(limit.slice(-5)),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<ng-container
|
||||
*ngFor="let entry of spec | keyvalue : asIsOrder"
|
||||
tuiMode="onDark"
|
||||
[ngSwitch]="entry.value.type"
|
||||
[tuiTextfieldCleaner]="true"
|
||||
>
|
||||
<form-object
|
||||
*ngSwitchCase="'object'"
|
||||
class="g-form-control"
|
||||
[formGroupName]="entry.key"
|
||||
[spec]="$any(entry.value)"
|
||||
></form-object>
|
||||
<form-union
|
||||
*ngSwitchCase="'union'"
|
||||
class="g-form-control"
|
||||
[formGroupName]="entry.key"
|
||||
[spec]="$any(entry.value)"
|
||||
></form-union>
|
||||
<form-array
|
||||
*ngSwitchCase="'list'"
|
||||
[formArrayName]="entry.key"
|
||||
[spec]="$any(entry.value)"
|
||||
></form-array>
|
||||
<form-control
|
||||
*ngSwitchDefault
|
||||
class="g-form-control"
|
||||
[formControlName]="entry.key"
|
||||
[spec]="entry.value"
|
||||
></form-control>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,35 @@
|
||||
form-group .g-form-control:not(:first-child) {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
form-group .g-form-group {
|
||||
position: relative;
|
||||
padding-left: var(--tui-height-m);
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: var(--tui-clear);
|
||||
}
|
||||
|
||||
&::before {
|
||||
top: 0;
|
||||
left: calc(1rem - 1px);
|
||||
bottom: 0.5rem;
|
||||
width: 2px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
left: 0.75rem;
|
||||
bottom: 0;
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
form-group tui-tooltip {
|
||||
z-index: 1;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Input,
|
||||
ViewEncapsulation,
|
||||
} from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { FORM_GROUP_PROVIDERS } from './form-group.providers'
|
||||
|
||||
export const ERRORS = [
|
||||
'required',
|
||||
'pattern',
|
||||
'notNumber',
|
||||
'numberNotInteger',
|
||||
'numberNotInRange',
|
||||
'listNotUnique',
|
||||
'listNotInRange',
|
||||
'listItemIssue',
|
||||
]
|
||||
|
||||
@Component({
|
||||
selector: 'form-group',
|
||||
templateUrl: './form-group.component.html',
|
||||
styleUrls: ['./form-group.component.scss'],
|
||||
encapsulation: ViewEncapsulation.None,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
viewProviders: [FORM_GROUP_PROVIDERS],
|
||||
})
|
||||
export class FormGroupComponent {
|
||||
@Input() spec: CT.InputSpec = {}
|
||||
|
||||
asIsOrder() {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Provider, SkipSelf } from '@angular/core'
|
||||
import {
|
||||
TUI_ARROW_MODE,
|
||||
tuiInputDateOptionsProvider,
|
||||
tuiInputTimeOptionsProvider,
|
||||
} from '@taiga-ui/kit'
|
||||
import { TUI_DEFAULT_ERROR_MESSAGE } from '@taiga-ui/core'
|
||||
import { ControlContainer } from '@angular/forms'
|
||||
import { identity, of } from 'rxjs'
|
||||
|
||||
export const FORM_GROUP_PROVIDERS: Provider[] = [
|
||||
{
|
||||
provide: TUI_DEFAULT_ERROR_MESSAGE,
|
||||
useValue: of('Unknown error'),
|
||||
},
|
||||
{
|
||||
provide: ControlContainer,
|
||||
deps: [[new SkipSelf(), ControlContainer]],
|
||||
useFactory: identity,
|
||||
},
|
||||
{
|
||||
provide: TUI_ARROW_MODE,
|
||||
useValue: {
|
||||
interactive: null,
|
||||
disabled: null,
|
||||
},
|
||||
},
|
||||
tuiInputDateOptionsProvider({
|
||||
nativePicker: true,
|
||||
}),
|
||||
tuiInputTimeOptionsProvider({
|
||||
nativePicker: true,
|
||||
}),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
<tui-multi-select
|
||||
[tuiHintContent]="spec | hint"
|
||||
[disabled]="disabled"
|
||||
[readOnly]="readOnly"
|
||||
[pseudoInvalid]="invalid"
|
||||
[editable]="false"
|
||||
[disabledItemHandler]="disabledItemHandler"
|
||||
[(ngModel)]="selected"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<select
|
||||
tuiSelect
|
||||
multiple
|
||||
[items]="items"
|
||||
[disabledItemHandler]="disabledItemHandler"
|
||||
></select>
|
||||
</tui-multi-select>
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
import { tuiPure } from '@taiga-ui/cdk'
|
||||
import { invert } from '@start9labs/shared'
|
||||
|
||||
@Component({
|
||||
selector: 'form-multiselect',
|
||||
templateUrl: './form-multiselect.component.html',
|
||||
})
|
||||
export class FormMultiselectComponent extends Control<
|
||||
CT.ValueSpecMultiselect,
|
||||
readonly string[]
|
||||
> {
|
||||
private readonly inverted = invert(this.spec.values)
|
||||
|
||||
private readonly isDisabled = (item: string) =>
|
||||
Array.isArray(this.spec.disabled) &&
|
||||
this.spec.disabled.includes(this.inverted[item])
|
||||
|
||||
private readonly isExceedingLimit = (item: string) =>
|
||||
!!this.spec.maxLength &&
|
||||
this.selected.length >= this.spec.maxLength &&
|
||||
!this.selected.includes(item)
|
||||
|
||||
readonly disabledItemHandler = (item: string): boolean =>
|
||||
this.isDisabled(item) || this.isExceedingLimit(item)
|
||||
|
||||
readonly items = Object.values(this.spec.values)
|
||||
|
||||
get disabled(): boolean {
|
||||
return typeof this.spec.disabled === 'string'
|
||||
}
|
||||
|
||||
get selected(): string[] {
|
||||
return this.memoize(this.value)
|
||||
}
|
||||
|
||||
set selected(value: string[]) {
|
||||
this.value = Object.entries(this.spec.values)
|
||||
.filter(([_, v]) => value.includes(v))
|
||||
.map(([k]) => k)
|
||||
}
|
||||
|
||||
@tuiPure
|
||||
private memoize(value: null | readonly string[]): string[] {
|
||||
return value?.map(key => this.spec.values[key]) || []
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<tui-input-number
|
||||
[tuiHintContent]="spec | hint"
|
||||
[disabled]="!!spec.disabled"
|
||||
[readOnly]="readOnly"
|
||||
[tuiTextfieldPostfix]="spec.units || ''"
|
||||
[pseudoInvalid]="invalid"
|
||||
[precision]="Infinity"
|
||||
[decimal]="spec.integer ? 'never' : 'not-zero'"
|
||||
[min]="spec.min ?? -Infinity"
|
||||
[max]="spec.max ?? Infinity"
|
||||
[step]="spec.step || 0"
|
||||
[(ngModel)]="value"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
<input tuiTextfield [placeholder]="spec.placeholder || ''" />
|
||||
</tui-input-number>
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
selector: 'form-number',
|
||||
templateUrl: './form-number.component.html',
|
||||
})
|
||||
export class FormNumberComponent extends Control<CT.ValueSpecNumber, number> {
|
||||
protected readonly Infinity = Infinity
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<h3 class="title" (click)="toggle()">
|
||||
<button
|
||||
tuiIconButton
|
||||
size="s"
|
||||
iconLeft="tuiIconChevronDown"
|
||||
type="button"
|
||||
class="button"
|
||||
[class.button_open]="open"
|
||||
[style.border-radius.%]="100"
|
||||
[appearance]="invalid ? 'destructive' : 'secondary'"
|
||||
></button>
|
||||
<ng-content></ng-content>
|
||||
{{ spec.name }}
|
||||
<tui-tooltip
|
||||
*ngIf="spec.description"
|
||||
[content]="spec.description"
|
||||
(click.stop)="(0)"
|
||||
></tui-tooltip>
|
||||
</h3>
|
||||
|
||||
<tui-expand class="expand" [expanded]="open">
|
||||
<div class="g-form-group" [class.g-form-group_invalid]="invalid">
|
||||
<form-group [spec]="spec.spec"></form-group>
|
||||
</div>
|
||||
</tui-expand>
|
||||
@@ -0,0 +1,41 @@
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.title {
|
||||
position: relative;
|
||||
height: var(--tui-height-l);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
font: var(--tui-font-text-l);
|
||||
font-weight: bold;
|
||||
margin: 0 0 -0.75rem;
|
||||
}
|
||||
|
||||
.button {
|
||||
@include transition(transform);
|
||||
|
||||
margin-right: 1rem;
|
||||
|
||||
&_open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.expand {
|
||||
align-self: stretch;
|
||||
}
|
||||
|
||||
.g-form-group {
|
||||
padding-top: 0.75rem;
|
||||
|
||||
&_invalid::before,
|
||||
&_invalid::after {
|
||||
background: var(--tui-error-bg);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
inject,
|
||||
Input,
|
||||
Output,
|
||||
} from '@angular/core'
|
||||
import { ControlContainer } from '@angular/forms'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
|
||||
@Component({
|
||||
selector: 'form-object',
|
||||
templateUrl: './form-object.component.html',
|
||||
styleUrls: ['./form-object.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FormObjectComponent {
|
||||
@Input()
|
||||
spec!: CT.ValueSpecObject
|
||||
|
||||
@Input()
|
||||
open = false
|
||||
|
||||
@Output()
|
||||
readonly openChange = new EventEmitter<boolean>()
|
||||
|
||||
private readonly container = inject(ControlContainer)
|
||||
|
||||
get invalid() {
|
||||
return !this.container.valid && this.container.touched
|
||||
}
|
||||
|
||||
toggle() {
|
||||
this.open = !this.open
|
||||
this.openChange.emit(this.open)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<tui-select
|
||||
[tuiHintContent]="spec | hint"
|
||||
[disabled]="disabled"
|
||||
[readOnly]="readOnly"
|
||||
[tuiTextfieldCleaner]="!spec.required"
|
||||
[pseudoInvalid]="invalid"
|
||||
[(ngModel)]="selected"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
<select
|
||||
tuiSelect
|
||||
[placeholder]="spec.name"
|
||||
[items]="items"
|
||||
[disabledItemHandler]="disabledItemHandler"
|
||||
></select>
|
||||
</tui-select>
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { invert } from '@start9labs/shared'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
selector: 'form-select',
|
||||
templateUrl: './form-select.component.html',
|
||||
})
|
||||
export class FormSelectComponent extends Control<CT.ValueSpecSelect, string> {
|
||||
private readonly inverted = invert(this.spec.values)
|
||||
|
||||
readonly items = Object.values(this.spec.values)
|
||||
|
||||
readonly disabledItemHandler = (item: string) =>
|
||||
Array.isArray(this.spec.disabled) &&
|
||||
this.spec.disabled.includes(this.inverted[item])
|
||||
|
||||
get disabled(): boolean {
|
||||
return typeof this.spec.disabled === 'string'
|
||||
}
|
||||
|
||||
get selected(): string | null {
|
||||
return (this.value && this.spec.values[this.value]) || null
|
||||
}
|
||||
|
||||
set selected(value: string | null) {
|
||||
this.value = (value && this.inverted[value]) || null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<tui-input
|
||||
[tuiTextfieldCustomContent]="spec.masked || spec.generate ? toggle : ''"
|
||||
[tuiHintContent]="spec | hint"
|
||||
[disabled]="!!spec.disabled"
|
||||
[readOnly]="readOnly"
|
||||
[pseudoInvalid]="invalid"
|
||||
[(ngModel)]="value"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
<input
|
||||
tuiTextfield
|
||||
[class.masked]="spec.masked && masked"
|
||||
[placeholder]="spec.placeholder || ''"
|
||||
[attr.minLength]="spec.minLength"
|
||||
[attr.maxLength]="spec.maxLength"
|
||||
[attr.inputmode]="spec.inputmode"
|
||||
/>
|
||||
</tui-input>
|
||||
<ng-template #toggle>
|
||||
<button
|
||||
*ngIf="spec.generate"
|
||||
tuiIconButton
|
||||
type="button"
|
||||
appearance="icon"
|
||||
title="Generate"
|
||||
size="xs"
|
||||
class="button"
|
||||
iconLeft="tuiIconRefreshCcw"
|
||||
(click)="generate()"
|
||||
></button>
|
||||
<button
|
||||
*ngIf="spec.masked"
|
||||
tuiIconButton
|
||||
type="button"
|
||||
appearance="icon"
|
||||
title="Toggle masking"
|
||||
size="xs"
|
||||
class="button"
|
||||
[iconLeft]="masked ? 'tuiIconEye' : 'tuiIconEyeOff'"
|
||||
(click)="masked = !masked"
|
||||
></button>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,8 @@
|
||||
.button {
|
||||
pointer-events: auto;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.masked {
|
||||
-webkit-text-security: disc;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT, utils } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
selector: 'form-text',
|
||||
templateUrl: './form-text.component.html',
|
||||
styleUrls: ['./form-text.component.scss'],
|
||||
})
|
||||
export class FormTextComponent extends Control<CT.ValueSpecText, string> {
|
||||
masked = true
|
||||
|
||||
generate() {
|
||||
this.value = utils.getDefaultString(this.spec.generate || '')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<tui-textarea
|
||||
[tuiHintContent]="spec | hint"
|
||||
[disabled]="!!spec.disabled"
|
||||
[readOnly]="readOnly"
|
||||
[pseudoInvalid]="invalid"
|
||||
[expandable]="true"
|
||||
[rows]="6"
|
||||
[maxLength]="spec.maxLength"
|
||||
[(ngModel)]="value"
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
<textarea tuiTextfield [placeholder]="spec.placeholder || ''"></textarea>
|
||||
</tui-textarea>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
selector: 'form-textarea',
|
||||
templateUrl: './form-textarea.component.html',
|
||||
})
|
||||
export class FormTextareaComponent extends Control<
|
||||
CT.ValueSpecTextarea,
|
||||
string
|
||||
> {}
|
||||
@@ -0,0 +1,11 @@
|
||||
{{ spec.name }}
|
||||
<tui-tooltip
|
||||
*ngIf="spec.description || spec.disabled"
|
||||
[tuiHintContent]="spec | hint"
|
||||
></tui-tooltip>
|
||||
<tui-toggle
|
||||
size="l"
|
||||
[disabled]="!!spec.disabled || readOnly"
|
||||
[(ngModel)]="value"
|
||||
(focusedChange)="onFocus($event)"
|
||||
></tui-toggle>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { Control } from '../control'
|
||||
|
||||
@Component({
|
||||
selector: 'form-toggle',
|
||||
templateUrl: './form-toggle.component.html',
|
||||
host: { class: 'g-toggle' },
|
||||
})
|
||||
export class FormToggleComponent extends Control<CT.ValueSpecToggle, boolean> {}
|
||||
@@ -0,0 +1,11 @@
|
||||
<form-control
|
||||
[spec]="selectSpec"
|
||||
formControlName="selection"
|
||||
(tuiValueChanges)="onUnion($event)"
|
||||
></form-control>
|
||||
<tui-elastic-container class="g-form-group" formGroupName="value">
|
||||
<form-group
|
||||
class="group"
|
||||
[spec]="(union && spec.variants[union].spec) || {}"
|
||||
></form-group>
|
||||
</tui-elastic-container>
|
||||
@@ -0,0 +1,8 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
OnChanges,
|
||||
} from '@angular/core'
|
||||
import { ControlContainer, FormGroupName } from '@angular/forms'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
import { FormService } from 'src/app/services/form.service'
|
||||
import { tuiPure } from '@taiga-ui/cdk'
|
||||
|
||||
@Component({
|
||||
selector: 'form-union',
|
||||
templateUrl: './form-union.component.html',
|
||||
styleUrls: ['./form-union.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
viewProviders: [
|
||||
{
|
||||
provide: ControlContainer,
|
||||
useExisting: FormGroupName,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class FormUnionComponent implements OnChanges {
|
||||
@Input()
|
||||
spec!: CT.ValueSpecUnion
|
||||
|
||||
selectSpec!: CT.ValueSpecSelect
|
||||
|
||||
private readonly form = inject(FormGroupName)
|
||||
private readonly formService = inject(FormService)
|
||||
|
||||
get union(): string {
|
||||
return this.form.value.selection
|
||||
}
|
||||
|
||||
@tuiPure
|
||||
onUnion(union: string) {
|
||||
this.form.control.setControl(
|
||||
'value',
|
||||
this.formService.getFormGroup(
|
||||
union ? this.spec.variants[union].spec : {},
|
||||
),
|
||||
{
|
||||
emitEvent: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ngOnChanges() {
|
||||
this.selectSpec = this.formService.getUnionSelectSpec(this.spec, this.union)
|
||||
if (this.union) this.onUnion(this.union)
|
||||
}
|
||||
}
|
||||
107
web/projects/ui/src/app/components/form/form.module.ts
Normal file
107
web/projects/ui/src/app/components/form/form.module.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { MaskitoModule } from '@maskito/angular'
|
||||
import { TuiMapperPipeModule, TuiValueChangesModule } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiErrorModule,
|
||||
TuiExpandModule,
|
||||
TuiHintModule,
|
||||
TuiLinkModule,
|
||||
TuiModeModule,
|
||||
TuiTextfieldControllerModule,
|
||||
TuiTooltipModule,
|
||||
} from '@taiga-ui/core'
|
||||
import {
|
||||
TuiAppearanceModule,
|
||||
TuiButtonModule,
|
||||
TuiIconModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import {
|
||||
TuiElasticContainerModule,
|
||||
TuiFieldErrorPipeModule,
|
||||
TuiInputDateModule,
|
||||
TuiInputDateTimeModule,
|
||||
TuiInputFilesModule,
|
||||
TuiInputModule,
|
||||
TuiInputNumberModule,
|
||||
TuiInputTimeModule,
|
||||
TuiMultiSelectModule,
|
||||
TuiPromptModule,
|
||||
TuiSelectModule,
|
||||
TuiTagModule,
|
||||
TuiTextareaModule,
|
||||
TuiToggleModule,
|
||||
} from '@taiga-ui/kit'
|
||||
|
||||
import { FormGroupComponent } from './form-group/form-group.component'
|
||||
import { FormTextComponent } from './form-text/form-text.component'
|
||||
import { FormToggleComponent } from './form-toggle/form-toggle.component'
|
||||
import { FormTextareaComponent } from './form-textarea/form-textarea.component'
|
||||
import { FormNumberComponent } from './form-number/form-number.component'
|
||||
import { FormSelectComponent } from './form-select/form-select.component'
|
||||
import { FormMultiselectComponent } from './form-multiselect/form-multiselect.component'
|
||||
import { FormUnionComponent } from './form-union/form-union.component'
|
||||
import { FormObjectComponent } from './form-object/form-object.component'
|
||||
import { FormArrayComponent } from './form-array/form-array.component'
|
||||
import { FormControlComponent } from './form-control/form-control.component'
|
||||
import { MustachePipe } from './mustache.pipe'
|
||||
import { ControlDirective } from './control.directive'
|
||||
import { FormColorComponent } from './form-color/form-color.component'
|
||||
import { FormDatetimeComponent } from './form-datetime/form-datetime.component'
|
||||
import { HintPipe } from './hint.pipe'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
TuiInputModule,
|
||||
TuiInputNumberModule,
|
||||
TuiInputFilesModule,
|
||||
TuiTextareaModule,
|
||||
TuiSelectModule,
|
||||
TuiMultiSelectModule,
|
||||
TuiToggleModule,
|
||||
TuiTooltipModule,
|
||||
TuiHintModule,
|
||||
TuiModeModule,
|
||||
TuiTagModule,
|
||||
TuiButtonModule,
|
||||
TuiExpandModule,
|
||||
TuiTextfieldControllerModule,
|
||||
TuiLinkModule,
|
||||
TuiPromptModule,
|
||||
TuiErrorModule,
|
||||
TuiFieldErrorPipeModule,
|
||||
TuiValueChangesModule,
|
||||
TuiElasticContainerModule,
|
||||
MaskitoModule,
|
||||
TuiIconModule,
|
||||
TuiAppearanceModule,
|
||||
TuiInputDateModule,
|
||||
TuiInputTimeModule,
|
||||
TuiInputDateTimeModule,
|
||||
TuiMapperPipeModule,
|
||||
],
|
||||
declarations: [
|
||||
FormGroupComponent,
|
||||
FormControlComponent,
|
||||
FormColorComponent,
|
||||
FormDatetimeComponent,
|
||||
FormTextComponent,
|
||||
FormToggleComponent,
|
||||
FormTextareaComponent,
|
||||
FormNumberComponent,
|
||||
FormSelectComponent,
|
||||
FormMultiselectComponent,
|
||||
FormUnionComponent,
|
||||
FormObjectComponent,
|
||||
FormArrayComponent,
|
||||
MustachePipe,
|
||||
HintPipe,
|
||||
ControlDirective,
|
||||
],
|
||||
exports: [FormGroupComponent],
|
||||
})
|
||||
export class FormModule {}
|
||||
21
web/projects/ui/src/app/components/form/hint.pipe.ts
Normal file
21
web/projects/ui/src/app/components/form/hint.pipe.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { CT } from '@start9labs/start-sdk'
|
||||
|
||||
@Pipe({
|
||||
name: 'hint',
|
||||
})
|
||||
export class HintPipe implements PipeTransform {
|
||||
transform(spec: CT.ValueSpec): string {
|
||||
const hint = []
|
||||
|
||||
if (spec.description) {
|
||||
hint.push(spec.description)
|
||||
}
|
||||
|
||||
if ('disabled' in spec && typeof spec.disabled === 'string') {
|
||||
hint.push(`Disabled: ${spec.disabled}`)
|
||||
}
|
||||
|
||||
return hint.join('\n\n')
|
||||
}
|
||||
}
|
||||
19
web/projects/ui/src/app/components/form/invalid.service.ts
Normal file
19
web/projects/ui/src/app/components/form/invalid.service.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ControlDirective } from './control.directive'
|
||||
|
||||
@Injectable()
|
||||
export class InvalidService {
|
||||
private readonly controls: ControlDirective[] = []
|
||||
|
||||
scrollIntoView() {
|
||||
this.controls.find(({ invalid }) => invalid)?.scrollIntoView()
|
||||
}
|
||||
|
||||
add(control: ControlDirective) {
|
||||
this.controls.push(control)
|
||||
}
|
||||
|
||||
remove(control: ControlDirective) {
|
||||
this.controls.splice(this.controls.indexOf(control), 1)
|
||||
}
|
||||
}
|
||||
12
web/projects/ui/src/app/components/form/mustache.pipe.ts
Normal file
12
web/projects/ui/src/app/components/form/mustache.pipe.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
|
||||
const Mustache = require('mustache')
|
||||
|
||||
@Pipe({
|
||||
name: 'mustache',
|
||||
})
|
||||
export class MustachePipe implements PipeTransform {
|
||||
transform(value: any, displayAs: string): string {
|
||||
return displayAs && Mustache.render(displayAs, value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { BackupServerSelectModal } from './backup-server-select.page'
|
||||
import { AppRecoverSelectPageModule } from 'src/app/modals/app-recover-select/app-recover-select.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [BackupServerSelectModal],
|
||||
imports: [CommonModule, FormsModule, IonicModule, AppRecoverSelectPageModule],
|
||||
exports: [BackupServerSelectModal],
|
||||
})
|
||||
export class BackupServerSelectModule {}
|
||||
@@ -0,0 +1,35 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Select Server Backup</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<ion-item-group>
|
||||
<ion-item
|
||||
*ngFor="let server of target.entry.startOs | keyvalue"
|
||||
button
|
||||
(click)="presentModalPassword(server.key, server.value)"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>
|
||||
<b>Local Hostname</b>
|
||||
: {{ server.value.hostname }}.local
|
||||
</h2>
|
||||
<h2>
|
||||
<b>StartOS Version</b>
|
||||
: {{ server.value.version }}
|
||||
</h2>
|
||||
<h2>
|
||||
<b>Created</b>
|
||||
: {{ server.value.timestamp | date : 'medium' }}
|
||||
</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController, NavController } from '@ionic/angular'
|
||||
import * as argon2 from '@start9labs/argon2'
|
||||
import {
|
||||
ErrorService,
|
||||
LoadingService,
|
||||
StartOSDiskInfo,
|
||||
} from '@start9labs/shared'
|
||||
import {
|
||||
PasswordPromptComponent,
|
||||
PromptOptions,
|
||||
} from 'src/app/modals/password-prompt.component'
|
||||
import {
|
||||
BackupInfo,
|
||||
CifsBackupTarget,
|
||||
DiskBackupTarget,
|
||||
} from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { MappedBackupTarget } from 'src/app/types/mapped-backup-target'
|
||||
import { AppRecoverSelectPage } from '../app-recover-select/app-recover-select.page'
|
||||
|
||||
@Component({
|
||||
selector: 'backup-server-select',
|
||||
templateUrl: 'backup-server-select.page.html',
|
||||
styleUrls: ['backup-server-select.page.scss'],
|
||||
})
|
||||
export class BackupServerSelectModal {
|
||||
@Input() target!: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly api: ApiService,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly errorService: ErrorService,
|
||||
) {}
|
||||
|
||||
dismiss() {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
|
||||
async presentModalPassword(
|
||||
serverId: string,
|
||||
{ passwordHash }: StartOSDiskInfo,
|
||||
): Promise<void> {
|
||||
const options: PromptOptions = {
|
||||
title: 'Password Required',
|
||||
message:
|
||||
'Enter the password that was used to encrypt this backup. On the next screen, you will select the individual services you want to restore.',
|
||||
label: 'Decrypt Backup',
|
||||
placeholder: 'Enter password',
|
||||
buttonText: 'Next',
|
||||
}
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: PasswordPromptComponent,
|
||||
componentProps: { options },
|
||||
canDismiss: async password => {
|
||||
if (password === null) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
argon2.verify(passwordHash!, password)
|
||||
await this.restoreFromBackup(serverId, password)
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
return false
|
||||
}
|
||||
},
|
||||
})
|
||||
modal.present()
|
||||
}
|
||||
|
||||
private async restoreFromBackup(
|
||||
serverId: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
const loader = this.loader.open('Decrypting drive...').subscribe()
|
||||
|
||||
try {
|
||||
const backupInfo = await this.api.getBackupInfo({
|
||||
targetId: this.target.id,
|
||||
serverId,
|
||||
password,
|
||||
})
|
||||
this.presentModalSelect(serverId, backupInfo, password)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async presentModalSelect(
|
||||
serverId: string,
|
||||
backupInfo: BackupInfo,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
const modal = await this.modalCtrl.create({
|
||||
componentProps: {
|
||||
targetId: this.target.id,
|
||||
serverId,
|
||||
backupInfo,
|
||||
password,
|
||||
},
|
||||
presentingElement: await this.modalCtrl.getTop(),
|
||||
component: AppRecoverSelectPage,
|
||||
})
|
||||
|
||||
modal.onDidDismiss().then(res => {
|
||||
if (res.role === 'success') {
|
||||
this.modalCtrl.dismiss(undefined, 'success')
|
||||
this.navCtrl.navigateRoot('/services')
|
||||
}
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
}
|
||||
104
web/projects/ui/src/app/modals/config-dep.component.ts
Normal file
104
web/projects/ui/src/app/modals/config-dep.component.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Input,
|
||||
OnChanges,
|
||||
} from '@angular/core'
|
||||
import { compare, getValueByPointer, Operation } from 'fast-json-patch'
|
||||
import { isObject } from '@start9labs/shared'
|
||||
import { tuiIsNumber } from '@taiga-ui/cdk'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { TuiNotificationModule } from '@taiga-ui/core'
|
||||
|
||||
@Component({
|
||||
selector: 'config-dep',
|
||||
template: `
|
||||
<tui-notification>
|
||||
<h3 style="margin: 0 0 0.5rem; font-size: 1.25rem;">
|
||||
{{ package }}
|
||||
</h3>
|
||||
The following modifications have been made to {{ package }} to satisfy
|
||||
{{ dep }}:
|
||||
<ul>
|
||||
<li *ngFor="let d of diff" [innerHTML]="d"></li>
|
||||
</ul>
|
||||
To accept these modifications, click "Save".
|
||||
</tui-notification>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [CommonModule, TuiNotificationModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ConfigDepComponent implements OnChanges {
|
||||
@Input()
|
||||
package = ''
|
||||
|
||||
@Input()
|
||||
dep = ''
|
||||
|
||||
@Input()
|
||||
original: object = {}
|
||||
|
||||
@Input()
|
||||
value: object = {}
|
||||
|
||||
diff: string[] = []
|
||||
|
||||
ngOnChanges() {
|
||||
this.diff = compare(this.original, this.value).map(
|
||||
op => `${this.getPath(op)}: ${this.getMessage(op)}`,
|
||||
)
|
||||
}
|
||||
|
||||
private getPath(operation: Operation): string {
|
||||
const path = operation.path
|
||||
.substring(1)
|
||||
.split('/')
|
||||
.map(node => {
|
||||
const num = Number(node)
|
||||
return isNaN(num) ? node : num
|
||||
})
|
||||
|
||||
if (tuiIsNumber(path[path.length - 1])) {
|
||||
path.pop()
|
||||
}
|
||||
|
||||
return path.join(' → ')
|
||||
}
|
||||
|
||||
private getMessage(operation: Operation): string {
|
||||
switch (operation.op) {
|
||||
case 'add':
|
||||
return `Added ${this.getNewValue(operation.value)}`
|
||||
case 'remove':
|
||||
return `Removed ${this.getOldValue(operation.path)}`
|
||||
case 'replace':
|
||||
return `Changed from ${this.getOldValue(
|
||||
operation.path,
|
||||
)} to ${this.getNewValue(operation.value)}`
|
||||
default:
|
||||
return `Unknown operation`
|
||||
}
|
||||
}
|
||||
|
||||
private getOldValue(path: any): string {
|
||||
const val = getValueByPointer(this.original, path)
|
||||
if (['string', 'number', 'boolean'].includes(typeof val)) {
|
||||
return val
|
||||
} else if (isObject(val)) {
|
||||
return 'entry'
|
||||
} else {
|
||||
return 'list'
|
||||
}
|
||||
}
|
||||
|
||||
private getNewValue(val: any): string {
|
||||
if (['string', 'number', 'boolean'].includes(typeof val)) {
|
||||
return val
|
||||
} else if (isObject(val)) {
|
||||
return 'new entry'
|
||||
} else {
|
||||
return 'new list'
|
||||
}
|
||||
}
|
||||
}
|
||||
261
web/projects/ui/src/app/modals/config.component.ts
Normal file
261
web/projects/ui/src/app/modals/config.component.ts
Normal file
@@ -0,0 +1,261 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Component, Inject, ViewChild } from '@angular/core'
|
||||
import {
|
||||
ErrorService,
|
||||
getErrorMessage,
|
||||
isEmptyObject,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { CT, T } from '@start9labs/start-sdk'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import {
|
||||
TuiDialogContext,
|
||||
TuiDialogService,
|
||||
TuiLoaderModule,
|
||||
TuiModeModule,
|
||||
TuiNotificationModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { compare, Operation } from 'fast-json-patch'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { endWith, firstValueFrom, Subscription } from 'rxjs'
|
||||
import { ActionButton, FormComponent } from 'src/app/components/form.component'
|
||||
import { InvalidService } from 'src/app/components/form/invalid.service'
|
||||
import { ConfigDepComponent } from 'src/app/modals/config-dep.component'
|
||||
import { UiPipeModule } from 'src/app/pipes/ui/ui.module'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import {
|
||||
DataModel,
|
||||
PackageDataEntry,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import {
|
||||
getAllPackages,
|
||||
getManifest,
|
||||
getPackage,
|
||||
} from 'src/app/util/get-package-data'
|
||||
import { hasCurrentDeps } from 'src/app/util/has-deps'
|
||||
import { Breakages } from 'src/app/services/api/api.types'
|
||||
import { DependentInfo } from 'src/app/types/dependent-info'
|
||||
|
||||
export interface PackageConfigData {
|
||||
readonly pkgId: string
|
||||
readonly dependentInfo?: DependentInfo
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<tui-loader
|
||||
*ngIf="loadingText"
|
||||
size="l"
|
||||
[textContent]="loadingText"
|
||||
></tui-loader>
|
||||
|
||||
<tui-notification
|
||||
*ngIf="!loadingText && (loadingError || !pkg)"
|
||||
status="error"
|
||||
>
|
||||
<div [innerHTML]="loadingError"></div>
|
||||
</tui-notification>
|
||||
|
||||
<ng-container
|
||||
*ngIf="
|
||||
!loadingText && !loadingError && pkg && (pkg | toManifest) as manifest
|
||||
"
|
||||
>
|
||||
<tui-notification *ngIf="success" status="success">
|
||||
{{ manifest.title }} has been automatically configured with recommended
|
||||
defaults. Make whatever changes you want, then click "Save".
|
||||
</tui-notification>
|
||||
|
||||
<config-dep
|
||||
*ngIf="dependentInfo && value && original"
|
||||
[package]="manifest.title"
|
||||
[dep]="dependentInfo.title"
|
||||
[original]="original"
|
||||
[value]="value"
|
||||
></config-dep>
|
||||
|
||||
<tui-notification *ngIf="!manifest.hasConfig" status="warning">
|
||||
No config options for {{ manifest.title }} {{ manifest.version }}.
|
||||
</tui-notification>
|
||||
|
||||
<app-form
|
||||
tuiMode="onDark"
|
||||
[spec]="spec"
|
||||
[value]="value || {}"
|
||||
[buttons]="buttons"
|
||||
[patch]="patch"
|
||||
>
|
||||
<button
|
||||
tuiButton
|
||||
appearance="flat"
|
||||
type="reset"
|
||||
[style.margin-right]="'auto'"
|
||||
>
|
||||
Reset Defaults
|
||||
</button>
|
||||
</app-form>
|
||||
</ng-container>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
tui-notification {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormComponent,
|
||||
TuiLoaderModule,
|
||||
TuiNotificationModule,
|
||||
TuiButtonModule,
|
||||
TuiModeModule,
|
||||
ConfigDepComponent,
|
||||
UiPipeModule,
|
||||
],
|
||||
providers: [InvalidService],
|
||||
})
|
||||
export class ConfigModal {
|
||||
@ViewChild(FormComponent)
|
||||
private readonly form?: FormComponent<Record<string, any>>
|
||||
|
||||
readonly pkgId = this.context.data.pkgId
|
||||
readonly dependentInfo = this.context.data.dependentInfo
|
||||
|
||||
loadingError = ''
|
||||
loadingText = this.dependentInfo
|
||||
? `Setting properties to accommodate ${this.dependentInfo.title}`
|
||||
: 'Loading Config'
|
||||
|
||||
pkg?: PackageDataEntry
|
||||
spec: CT.InputSpec = {}
|
||||
patch: Operation[] = []
|
||||
buttons: ActionButton<any>[] = [
|
||||
{
|
||||
text: 'Save',
|
||||
handler: value => this.save(value),
|
||||
},
|
||||
]
|
||||
|
||||
original: object | null = null
|
||||
value: object | null = null
|
||||
|
||||
constructor(
|
||||
@Inject(POLYMORPHEUS_CONTEXT)
|
||||
private readonly context: TuiDialogContext<void, PackageConfigData>,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly patchDb: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
get success(): boolean {
|
||||
return (
|
||||
!!this.form &&
|
||||
!this.form.form.dirty &&
|
||||
!this.original &&
|
||||
!this.pkg?.status?.configured
|
||||
)
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
this.pkg = await getPackage(this.patchDb, this.pkgId)
|
||||
|
||||
if (!this.pkg) {
|
||||
this.loadingError = 'This service does not exist'
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (this.dependentInfo) {
|
||||
const depConfig = await this.embassyApi.dryConfigureDependency({
|
||||
dependencyId: this.pkgId,
|
||||
dependentId: this.dependentInfo.id,
|
||||
})
|
||||
|
||||
this.original = depConfig.oldConfig
|
||||
this.value = depConfig.newConfig || this.original
|
||||
this.spec = depConfig.spec
|
||||
this.patch = compare(this.original, this.value)
|
||||
} else {
|
||||
const { config, spec } = await this.embassyApi.getPackageConfig({
|
||||
id: this.pkgId,
|
||||
})
|
||||
|
||||
this.original = config
|
||||
this.value = config
|
||||
this.spec = spec
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.loadingError = String(getErrorMessage(e))
|
||||
} finally {
|
||||
this.loadingText = ''
|
||||
}
|
||||
}
|
||||
|
||||
private async save(config: any) {
|
||||
const loader = new Subscription()
|
||||
|
||||
try {
|
||||
if (hasCurrentDeps(this.pkgId, await getAllPackages(this.patchDb))) {
|
||||
await this.configureDeps(config, loader)
|
||||
} else {
|
||||
await this.configure(config, loader)
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async configureDeps(
|
||||
config: Record<string, any>,
|
||||
loader: Subscription,
|
||||
) {
|
||||
loader.unsubscribe()
|
||||
loader.closed = false
|
||||
loader.add(this.loader.open('Checking dependent services...').subscribe())
|
||||
|
||||
const breakages = await this.embassyApi.drySetPackageConfig({
|
||||
id: this.pkgId,
|
||||
config,
|
||||
})
|
||||
|
||||
loader.unsubscribe()
|
||||
loader.closed = false
|
||||
|
||||
if (isEmptyObject(breakages) || (await this.approveBreakages(breakages))) {
|
||||
await this.configure(config, loader)
|
||||
}
|
||||
}
|
||||
|
||||
private async configure(config: Record<string, any>, loader: Subscription) {
|
||||
loader.unsubscribe()
|
||||
loader.closed = false
|
||||
loader.add(this.loader.open('Saving...').subscribe())
|
||||
|
||||
await this.embassyApi.setPackageConfig({ id: this.pkgId, config })
|
||||
this.context.$implicit.complete()
|
||||
}
|
||||
|
||||
private async approveBreakages(breakages: T.PackageId[]): Promise<boolean> {
|
||||
const packages = await getAllPackages(this.patchDb)
|
||||
const message =
|
||||
'As a result of this change, the following services will no longer work properly and may crash:<ul>'
|
||||
const content = `${message}${breakages.map(
|
||||
id => `<li><b>${getManifest(packages[id]).title}</b></li>`,
|
||||
)}</ul>`
|
||||
const data: TuiPromptData = { content, yes: 'Continue', no: 'Cancel' }
|
||||
|
||||
return firstValueFrom(
|
||||
this.dialogs.open<boolean>(TUI_PROMPT, { data }).pipe(endWith(false)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NgIf } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { TuiIconModule, TuiTitleModule } from '@taiga-ui/experimental'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
|
||||
import { StoreIconComponent } from './store-icon.component'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: '[registry]',
|
||||
template: `
|
||||
<store-icon
|
||||
[url]="registry.url"
|
||||
[marketplace]="marketplace"
|
||||
size="40px"
|
||||
></store-icon>
|
||||
<div tuiTitle>
|
||||
{{ registry.name }}
|
||||
<div tuiSubtitle>{{ registry.url }}</div>
|
||||
</div>
|
||||
<tui-icon
|
||||
*ngIf="registry.selected; else content"
|
||||
icon="tuiIconCheck"
|
||||
[style.color]="'var(--tui-positive)'"
|
||||
></tui-icon>
|
||||
<ng-template #content><ng-content></ng-content></ng-template>
|
||||
`,
|
||||
styles: [':host { border-radius: 0.25rem; width: stretch; }'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [NgIf, StoreIconComponent, TuiIconModule, TuiTitleModule],
|
||||
})
|
||||
export class MarketplaceRegistryComponent {
|
||||
readonly marketplace = inject(ConfigService).marketplace
|
||||
|
||||
@Input()
|
||||
registry!: { url: string; selected: boolean; name?: string }
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NgIf } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { sameUrl } from '@start9labs/shared'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'store-icon',
|
||||
template: `
|
||||
<img
|
||||
*ngIf="icon; else noIcon"
|
||||
[style.border-radius.%]="100"
|
||||
[style.max-width]="size || '100%'"
|
||||
[src]="icon"
|
||||
alt="Marketplace Icon"
|
||||
/>
|
||||
<ng-template #noIcon>
|
||||
<img
|
||||
[style.max-width]="size || '100%'"
|
||||
[style.border-radius]="0"
|
||||
src="assets/img/storefront-outline.png"
|
||||
alt="Marketplace Icon"
|
||||
/>
|
||||
</ng-template>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [NgIf],
|
||||
})
|
||||
export class StoreIconComponent {
|
||||
@Input()
|
||||
url = ''
|
||||
@Input()
|
||||
size?: string
|
||||
@Input()
|
||||
marketplace!: any
|
||||
|
||||
get icon() {
|
||||
const { start9, community } = this.marketplace
|
||||
|
||||
if (sameUrl(this.url, start9)) {
|
||||
return 'assets/img/icon_transparent.png'
|
||||
} else if (sameUrl(this.url, community)) {
|
||||
return 'assets/img/community-store.png'
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
94
web/projects/ui/src/app/modals/password-prompt.component.ts
Normal file
94
web/projects/ui/src/app/modals/password-prompt.component.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
ElementRef,
|
||||
Input,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { IonicModule, ModalController } from '@ionic/angular'
|
||||
import { TuiTextfieldComponent } from '@taiga-ui/core'
|
||||
import { TuiInputPasswordModule } from '@taiga-ui/kit'
|
||||
|
||||
export interface PromptOptions {
|
||||
title: string
|
||||
message: string
|
||||
label: string
|
||||
placeholder: string
|
||||
buttonText: string
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>{{ options.title }}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="cancel()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<p>{{ options.message }}</p>
|
||||
<p>
|
||||
<tui-input-password [(ngModel)]="password" (keydown.enter)="confirm()">
|
||||
{{ options.label }}
|
||||
<input tuiTextfield [placeholder]="options.placeholder" />
|
||||
</tui-input-password>
|
||||
</p>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-button
|
||||
class="ion-padding-end"
|
||||
slot="end"
|
||||
color="dark"
|
||||
(click)="cancel()"
|
||||
>
|
||||
Cancel
|
||||
</ion-button>
|
||||
<ion-button
|
||||
class="ion-padding-end"
|
||||
slot="end"
|
||||
color="primary"
|
||||
strong="true"
|
||||
[disabled]="!password"
|
||||
(click)="confirm()"
|
||||
>
|
||||
{{ options.buttonText }}
|
||||
</ion-button>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
`,
|
||||
imports: [IonicModule, FormsModule, TuiInputPasswordModule],
|
||||
})
|
||||
export class PasswordPromptComponent implements AfterViewInit {
|
||||
@ViewChild(TuiTextfieldComponent, { read: ElementRef })
|
||||
input?: ElementRef<HTMLInputElement>
|
||||
|
||||
@Input()
|
||||
options!: PromptOptions
|
||||
|
||||
password = ''
|
||||
|
||||
constructor(private modalCtrl: ModalController) {}
|
||||
|
||||
ngAfterViewInit() {
|
||||
setTimeout(() => {
|
||||
this.input?.nativeElement.focus({ preventScroll: true })
|
||||
}, 300)
|
||||
}
|
||||
|
||||
cancel() {
|
||||
return this.modalCtrl.dismiss(null, 'cancel')
|
||||
}
|
||||
|
||||
confirm() {
|
||||
return this.modalCtrl.dismiss(this.password, 'confirm')
|
||||
}
|
||||
}
|
||||
123
web/projects/ui/src/app/modals/prompt.component.ts
Normal file
123
web/projects/ui/src/app/modals/prompt.component.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { TuiAutoFocusModule } from '@taiga-ui/cdk'
|
||||
import { TuiDialogContext, TuiTextfieldControllerModule } from '@taiga-ui/core'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { TuiInputModule } from '@taiga-ui/kit'
|
||||
import {
|
||||
POLYMORPHEUS_CONTEXT,
|
||||
PolymorpheusComponent,
|
||||
} from '@tinkoff/ng-polymorpheus'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<p>{{ options.message }}</p>
|
||||
<p *ngIf="options.warning" class="warning">{{ options.warning }}</p>
|
||||
<form (ngSubmit)="submit(value.trim())">
|
||||
<tui-input
|
||||
tuiAutoFocus
|
||||
[tuiTextfieldLabelOutside]="!options.label"
|
||||
[tuiTextfieldCustomContent]="options.useMask ? toggle : ''"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[(ngModel)]="value"
|
||||
>
|
||||
{{ options.label }}
|
||||
<span *ngIf="options.required !== false && options.label">*</span>
|
||||
<input
|
||||
tuiTextfield
|
||||
[class.masked]="options.useMask && masked && value"
|
||||
[placeholder]="options.placeholder || ''"
|
||||
/>
|
||||
</tui-input>
|
||||
<footer class="g-buttons">
|
||||
<button
|
||||
tuiButton
|
||||
type="button"
|
||||
appearance="secondary"
|
||||
(click)="cancel()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button tuiButton [disabled]="!value && options.required !== false">
|
||||
{{ options.buttonText || 'Submit' }}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
<ng-template #toggle>
|
||||
<button
|
||||
tuiIconButton
|
||||
type="button"
|
||||
appearance="icon"
|
||||
title="Toggle masking"
|
||||
size="xs"
|
||||
class="button"
|
||||
[iconLeft]="masked ? 'tuiIconEye' : 'tuiIconEyeOff'"
|
||||
(click)="masked = !masked"
|
||||
></button>
|
||||
</ng-template>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.warning {
|
||||
color: var(--tui-warning-fill);
|
||||
}
|
||||
|
||||
.button {
|
||||
pointer-events: auto;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.masked {
|
||||
-webkit-text-security: disc;
|
||||
}
|
||||
`,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
TuiInputModule,
|
||||
TuiButtonModule,
|
||||
TuiTextfieldControllerModule,
|
||||
TuiAutoFocusModule,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PromptModal {
|
||||
masked = this.options.useMask
|
||||
value = this.options.initialValue || ''
|
||||
|
||||
constructor(
|
||||
@Inject(POLYMORPHEUS_CONTEXT)
|
||||
private readonly context: TuiDialogContext<string, PromptOptions>,
|
||||
) {}
|
||||
|
||||
get options(): PromptOptions {
|
||||
return this.context.data
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.context.$implicit.complete()
|
||||
}
|
||||
|
||||
submit(value: string) {
|
||||
if (value || !this.options.required) {
|
||||
this.context.$implicit.next(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const PROMPT = new PolymorpheusComponent(PromptModal)
|
||||
|
||||
export interface PromptOptions {
|
||||
message: string
|
||||
label?: string
|
||||
warning?: string
|
||||
buttonText?: string
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
useMask?: boolean
|
||||
initialValue?: string | null
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
|
||||
const ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadChildren: () =>
|
||||
import('./home/home.module').then(m => m.HomePageModule),
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
loadChildren: () =>
|
||||
import('./logs/logs.module').then(m => m.LogsPageModule),
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(ROUTES)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class DiagnosticModule {}
|
||||
@@ -0,0 +1,222 @@
|
||||
<<<<<<<< HEAD:web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
|
||||
import { TUI_CONFIRM } from '@taiga-ui/kit'
|
||||
import { Component, Inject } from '@angular/core'
|
||||
import { WINDOW } from '@ng-web-apis/common'
|
||||
import { LoadingService } from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { filter } from 'rxjs'
|
||||
import { DiagnosticService } from '../services/diagnostic.service'
|
||||
========
|
||||
import { Component } from '@angular/core'
|
||||
import { AlertController } from '@ionic/angular'
|
||||
import { LoadingService } from '@start9labs/shared'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
>>>>>>>> 94a5075b6daa1375433420abf5d121171dae72cb:web/projects/ui/src/app/pages/diagnostic-routes/home/home.page.ts
|
||||
|
||||
@Component({
|
||||
selector: 'diagnostic-home',
|
||||
templateUrl: 'home.page.html',
|
||||
styleUrls: ['home.page.scss'],
|
||||
})
|
||||
export class HomePage {
|
||||
restarted = false
|
||||
error?: {
|
||||
code: number
|
||||
problem: string
|
||||
solution: string
|
||||
details?: string
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly loader: LoadingService,
|
||||
<<<<<<<< HEAD:web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
|
||||
private readonly api: DiagnosticService,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
@Inject(WINDOW) private readonly window: Window,
|
||||
========
|
||||
private readonly api: ApiService,
|
||||
private readonly alertCtrl: AlertController,
|
||||
>>>>>>>> 94a5075b6daa1375433420abf5d121171dae72cb:web/projects/ui/src/app/pages/diagnostic-routes/home/home.page.ts
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
const error = await this.api.diagnosticGetError()
|
||||
// incorrect drive
|
||||
if (error.code === 15) {
|
||||
this.error = {
|
||||
code: 15,
|
||||
problem: 'Unknown storage drive detected',
|
||||
solution:
|
||||
'To use a different storage drive, replace the current one and click RESTART SERVER below. To use the current storage drive, click USE CURRENT DRIVE below, then follow instructions. No data will be erased during this process.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
// no drive
|
||||
} else if (error.code === 20) {
|
||||
this.error = {
|
||||
code: 20,
|
||||
problem: 'Storage drive not found',
|
||||
solution:
|
||||
'Insert your StartOS storage drive and click RESTART SERVER below.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
// drive corrupted
|
||||
} else if (error.code === 25) {
|
||||
this.error = {
|
||||
code: 25,
|
||||
problem:
|
||||
'Storage drive corrupted. This could be the result of data corruption or physical damage.',
|
||||
solution:
|
||||
'It may or may not be possible to re-use this drive by reformatting and recovering from backup. To enter recovery mode, click ENTER RECOVERY MODE below, then follow instructions. No data will be erased during this step.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
// filesystem I/O error - disk needs repair
|
||||
} else if (error.code === 2) {
|
||||
this.error = {
|
||||
code: 2,
|
||||
problem: 'Filesystem I/O error.',
|
||||
solution:
|
||||
'Repairing the disk could help resolve this issue. Please DO NOT unplug the drive or server during this time or the situation will become worse.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
// disk management error - disk needs repair
|
||||
} else if (error.code === 48) {
|
||||
this.error = {
|
||||
code: 48,
|
||||
problem: 'Disk management error.',
|
||||
solution:
|
||||
'Repairing the disk could help resolve this issue. Please DO NOT unplug the drive or server during this time or the situation will become worse.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
} else {
|
||||
this.error = {
|
||||
code: error.code,
|
||||
problem: error.message,
|
||||
solution: 'Please contact support.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
async restart(): Promise<void> {
|
||||
<<<<<<<< HEAD:web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
|
||||
const loader = this.loader.open('').subscribe()
|
||||
========
|
||||
const loader = this.loader.open('Loading...').subscribe()
|
||||
>>>>>>>> 94a5075b6daa1375433420abf5d121171dae72cb:web/projects/ui/src/app/pages/diagnostic-routes/home/home.page.ts
|
||||
|
||||
try {
|
||||
await this.api.diagnosticRestart()
|
||||
this.restarted = true
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
async forgetDrive(): Promise<void> {
|
||||
<<<<<<<< HEAD:web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
|
||||
const loader = this.loader.open('').subscribe()
|
||||
========
|
||||
const loader = this.loader.open('Loading...').subscribe()
|
||||
>>>>>>>> 94a5075b6daa1375433420abf5d121171dae72cb:web/projects/ui/src/app/pages/diagnostic-routes/home/home.page.ts
|
||||
|
||||
try {
|
||||
await this.api.diagnosticForgetDrive()
|
||||
await this.api.diagnosticRestart()
|
||||
this.restarted = true
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
<<<<<<<< HEAD:web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
|
||||
async presentAlertSystemRebuild() {
|
||||
this.dialogs
|
||||
.open(TUI_CONFIRM, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
no: 'Cancel',
|
||||
yes: 'Rebuild',
|
||||
content:
|
||||
'<p>This action will tear down all service containers and rebuild them from scratch. No data will be deleted.</p><p>A system rebuild can be useful if your system gets into a bad state, and it should only be performed if you are experiencing general performance or reliability issues.</p><p>It may take up to an hour to complete. During this time, you will lose all connectivity to your Start9 server.</p>',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => {
|
||||
try {
|
||||
this.systemRebuild()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
========
|
||||
>>>>>>>> 94a5075b6daa1375433420abf5d121171dae72cb:web/projects/ui/src/app/pages/diagnostic-routes/home/home.page.ts
|
||||
async presentAlertRepairDisk() {
|
||||
this.dialogs
|
||||
.open(TUI_CONFIRM, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
no: 'Cancel',
|
||||
yes: 'Repair',
|
||||
content:
|
||||
'<p>This action should only be executed if directed by a Start9 support specialist.</p><p>If anything happens to the device during the reboot, such as losing power or unplugging the drive, the filesystem <i>will</i> be in an unrecoverable state. Please proceed with caution.</p>',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => {
|
||||
try {
|
||||
this.repairDisk()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
refreshPage(): void {
|
||||
this.window.location.reload()
|
||||
}
|
||||
|
||||
<<<<<<<< HEAD:web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
|
||||
private async systemRebuild(): Promise<void> {
|
||||
const loader = this.loader.open('').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.systemRebuild()
|
||||
await this.api.restart()
|
||||
this.restarted = true
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async repairDisk(): Promise<void> {
|
||||
const loader = this.loader.open('').subscribe()
|
||||
========
|
||||
private async repairDisk(): Promise<void> {
|
||||
const loader = this.loader.open('Loading...').subscribe()
|
||||
>>>>>>>> 94a5075b6daa1375433420abf5d121171dae72cb:web/projects/ui/src/app/pages/diagnostic-routes/home/home.page.ts
|
||||
|
||||
try {
|
||||
await this.api.diagnosticRepairDisk()
|
||||
await this.api.diagnosticRestart()
|
||||
this.restarted = true
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
24
web/projects/ui/src/app/pages/init/init.module.ts
Normal file
24
web/projects/ui/src/app/pages/init/init.module.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { TuiProgressModule } from '@taiga-ui/kit'
|
||||
import { LogsModule } from 'src/app/pages/init/logs/logs.module'
|
||||
import { InitPage } from './init.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: InitPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
LogsModule,
|
||||
TuiProgressModule,
|
||||
RouterModule.forChild(routes),
|
||||
],
|
||||
declarations: [InitPage],
|
||||
})
|
||||
export class InitPageModule {}
|
||||
18
web/projects/ui/src/app/pages/init/init.page.html
Normal file
18
web/projects/ui/src/app/pages/init/init.page.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<section *ngIf="progress$ | async as progress">
|
||||
<h1 [style.font-size.rem]="2.5" [style.margin.rem]="1">
|
||||
Initializing StartOS
|
||||
</h1>
|
||||
<div *ngIf="progress.total">
|
||||
Progress: {{ (progress.total * 100).toFixed(0) }}%
|
||||
</div>
|
||||
|
||||
<progress
|
||||
tuiProgressBar
|
||||
class="progress"
|
||||
[style.max-width.rem]="40"
|
||||
[style.margin]="'1rem auto'"
|
||||
[attr.value]="progress.total"
|
||||
></progress>
|
||||
<p [innerHTML]="progress.message"></p>
|
||||
</section>
|
||||
<logs-window></logs-window>
|
||||
23
web/projects/ui/src/app/pages/init/init.page.scss
Normal file
23
web/projects/ui/src/app/pages/init/init.page.scss
Normal file
@@ -0,0 +1,23 @@
|
||||
section {
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem;
|
||||
margin: 1.5rem;
|
||||
text-align: center;
|
||||
/* TODO: Theme */
|
||||
background: #e0e0e0;
|
||||
color: #333;
|
||||
--tui-clear-inverse: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
logs-window {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 18rem;
|
||||
padding: 1rem;
|
||||
margin: 0 1.5rem auto;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
border-radius: 2rem;
|
||||
/* TODO: Theme */
|
||||
background: #181818;
|
||||
}
|
||||
11
web/projects/ui/src/app/pages/init/init.page.ts
Normal file
11
web/projects/ui/src/app/pages/init/init.page.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { InitService } from 'src/app/pages/init/init.service'
|
||||
|
||||
@Component({
|
||||
selector: 'init-page',
|
||||
templateUrl: 'init.page.html',
|
||||
styleUrls: ['init.page.scss'],
|
||||
})
|
||||
export class InitPage {
|
||||
readonly progress$ = inject(InitService)
|
||||
}
|
||||
90
web/projects/ui/src/app/pages/init/init.service.ts
Normal file
90
web/projects/ui/src/app/pages/init/init.service.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import {
|
||||
catchError,
|
||||
defer,
|
||||
EMPTY,
|
||||
from,
|
||||
map,
|
||||
Observable,
|
||||
startWith,
|
||||
switchMap,
|
||||
tap,
|
||||
} from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
interface MappedProgress {
|
||||
readonly total: number | null
|
||||
readonly message: string
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class InitService extends Observable<MappedProgress> {
|
||||
private readonly state = inject(StateService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly progress$ = defer(() =>
|
||||
from(this.api.initGetProgress()),
|
||||
).pipe(
|
||||
switchMap(({ guid, progress }) =>
|
||||
this.api
|
||||
.openWebsocket$<T.FullProgress>(guid, {})
|
||||
.pipe(startWith(progress)),
|
||||
),
|
||||
map(({ phases, overall }) => {
|
||||
return {
|
||||
total: getOverallDecimal(overall),
|
||||
message: phases
|
||||
.filter(
|
||||
(
|
||||
p,
|
||||
): p is {
|
||||
name: string
|
||||
progress: {
|
||||
done: number
|
||||
total: number | null
|
||||
}
|
||||
} => p.progress !== true && p.progress !== null,
|
||||
)
|
||||
.map(p => `<b>${p.name}</b>${getPhaseBytes(p.progress)}`)
|
||||
.join(', '),
|
||||
}
|
||||
}),
|
||||
tap(({ total }) => {
|
||||
if (total === 1) {
|
||||
this.state.syncState()
|
||||
}
|
||||
}),
|
||||
catchError(e => {
|
||||
console.error(e)
|
||||
return EMPTY
|
||||
}),
|
||||
)
|
||||
|
||||
constructor() {
|
||||
super(subscriber => this.progress$.subscribe(subscriber))
|
||||
}
|
||||
}
|
||||
|
||||
function getOverallDecimal(progress: T.Progress): number {
|
||||
if (progress === true) {
|
||||
return 1
|
||||
} else if (!progress || !progress.total) {
|
||||
return 0
|
||||
} else {
|
||||
return progress.total && progress.done / progress.total
|
||||
}
|
||||
}
|
||||
|
||||
function getPhaseBytes(
|
||||
progress:
|
||||
| false
|
||||
| {
|
||||
done: number
|
||||
total: number | null
|
||||
},
|
||||
): string {
|
||||
return progress === false ? '' : `: (${progress.done}/${progress.total})`
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user