mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
Merge pull request #2709 from Start9Labs/fix-build
fix: fix build after minor merged into major
This commit is contained in:
18
web/package-lock.json
generated
18
web/package-lock.json
generated
@@ -31,7 +31,6 @@
|
||||
"@taiga-ui/cdk": "4.0.0-rc.7",
|
||||
"@taiga-ui/core": "4.0.0-rc.7",
|
||||
"@taiga-ui/event-plugins": "^4.0.1",
|
||||
"@taiga-ui/experimental": "4.0.0-rc.7",
|
||||
"@taiga-ui/icons": "4.0.0-rc.7",
|
||||
"@taiga-ui/kit": "4.0.0-rc.7",
|
||||
"@taiga-ui/layout": "4.0.0-rc.7",
|
||||
@@ -5255,23 +5254,6 @@
|
||||
"rxjs": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@taiga-ui/experimental": {
|
||||
"version": "4.0.0-rc.7",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"tslib": "^2.6.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": ">=16.0.0",
|
||||
"@angular/core": ">=16.0.0",
|
||||
"@taiga-ui/addon-commerce": "^4.0.0-rc.7",
|
||||
"@taiga-ui/cdk": "^4.0.0-rc.7",
|
||||
"@taiga-ui/core": "^4.0.0-rc.7",
|
||||
"@taiga-ui/kit": "^4.0.0-rc.7",
|
||||
"@taiga-ui/polymorpheus": "^4.6.4",
|
||||
"rxjs": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@taiga-ui/i18n": {
|
||||
"version": "4.0.0-rc.7",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
"@taiga-ui/cdk": "4.0.0-rc.7",
|
||||
"@taiga-ui/core": "4.0.0-rc.7",
|
||||
"@taiga-ui/event-plugins": "^4.0.1",
|
||||
"@taiga-ui/experimental": "4.0.0-rc.7",
|
||||
"@taiga-ui/icons": "4.0.0-rc.7",
|
||||
"@taiga-ui/kit": "4.0.0-rc.7",
|
||||
"@taiga-ui/layout": "4.0.0-rc.7",
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"@angular/core": ">=13.2.0",
|
||||
"@start9labs/shared": ">=0.3.2",
|
||||
"@taiga-ui/cdk": "4.0.0-rc.6",
|
||||
"@taiga-ui/core": "4.0.0-rc.6",
|
||||
"@taiga-ui/layout": "4.0.0-rc.6",
|
||||
"@tinkoff/ng-dompurify": ">=4.0.0",
|
||||
"fuse.js": "^6.4.6"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { Exver, MarkdownPipeModule } from '@start9labs/shared'
|
||||
import { TuiButton, TuiLoader } from '@taiga-ui/core'
|
||||
import { TuiCardLarge } from '@taiga-ui/layout'
|
||||
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import { map } from 'rxjs'
|
||||
import { AbstractMarketplaceService } from '../services/marketplace.service'
|
||||
import { MarketplacePkg } from '../types'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
@if (notes$ | async; as notes) {
|
||||
@for (note of notes | keyvalue: asIsOrder; track $index) {
|
||||
<button tuiButton (click)="setSelected(note.key)">
|
||||
{{ note.key }}
|
||||
</button>
|
||||
<div
|
||||
tuiCardLarge
|
||||
#element
|
||||
[id]="note.key"
|
||||
[style.max-height.px]="getDocSize(note.key, element)"
|
||||
[innerHTML]="note.value | markdown"
|
||||
></div>
|
||||
}
|
||||
} @else {
|
||||
<tui-loader textContent="Loading Release Notes" />
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
TuiButton,
|
||||
TuiLoader,
|
||||
TuiCardLarge,
|
||||
MarkdownPipeModule,
|
||||
],
|
||||
})
|
||||
export class ReleaseNotesComponent {
|
||||
@Input() pkg!: MarketplacePkg
|
||||
|
||||
private selected: string | null = null
|
||||
private readonly exver = inject(Exver)
|
||||
|
||||
readonly notes$ = inject(AbstractMarketplaceService)
|
||||
.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,
|
||||
},
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
isSelected(key: string): boolean {
|
||||
return this.selected === key
|
||||
}
|
||||
|
||||
setSelected(selected: string) {
|
||||
this.selected = this.isSelected(selected) ? null : selected
|
||||
}
|
||||
|
||||
getDocSize(key: string, { scrollHeight }: HTMLElement) {
|
||||
return this.isSelected(key) ? scrollHeight : 0
|
||||
}
|
||||
|
||||
asIsOrder(a: any, b: any) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
export const RELEASE_NOTES = new PolymorpheusComponent(ReleaseNotesComponent)
|
||||
@@ -1,51 +0,0 @@
|
||||
<div class="background-border box-shadow-lg shadow-color-light">
|
||||
<div class="box-container">
|
||||
<h2 class="additional-detail-title">What's new</h2>
|
||||
<div class="box-container-details">
|
||||
<div class="box-container-details-version">
|
||||
<h3>Version {{ pkg.manifest.version }}</h3>
|
||||
<p
|
||||
safeLinks
|
||||
class="box-container-details-notes"
|
||||
[innerHTML]="pkg.manifest.releaseNotes | markdown | dompurify"
|
||||
></p>
|
||||
</div>
|
||||
<button
|
||||
*ngIf="pkg.versions.length > 2"
|
||||
tuiButton
|
||||
type="button"
|
||||
appearance="secondary"
|
||||
size="m"
|
||||
(click)="showReleaseNotes(template)"
|
||||
>
|
||||
Previous releases
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ng-template #template let-observer>
|
||||
<ng-container *ngIf="notes$ | async as notes; else loading">
|
||||
<tui-accordion
|
||||
class="max-width-lg"
|
||||
style="max-width: 32rem"
|
||||
[closeOthers]="false"
|
||||
*ngFor="
|
||||
let note of notes
|
||||
| filterVersions: pkg.manifest.version
|
||||
| keyvalue: asIsOrder
|
||||
"
|
||||
>
|
||||
<tui-accordion-item style="margin: 0.25rem 0">
|
||||
{{ note.key | displayEmver }}
|
||||
<ng-template tuiAccordionItemContent>
|
||||
<p safeLinks [innerHTML]="note.value | markdown | dompurify"></p>
|
||||
</ng-template>
|
||||
</tui-accordion-item>
|
||||
</tui-accordion>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #loading>
|
||||
<tui-loader textContent="Loading Release Notes" />
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
@@ -1,44 +0,0 @@
|
||||
.box-container {
|
||||
background-color: rgb(39 39 42);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.75rem;
|
||||
display: grid;
|
||||
grid-auto-flow: row;
|
||||
align-items: center;
|
||||
|
||||
&-details {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
|
||||
&-version {
|
||||
line-height: 1.5rem;
|
||||
font-size: 1rem;
|
||||
|
||||
h3 {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 1.25rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
&-notes {
|
||||
flex-wrap: wrap;
|
||||
margin-top: 0.25rem;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
button {
|
||||
margin-top: 0.75rem;
|
||||
place-self: end;
|
||||
|
||||
@media (min-width: 768px) {
|
||||
place-self: start;
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
place-self: end;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
import { TuiAccordion } from '@taiga-ui/kit'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ExverPipesModule,
|
||||
MarkdownPipeModule,
|
||||
SafeLinksDirective,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiLoader, TuiButton } from '@taiga-ui/core'
|
||||
import { NgDompurifyModule } from '@tinkoff/ng-dompurify'
|
||||
import {
|
||||
FilterVersionsPipe,
|
||||
ReleaseNotesComponent,
|
||||
} from './release-notes.component'
|
||||
import { ReleaseNotesComponent } from './release-notes.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
ExverPipesModule,
|
||||
MarkdownPipeModule,
|
||||
NgDompurifyModule,
|
||||
SafeLinksDirective,
|
||||
TuiButton,
|
||||
...TuiAccordion,
|
||||
TuiLoader,
|
||||
FilterVersionsPipe,
|
||||
],
|
||||
declarations: [ReleaseNotesComponent],
|
||||
exports: [ReleaseNotesComponent],
|
||||
})
|
||||
export class ReleaseNotesComponentModule {}
|
||||
@@ -1,14 +1,14 @@
|
||||
<button
|
||||
*ngFor="let cat of categories || ['', '', '', '', '', '']"
|
||||
(click)="switchCategory(cat)"
|
||||
[class.category_selected]="cat === category"
|
||||
*ngFor="let cat of categories || fallback | keyvalue"
|
||||
(click)="switchCategory(cat.key)"
|
||||
[class.category_selected]="cat.key === category"
|
||||
>
|
||||
<div
|
||||
class="category-wrapper"
|
||||
[class.tui-skeleton]="!categories"
|
||||
[class.tui-skeleton_rounded]="!categories"
|
||||
>
|
||||
<tui-icon tuiAppearance="icon" icon="{{ determineIcon(cat) }}"></tui-icon>
|
||||
<tui-icon tuiAppearance="icon" [icon]="determineIcon(cat.key)" />
|
||||
</div>
|
||||
<span
|
||||
class="category-title"
|
||||
@@ -16,9 +16,9 @@
|
||||
[class.tui-skeleton_rounded]="!categories"
|
||||
>
|
||||
{{
|
||||
cat === 'ai'
|
||||
? (cat | uppercase)
|
||||
: (cat | titlecase) || 'Loading category...'
|
||||
cat.key === 'ai'
|
||||
? (cat.key | uppercase)
|
||||
: (cat.value.name | titlecase) || 'Loading category...'
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -15,7 +15,7 @@ import { T } from '@start9labs/start-sdk'
|
||||
})
|
||||
export class CategoriesComponent {
|
||||
@Input()
|
||||
categories!: Map<string, T.Category>
|
||||
categories?: Record<string, T.Category>
|
||||
|
||||
@Input()
|
||||
category = ''
|
||||
@@ -23,6 +23,14 @@ export class CategoriesComponent {
|
||||
@Output()
|
||||
readonly categoryChange = new EventEmitter<string>()
|
||||
|
||||
readonly fallback: Record<string, T.Category> = {
|
||||
a: { name: 'a', description: { short: 'a', long: 'a' } },
|
||||
b: { name: 'a', description: { short: 'a', long: 'a' } },
|
||||
c: { name: 'a', description: { short: 'a', long: 'a' } },
|
||||
d: { name: 'a', description: { short: 'a', long: 'a' } },
|
||||
e: { name: 'a', description: { short: 'a', long: 'a' } },
|
||||
}
|
||||
|
||||
switchCategory(category: string): void {
|
||||
this.category = category
|
||||
this.categoryChange.emit(category)
|
||||
|
||||
@@ -25,7 +25,7 @@ export class ItemComponent {
|
||||
const iconUrl = new URL(this.pkg.icon)
|
||||
return iconUrl.href
|
||||
} catch (e) {
|
||||
return `${marketplace?.url}package/v0/icon/${this.pkg.manifest.id}`
|
||||
return `${marketplace?.url}package/v0/icon/${this.pkg.id}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
<div class="background-border box-shadow-lg shadow-color-light">
|
||||
<div class="box-container">
|
||||
<h2 class="additional-detail-title">New in {{ pkg.version }}</h2>
|
||||
<p [innerHTML]="pkg.releaseNotes | markdown"></p>
|
||||
<button tuiButton iconEnd="@tui.chevron-right" (click)="onPast()">
|
||||
Past Release Notes
|
||||
</button>
|
||||
</div>
|
||||
<div class="box-container">
|
||||
<h2 class="additional-detail-title">About</h2>
|
||||
<p>
|
||||
{{ pkg.manifest.description.long }}
|
||||
</p>
|
||||
<p>{{ pkg.description.long }}</p>
|
||||
<a
|
||||
*ngIf="pkg.marketingSite as url"
|
||||
tuiButton
|
||||
iconEnd="@tui.external-link"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
[href]="url"
|
||||
>
|
||||
View website
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { RELEASE_NOTES } from '../../../modals/release-notes.component'
|
||||
import { MarketplacePkg } from '../../../types'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { ReleaseNotesComponent } from '../../../modals/release-notes/release-notes.component'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-about',
|
||||
@@ -10,17 +15,14 @@ import { ReleaseNotesComponent } from '../../../modals/release-notes/release-not
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AboutComponent {
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
|
||||
@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()
|
||||
async onPast() {
|
||||
this.dialogs
|
||||
.open(RELEASE_NOTES, { label: 'Past Release Notes' })
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { TuiButton } from '@taiga-ui/core'
|
||||
import { TuiTagModule } from '@taiga-ui/legacy'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { AboutComponent } from './about.component'
|
||||
import { NgDompurifyModule } from '@tinkoff/ng-dompurify'
|
||||
import { SafeLinksDirective } from '@start9labs/shared'
|
||||
import { MarkdownPipeModule, SafeLinksDirective } from '@start9labs/shared'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
@@ -13,6 +14,8 @@ import { SafeLinksDirective } from '@start9labs/shared'
|
||||
TuiTagModule,
|
||||
NgDompurifyModule,
|
||||
SafeLinksDirective,
|
||||
MarkdownPipeModule,
|
||||
TuiButton,
|
||||
],
|
||||
declarations: [AboutComponent],
|
||||
exports: [AboutComponent],
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
<div class="detail-container">
|
||||
<!-- release date -->
|
||||
<marketplace-additional-item
|
||||
*ngIf="pkg.publishedAt as published"
|
||||
*ngIf="pkg.s9pk.publishedAt as published"
|
||||
[data]="(published | date: 'medium')!"
|
||||
label="Released"
|
||||
icon=""
|
||||
></marketplace-additional-item>
|
||||
/>
|
||||
<!-- git hash -->
|
||||
<marketplace-additional-item
|
||||
*ngIf="pkg.gitHash as gitHash; else noHash"
|
||||
@@ -17,7 +17,7 @@
|
||||
label="Git Hash"
|
||||
icon="@tui.copy"
|
||||
class="item-copy"
|
||||
></marketplace-additional-item>
|
||||
/>
|
||||
<ng-template #noHash>
|
||||
<div class="item-padding">
|
||||
<label tuiTitle>
|
||||
@@ -33,7 +33,7 @@
|
||||
label="License"
|
||||
icon="@tui.chevron-right"
|
||||
class="item-pointer"
|
||||
></marketplace-additional-item>
|
||||
/>
|
||||
<!-- instructions -->
|
||||
<marketplace-additional-item
|
||||
(click)="presentModalMd('Instructions')"
|
||||
@@ -41,7 +41,7 @@
|
||||
label="Instructions"
|
||||
icon="@tui.chevron-right"
|
||||
class="item-pointer"
|
||||
></marketplace-additional-item>
|
||||
/>
|
||||
<!-- versions -->
|
||||
<ng-content />
|
||||
<!-- links -->
|
||||
@@ -51,28 +51,28 @@
|
||||
label="Marketing Site"
|
||||
icon="@tui.external-link"
|
||||
class="item-pointer"
|
||||
></marketplace-additional-link>
|
||||
/>
|
||||
<marketplace-additional-link
|
||||
*ngIf="pkg.upstreamRepo"
|
||||
[url]="pkg.upstreamRepo"
|
||||
label="Source Repository"
|
||||
icon="@tui.external-link"
|
||||
class="item-pointer"
|
||||
></marketplace-additional-link>
|
||||
/>
|
||||
<marketplace-additional-link
|
||||
*ngIf="pkg.wrapperRepo"
|
||||
[url]="pkg.wrapperRepo"
|
||||
label="Wrapper Repository"
|
||||
icon="@tui.external-link"
|
||||
class="item-pointer"
|
||||
></marketplace-additional-link>
|
||||
/>
|
||||
<marketplace-additional-link
|
||||
*ngIf="pkg.supportSite"
|
||||
[url]="pkg.supportSite"
|
||||
label="Support Site"
|
||||
icon="@tui.external-link"
|
||||
class="item-pointer"
|
||||
></marketplace-additional-link>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import { CopyService, Exver, MarkdownComponent } from '@start9labs/shared'
|
||||
import { CopyService, MarkdownComponent } from '@start9labs/shared'
|
||||
import { MarketplacePkg } from '../../../types'
|
||||
import { AbstractMarketplaceService } from '../../../services/marketplace.service'
|
||||
|
||||
@@ -38,9 +38,8 @@ export class AdditionalComponent {
|
||||
size: 'l',
|
||||
data: {
|
||||
content: this.marketplaceService.fetchStatic$(
|
||||
this.pkg.id,
|
||||
label.toLowerCase(),
|
||||
this.url,
|
||||
this.pkg,
|
||||
label === 'License' ? 'LICENSE.md' : 'instructions.md',
|
||||
),
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
Output,
|
||||
inject,
|
||||
} from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { MarketplaceDepItemComponent } from './dependency-item.component'
|
||||
import { MarketplacePkg } from '../../../types'
|
||||
import { MarketplaceDepItemComponent } from './dependency-item.component'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-dependencies',
|
||||
@@ -17,7 +16,7 @@ import { MarketplacePkg } from '../../../types'
|
||||
<div class="dependencies-container">
|
||||
<h2 class="additional-detail-title">Dependencies</h2>
|
||||
<div class="dependencies-list">
|
||||
@for (dep of pkg.manifest.dependencies | keyvalue; track $index) {
|
||||
@for (dep of pkg.dependencyMetadata | keyvalue; track $index) {
|
||||
<marketplace-dep-item
|
||||
[dep]="dep"
|
||||
[pkg]="pkg"
|
||||
|
||||
@@ -2,15 +2,16 @@ import { CommonModule, KeyValue } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Input,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { EmverPipesModule } from '@start9labs/shared'
|
||||
import { ExverPipesModule } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { TuiLet } from '@taiga-ui/cdk'
|
||||
import { TuiAvatar, TuiLineClamp } from '@taiga-ui/kit'
|
||||
import { Dependency, MarketplacePkg, StoreIdentity } from '../../../types'
|
||||
import { AbstractMarketplaceService } from '../../../services/marketplace.service'
|
||||
import { MarketplacePkg, StoreIdentity } from '../../../types'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-dep-item',
|
||||
@@ -104,7 +105,7 @@ import { AbstractMarketplaceService } from '../../../services/marketplace.servic
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
TuiAvatar,
|
||||
EmverPipesModule,
|
||||
ExverPipesModule,
|
||||
TuiLineClamp,
|
||||
TuiLet,
|
||||
],
|
||||
@@ -114,7 +115,7 @@ export class MarketplaceDepItemComponent {
|
||||
pkg!: MarketplacePkg
|
||||
|
||||
@Input({ required: true })
|
||||
dep!: KeyValue<string, Dependency>
|
||||
dep!: KeyValue<string, T.DependencyMetadata>
|
||||
|
||||
private readonly marketplaceService = inject(AbstractMarketplaceService)
|
||||
readonly marketplace$ = this.marketplaceService.getSelectedHost$()
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
<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>
|
||||
@@ -1,10 +1,31 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
import { TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiCell } from '@taiga-ui/layout'
|
||||
import { MarketplacePkg } from '../../../types'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'marketplace-flavors',
|
||||
templateUrl: 'flavors.component.html',
|
||||
template: `
|
||||
<h2>Alternative Implementations</h2>
|
||||
@for (pkg of pkgs; track $index) {
|
||||
<a
|
||||
tuiCell
|
||||
[routerLink]="['/marketplace', pkg.id]"
|
||||
[queryParams]="{ flavor: pkg.flavor }"
|
||||
>
|
||||
<img alt="" style="border-radius: 100%" [src]="pkg.icon | trustUrl" />
|
||||
<span tuiTitle>
|
||||
{{ pkg.title }}
|
||||
<span tuiSubtitle>{{ pkg.version }}</span>
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [RouterLink, TuiCell, TuiTitle, SharedPipesModule],
|
||||
})
|
||||
export class FlavorsComponent {
|
||||
@Input()
|
||||
|
||||
@@ -1,19 +0,0 @@
|
||||
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 {}
|
||||
@@ -18,26 +18,26 @@ import { MarketplacePkg, StoreIdentity } from '../../../types'
|
||||
<!-- icon -->
|
||||
<img
|
||||
[src]="determineIcon(marketplace) | trustUrl"
|
||||
alt="{{ pkg.manifest.title }} Icon"
|
||||
alt="{{ pkg.title }} Icon"
|
||||
/>
|
||||
<!-- color background -->
|
||||
<div class="color-background">
|
||||
<img
|
||||
[src]="determineIcon(marketplace) | trustUrl"
|
||||
alt="{{ pkg.manifest.title }} background image"
|
||||
alt="{{ pkg.title }} background image"
|
||||
/>
|
||||
</div>
|
||||
<!-- background darkening overlay -->
|
||||
<div class="dark-overlay"></div>
|
||||
<div class="inner-container-title">
|
||||
<h2 ticker>
|
||||
{{ pkg.manifest.title }}
|
||||
{{ pkg.title }}
|
||||
</h2>
|
||||
<h3>
|
||||
{{ pkg.manifest.version }}
|
||||
{{ pkg.version }}
|
||||
</h3>
|
||||
<p>
|
||||
{{ pkg.manifest.description.short }}
|
||||
{{ pkg.description.short }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- control buttons -->
|
||||
@@ -175,7 +175,7 @@ export class MarketplacePackageHeroComponent {
|
||||
const iconUrl = new URL(this.pkg.icon)
|
||||
return iconUrl.href
|
||||
} catch (e) {
|
||||
return `${marketplace?.url}package/v0/icon/${this.pkg.manifest.id}`
|
||||
return `${marketplace?.url}package/v0/icon/${this.pkg.id}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,11 +14,16 @@ import { PolymorpheusContent } from '@taiga-ui/polymorpheus'
|
||||
@Component({
|
||||
selector: 'marketplace-package-screenshots',
|
||||
template: `
|
||||
<div *ngIf="pkg.screenshots" tuiCarouselButtons class="outer-container">
|
||||
<!--@TODO future release-->
|
||||
<div
|
||||
*ngIf="$any(pkg).screenshots as screenshots"
|
||||
tuiCarouselButtons
|
||||
class="outer-container"
|
||||
>
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="flat"
|
||||
icon="@tui.chevron-left"
|
||||
iconStart="@tui.chevron-left"
|
||||
title="Previous"
|
||||
type="button"
|
||||
(click)="carousel.prev()"
|
||||
@@ -29,7 +34,7 @@ import { PolymorpheusContent } from '@taiga-ui/polymorpheus'
|
||||
[(index)]="index"
|
||||
class="carousel"
|
||||
>
|
||||
<ng-container *ngFor="let item of pkg.screenshots; let i = index">
|
||||
<ng-container *ngFor="let item of screenshots; let i = index">
|
||||
<div
|
||||
*tuiItem
|
||||
draggable="false"
|
||||
@@ -57,7 +62,7 @@ import { PolymorpheusContent } from '@taiga-ui/polymorpheus'
|
||||
tuiIconButton
|
||||
appearance="flat"
|
||||
type="button"
|
||||
icon="@tui.chevron-right"
|
||||
iconStart="@tui.chevron-right"
|
||||
title="Next"
|
||||
(click)="carousel.next()"
|
||||
></button>
|
||||
|
||||
@@ -19,7 +19,6 @@ 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'
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { Observable } from 'rxjs'
|
||||
import {
|
||||
Marketplace,
|
||||
MarketplacePkg,
|
||||
StoreData,
|
||||
StoreIdentity,
|
||||
StoreIdentityWithData,
|
||||
} from '../types'
|
||||
import { Marketplace, MarketplacePkg, StoreData, StoreIdentity } from '../types'
|
||||
|
||||
export abstract class AbstractMarketplaceService {
|
||||
abstract getKnownHosts$(): Observable<StoreIdentity[]>
|
||||
@@ -16,7 +10,9 @@ export abstract class AbstractMarketplaceService {
|
||||
|
||||
abstract getSelectedStore$(): Observable<StoreData>
|
||||
|
||||
abstract getSelectedStoreWithCategories$(): Observable<StoreIdentityWithData>
|
||||
abstract getSelectedStoreWithCategories$(): Observable<
|
||||
StoreIdentity & StoreData
|
||||
>
|
||||
|
||||
abstract getPackage$(
|
||||
id: string,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Directive, ElementRef, inject, input, Output } from '@angular/core'
|
||||
import { StartOSDiskInfo } from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { filter, fromEvent, switchMap } from 'rxjs'
|
||||
import { PASSWORD } from 'src/app/components/password.component'
|
||||
|
||||
@Directive({
|
||||
standalone: true,
|
||||
selector: 'button[server][password]',
|
||||
})
|
||||
export class PasswordDirective {
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
|
||||
readonly server = input.required<StartOSDiskInfo>()
|
||||
|
||||
@Output()
|
||||
readonly password = fromEvent(inject(ElementRef).nativeElement, 'click').pipe(
|
||||
switchMap(() =>
|
||||
this.dialogs.open<string>(PASSWORD, {
|
||||
label: 'Unlock Drive',
|
||||
size: 's',
|
||||
data: { passwordHash: this.server().passwordHash },
|
||||
}),
|
||||
),
|
||||
filter(Boolean),
|
||||
)
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { ServerComponent } from '@start9labs/shared'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
import {
|
||||
POLYMORPHEUS_CONTEXT,
|
||||
PolymorpheusComponent,
|
||||
} from '@taiga-ui/polymorpheus'
|
||||
import { ServerComponent } from 'src/app/components/server.component'
|
||||
import { PasswordDirective } from 'src/app/components/password.directive'
|
||||
import { StartOSDiskInfoWithId } from 'src/app/services/api.service'
|
||||
|
||||
interface Data {
|
||||
@@ -23,7 +24,7 @@ export interface ServersResponse {
|
||||
<button [server]="server" (password)="select($event, server.id)"></button>
|
||||
}
|
||||
`,
|
||||
imports: [ServerComponent],
|
||||
imports: [ServerComponent, PasswordDirective],
|
||||
})
|
||||
export class ServersComponent {
|
||||
readonly context =
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import { Router } from '@angular/router'
|
||||
import { ErrorService, InitializingComponent } from '@start9labs/shared'
|
||||
import {
|
||||
ErrorService,
|
||||
formatProgress,
|
||||
InitializingComponent,
|
||||
} from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import {
|
||||
catchError,
|
||||
@@ -36,13 +40,12 @@ export default class LoadingPage {
|
||||
filter(Boolean),
|
||||
take(1),
|
||||
switchMap(({ guid, progress }) =>
|
||||
this.api.openProgressWebsocket$(guid).pipe(
|
||||
this.api.openWebsocket$<T.FullProgress>(guid).pipe(
|
||||
startWith(progress),
|
||||
catchError((_, watch$) =>
|
||||
interval(2000).pipe(
|
||||
switchMap(() =>
|
||||
from(this.api.getStatus()).pipe(catchError(() => EMPTY)),
|
||||
),
|
||||
switchMap(() => from(this.api.getStatus())),
|
||||
catchError(() => EMPTY),
|
||||
take(1),
|
||||
switchMap(() => watch$),
|
||||
),
|
||||
@@ -54,13 +57,7 @@ export default class LoadingPage {
|
||||
}),
|
||||
),
|
||||
),
|
||||
map(({ phases, overall }) => ({
|
||||
total: getDecimal(overall),
|
||||
message: phases
|
||||
.filter(p => p.progress !== true && p.progress !== null)
|
||||
.map(p => `${p.name}${getPhaseBytes(p.progress)}`)
|
||||
.join(','),
|
||||
})),
|
||||
map(formatProgress),
|
||||
catchError(e => {
|
||||
this.errorService.handleError(e)
|
||||
return EMPTY
|
||||
@@ -87,19 +84,3 @@ export default class LoadingPage {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getDecimal(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: T.Progress): string {
|
||||
return progress === true || !progress
|
||||
? ''
|
||||
: `: (${progress.done}/${progress.total})`
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { DatePipe } from '@angular/common'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { ErrorService, ServerComponent } from '@start9labs/shared'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDialogService,
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiCardLarge, TuiCell } from '@taiga-ui/layout'
|
||||
import { CIFS, CifsResponse } from 'src/app/components/cifs.component'
|
||||
import { ServerComponent } from 'src/app/components/server.component'
|
||||
import { PasswordDirective } from 'src/app/components/password.directive'
|
||||
import { ApiService, StartOSDiskInfoFull } from 'src/app/services/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@@ -66,6 +66,7 @@ import { StateService } from 'src/app/services/state.service'
|
||||
TuiTitle,
|
||||
DatePipe,
|
||||
ServerComponent,
|
||||
PasswordDirective,
|
||||
],
|
||||
})
|
||||
export default class RecoverPage {
|
||||
|
||||
@@ -2,9 +2,7 @@ import * as jose from 'node-jose'
|
||||
import {
|
||||
DiskInfo,
|
||||
DiskListResponse,
|
||||
FollowLogsReq,
|
||||
FollowLogsRes,
|
||||
Log,
|
||||
PartitionInfo,
|
||||
StartOSDiskInfo,
|
||||
} from '@start9labs/shared'
|
||||
@@ -25,11 +23,8 @@ export abstract class ApiService {
|
||||
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>
|
||||
abstract followServerLogs(): Promise<FollowLogsRes> // setup.logs.follow
|
||||
abstract openWebsocket$<T>(guid: string): Observable<T>
|
||||
|
||||
async encrypt(toEncrypt: string): Promise<T.EncryptedWire> {
|
||||
if (!this.pubkey) throw new Error('No pubkey found!')
|
||||
|
||||
@@ -3,11 +3,9 @@ import { Inject, Injectable } from '@angular/core'
|
||||
import {
|
||||
DiskListResponse,
|
||||
encodeBase64,
|
||||
FollowLogsReq,
|
||||
FollowLogsRes,
|
||||
HttpService,
|
||||
isRpcError,
|
||||
Log,
|
||||
RpcError,
|
||||
RPCOptions,
|
||||
StartOSDiskInfo,
|
||||
@@ -15,7 +13,7 @@ import {
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import * as jose from 'node-jose'
|
||||
import { Observable } from 'rxjs'
|
||||
import { webSocket, WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||
import { webSocket } from 'rxjs/webSocket'
|
||||
import { ApiService } from './api.service'
|
||||
|
||||
@Injectable({
|
||||
@@ -29,12 +27,13 @@ export class LiveApiService extends ApiService {
|
||||
super()
|
||||
}
|
||||
|
||||
openProgressWebsocket$(guid: string): Observable<T.FullProgress> {
|
||||
openWebsocket$<T>(guid: string): Observable<T> {
|
||||
const { location } = this.document.defaultView!
|
||||
const protocol = location.protocol === 'http:' ? 'ws' : 'wss'
|
||||
const host = location.host
|
||||
|
||||
return webSocket({
|
||||
url: `ws://${host}/ws/rpc/${guid}`,
|
||||
url: `${protocol}://${host}/ws/rpc/${guid}`,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -99,12 +98,8 @@ export class LiveApiService extends ApiService {
|
||||
})
|
||||
}
|
||||
|
||||
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 followServerLogs(): Promise<FollowLogsRes> {
|
||||
return this.rpcRequest({ method: 'setup.logs.follow', params: {} })
|
||||
}
|
||||
|
||||
async complete(): Promise<T.SetupResult> {
|
||||
|
||||
@@ -2,16 +2,13 @@ import { Injectable } from '@angular/core'
|
||||
import {
|
||||
DiskListResponse,
|
||||
encodeBase64,
|
||||
FollowLogsReq,
|
||||
FollowLogsRes,
|
||||
Log,
|
||||
pauseFor,
|
||||
StartOSDiskInfo,
|
||||
} from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import * as jose from 'node-jose'
|
||||
import { interval, map, Observable, of } from 'rxjs'
|
||||
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||
import { interval, map, Observable } from 'rxjs'
|
||||
import { ApiService } from './api.service'
|
||||
|
||||
@Injectable({
|
||||
@@ -47,68 +44,102 @@ export class MockApiService extends ApiService {
|
||||
|
||||
// websocket
|
||||
|
||||
openProgressWebsocket$(guid: string): Observable<T.FullProgress> {
|
||||
return of(PROGRESS)
|
||||
// const numPhases = PROGRESS.phases.length
|
||||
// oldMockProgress$(): Promise<T.FullProgress> {
|
||||
// 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
|
||||
// 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
|
||||
// }
|
||||
// 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 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
|
||||
// return interval(1000).pipe(
|
||||
// takeWhile(() => done < total),
|
||||
// map(() => {
|
||||
// done += step
|
||||
|
||||
// console.error(done)
|
||||
// console.error(done)
|
||||
|
||||
// if (
|
||||
// full.overall &&
|
||||
// typeof full.overall === 'object' &&
|
||||
// full.overall.total
|
||||
// ) {
|
||||
// const step = full.overall.total / numPhases / 4
|
||||
// if (
|
||||
// full.overall &&
|
||||
// typeof full.overall === 'object' &&
|
||||
// full.overall.total
|
||||
// ) {
|
||||
// const step = full.overall.total / numPhases / 4
|
||||
|
||||
// full.overall.done += step
|
||||
// }
|
||||
// full.overall.done += step
|
||||
// }
|
||||
|
||||
// if (done === total) {
|
||||
// full.phases[i].progress = true
|
||||
// if (done === total) {
|
||||
// full.phases[i].progress = true
|
||||
|
||||
// if (i === numPhases - 1) {
|
||||
// full.overall = true
|
||||
// }
|
||||
// }
|
||||
// return full
|
||||
// }),
|
||||
// )
|
||||
// }
|
||||
// }, full),
|
||||
// ),
|
||||
// ),
|
||||
// )
|
||||
// if (i === numPhases - 1) {
|
||||
// full.overall = true
|
||||
// }
|
||||
// }
|
||||
// return full
|
||||
// }),
|
||||
// )
|
||||
// }
|
||||
// }, full),
|
||||
// ),
|
||||
// ),
|
||||
// )
|
||||
// }
|
||||
|
||||
openWebsocket$<T>(guid: string): Observable<T> {
|
||||
if (guid === 'logs-guid') {
|
||||
return interval(500).pipe(
|
||||
map(() => ({
|
||||
timestamp: new Date().toISOString(),
|
||||
message: 'fake log entry',
|
||||
bootId: 'boot-id',
|
||||
})),
|
||||
) as Observable<T>
|
||||
} else if (guid === 'progress-guid') {
|
||||
// @TODO mock progress
|
||||
return interval(1000).pipe(
|
||||
map(() => ({
|
||||
overall: true,
|
||||
phases: [
|
||||
{
|
||||
name: 'Preparing Data',
|
||||
progress: true,
|
||||
},
|
||||
{
|
||||
name: 'Transferring Data',
|
||||
progress: true,
|
||||
},
|
||||
{
|
||||
name: 'Finalizing Setup',
|
||||
progress: true,
|
||||
},
|
||||
],
|
||||
})),
|
||||
) as Observable<T>
|
||||
} else {
|
||||
throw new Error('invalid guid type')
|
||||
}
|
||||
}
|
||||
|
||||
private statusIndex = 0
|
||||
@@ -270,24 +301,14 @@ export class MockApiService extends ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
async followServerLogs(params: FollowLogsReq): Promise<FollowLogsRes> {
|
||||
async followServerLogs(): Promise<FollowLogsRes> {
|
||||
await pauseFor(1000)
|
||||
return {
|
||||
startCursor: 'fakestartcursor',
|
||||
guid: 'fake-guid',
|
||||
guid: 'logs-guid',
|
||||
}
|
||||
}
|
||||
|
||||
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
|
||||
return interval(500).pipe(
|
||||
map(() => ({
|
||||
timestamp: new Date().toISOString(),
|
||||
message: 'fake log entry',
|
||||
bootId: 'boot-id',
|
||||
})),
|
||||
)
|
||||
}
|
||||
|
||||
async complete(): Promise<T.SetupResult> {
|
||||
await pauseFor(1000)
|
||||
return {
|
||||
|
||||
@@ -7,10 +7,9 @@
|
||||
"@angular/router": "^17.0.6",
|
||||
"@ng-web-apis/mutation-observer": ">=4.0.0",
|
||||
"@ng-web-apis/resize-observer": ">=4.0.0",
|
||||
"@start9labs/emver": "^0.1.5",
|
||||
"@taiga-ui/cdk": "4.0.0-rc.6",
|
||||
"@taiga-ui/core": "4.0.0-rc.6",
|
||||
"@taiga-ui/experimental": "4.0.0-rc.6",
|
||||
"@taiga-ui/cdk": "4.0.0-rc.7",
|
||||
"@taiga-ui/core": "4.0.0-rc.7",
|
||||
"@taiga-ui/layout": "4.0.0-rc.7",
|
||||
"@tinkoff/ng-dompurify": ">=4.0.0",
|
||||
"ansi-to-html": "^0.7.2"
|
||||
},
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { DatePipe } from '@angular/common'
|
||||
import { Component, ElementRef, inject, input, Output } from '@angular/core'
|
||||
import { StartOSDiskInfo } from '@start9labs/shared'
|
||||
import { Component, inject, input } from '@angular/core'
|
||||
import { TuiDialogService, TuiIcon, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiCell } from '@taiga-ui/layout'
|
||||
import { filter, fromEvent, switchMap } from 'rxjs'
|
||||
import { PASSWORD } from 'src/app/components/password.component'
|
||||
import { StartOSDiskInfo } from '../types/api'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
@@ -31,16 +29,4 @@ export class ServerComponent {
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
|
||||
readonly server = input.required<StartOSDiskInfo>()
|
||||
|
||||
@Output()
|
||||
readonly password = fromEvent(inject(ElementRef).nativeElement, 'click').pipe(
|
||||
switchMap(() =>
|
||||
this.dialogs.open<string>(PASSWORD, {
|
||||
label: 'Unlock Drive',
|
||||
size: 's',
|
||||
data: { passwordHash: this.server().passwordHash },
|
||||
}),
|
||||
),
|
||||
filter(Boolean),
|
||||
)
|
||||
}
|
||||
@@ -15,6 +15,7 @@ export * from './components/markdown/markdown.component.module'
|
||||
export * from './components/ticker/ticker.component'
|
||||
export * from './components/ticker/ticker.module'
|
||||
export * from './components/drive.component'
|
||||
export * from './components/server.component'
|
||||
|
||||
export * from './directives/drag-scroller.directive'
|
||||
export * from './directives/safe-links.directive'
|
||||
@@ -50,6 +51,7 @@ export * from './tokens/theme'
|
||||
export * from './util/base-64'
|
||||
export * from './util/convert-ansi'
|
||||
export * from './util/copy-to-clipboard'
|
||||
export * from './util/format-progress'
|
||||
export * from './util/get-new-entries'
|
||||
export * from './util/get-pkg-id'
|
||||
export * from './util/invert'
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { StaticClassProvider } from '@angular/core'
|
||||
import { bufferTime, defer, map, Observable, scan, switchMap } from 'rxjs'
|
||||
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||
import { FollowLogsReq, FollowLogsRes, Log } from '../types/api'
|
||||
import { Constructor } from '../types/constructor'
|
||||
import { convertAnsi } from '../util/convert-ansi'
|
||||
|
||||
interface Api {
|
||||
followServerLogs: (params: FollowLogsReq) => Promise<FollowLogsRes>
|
||||
openLogsWebsocket$: (config: WebSocketSubjectConfig<Log>) => Observable<Log>
|
||||
openWebsocket$: (guid: string) => Observable<Log>
|
||||
}
|
||||
|
||||
export function provideSetupLogsService(
|
||||
@@ -22,9 +21,7 @@ export function provideSetupLogsService(
|
||||
|
||||
export class SetupLogsService extends Observable<readonly string[]> {
|
||||
private readonly log$ = defer(() => this.api.followServerLogs({})).pipe(
|
||||
switchMap(({ guid }) =>
|
||||
this.api.openLogsWebsocket$({ url: `/rpc/${guid}` }),
|
||||
),
|
||||
switchMap(({ guid }) => this.api.openWebsocket$(guid)),
|
||||
bufferTime(1000),
|
||||
map(convertAnsi),
|
||||
scan((logs: readonly string[], log) => [...logs, log], []),
|
||||
|
||||
33
web/projects/shared/src/util/format-progress.ts
Normal file
33
web/projects/shared/src/util/format-progress.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
// @TODO get types from sdk
|
||||
type Progress = null | boolean | { done: number; total: number | null }
|
||||
type NamedProgress = { name: string; progress: Progress }
|
||||
type FullProgress = { overall: Progress; phases: Array<NamedProgress> }
|
||||
|
||||
export function formatProgress({ phases, overall }: FullProgress): {
|
||||
total: number
|
||||
message: string
|
||||
} {
|
||||
return {
|
||||
total: getDecimal(overall),
|
||||
message: phases
|
||||
.filter(p => p.progress !== true && p.progress !== null)
|
||||
.map(p => `${p.name}${getPhaseBytes(p.progress)}`)
|
||||
.join(', '),
|
||||
}
|
||||
}
|
||||
|
||||
function getDecimal(progress: 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: Progress): string {
|
||||
return progress === true || !progress
|
||||
? ''
|
||||
: `: (${progress.done}/${progress.total})`
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { PatchMonitorService } from './services/patch-monitor.service'
|
||||
})
|
||||
export class AppComponent implements OnInit {
|
||||
private readonly title = inject(Title)
|
||||
private readonly patch = inject(PatchDB<DataModel>)
|
||||
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||
|
||||
readonly auth = inject(AuthService)
|
||||
readonly theme$ = inject(THEME)
|
||||
@@ -29,7 +29,7 @@ export class AppComponent implements OnInit {
|
||||
.subscribe()
|
||||
|
||||
readonly offline$ = combineLatest([
|
||||
inject(ConnectionService).connected$,
|
||||
inject(ConnectionService),
|
||||
this.auth.isVerified$,
|
||||
this.patch
|
||||
.watch$('serverInfo', 'statusInfo')
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { RELATIVE_URL, THEME, WorkspaceConfig } from '@start9labs/shared'
|
||||
import {
|
||||
TUI_DATE_FORMAT,
|
||||
TUI_DIALOGS_CLOSE,
|
||||
tuiButtonOptionsProvider,
|
||||
tuiDropdownOptionsProvider,
|
||||
tuiNumberFormatProvider,
|
||||
@@ -19,7 +20,13 @@ import {
|
||||
TUI_DATE_VALUE_TRANSFORMER,
|
||||
} from '@taiga-ui/kit'
|
||||
import { tuiTextfieldOptionsProvider } from '@taiga-ui/legacy'
|
||||
import { PATCH_DB_PROVIDERS } from 'src/app/services/patch-db/patch-db.providers'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { filter, pairwise } from 'rxjs'
|
||||
import {
|
||||
PATCH_CACHE,
|
||||
PatchDbSource,
|
||||
} from 'src/app/services/patch-db/patch-db-source'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { ApiService } from './services/api/embassy-api.service'
|
||||
import { LiveApiService } from './services/api/embassy-live-api.service'
|
||||
import { MockApiService } from './services/api/embassy-mock-api.service'
|
||||
@@ -29,8 +36,8 @@ import { ClientStorageService } from './services/client-storage.service'
|
||||
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'
|
||||
import { ThemeSwitcherService } from './services/theme-switcher.service'
|
||||
|
||||
const {
|
||||
useMocks,
|
||||
@@ -38,7 +45,6 @@ const {
|
||||
} = require('../../../../config.json') as WorkspaceConfig
|
||||
|
||||
export const APP_PROVIDERS: Provider[] = [
|
||||
PATCH_DB_PROVIDERS,
|
||||
NG_EVENT_PLUGINS,
|
||||
FilterPackagesPipe,
|
||||
UntypedFormBuilder,
|
||||
|
||||
@@ -1,167 +0,0 @@
|
||||
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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
<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>
|
||||
@@ -1,50 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
<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>
|
||||
@@ -1,33 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
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)],
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
<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>
|
||||
@@ -1,11 +0,0 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 0.5rem;
|
||||
|
||||
:first-child {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
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',
|
||||
}),
|
||||
},
|
||||
]
|
||||
@@ -1,43 +0,0 @@
|
||||
<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>
|
||||
@@ -1,36 +0,0 @@
|
||||
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)),
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<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>
|
||||
@@ -1,35 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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,
|
||||
}),
|
||||
]
|
||||
@@ -1,18 +0,0 @@
|
||||
<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>
|
||||
@@ -1,49 +0,0 @@
|
||||
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]) || []
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<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>
|
||||
@@ -1,11 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<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>
|
||||
@@ -1,41 +0,0 @@
|
||||
@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);
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
<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>
|
||||
@@ -1,30 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
<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>
|
||||
@@ -1,8 +0,0 @@
|
||||
.button {
|
||||
pointer-events: auto;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.masked {
|
||||
-webkit-text-security: disc;
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
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 || '')
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<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>
|
||||
@@ -1,12 +0,0 @@
|
||||
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
|
||||
> {}
|
||||
@@ -1,11 +0,0 @@
|
||||
{{ 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>
|
||||
@@ -1,10 +0,0 @@
|
||||
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> {}
|
||||
@@ -1,11 +0,0 @@
|
||||
<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>
|
||||
@@ -1,8 +0,0 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.group {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
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 {}
|
||||
@@ -1,21 +0,0 @@
|
||||
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')
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ export class NotificationsToastComponent {
|
||||
|
||||
readonly visible$: Observable<boolean> = merge(
|
||||
this.dismiss$,
|
||||
inject(PatchDB<DataModel>)
|
||||
inject<PatchDB<DataModel>>(PatchDB)
|
||||
.watch$('serverInfo', 'unreadNotifications', 'count')
|
||||
.pipe(
|
||||
pairwise(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { SwUpdate } from '@angular/service-worker'
|
||||
import { Emver, LoadingService } from '@start9labs/shared'
|
||||
import { Exver, LoadingService } from '@start9labs/shared'
|
||||
import { TuiAutoFocus } from '@taiga-ui/cdk'
|
||||
import { TuiButton, TuiDialog } from '@taiga-ui/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
@@ -47,16 +47,16 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
export class RefreshAlertComponent {
|
||||
private readonly updates = inject(SwUpdate)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly emver = inject(Emver)
|
||||
private readonly exver = inject(Exver)
|
||||
private readonly config = inject(ConfigService)
|
||||
private readonly dismiss$ = new Subject<boolean>()
|
||||
|
||||
readonly show$ = merge(
|
||||
this.dismiss$,
|
||||
inject(PatchDB<DataModel>)
|
||||
inject<PatchDB<DataModel>>(PatchDB)
|
||||
.watch$('serverInfo', 'version')
|
||||
.pipe(
|
||||
map(version => !!this.emver.compare(this.config.version, version)),
|
||||
map(version => !!this.exver.compareExver(this.config.version, version)),
|
||||
endWith(false),
|
||||
),
|
||||
).pipe(debounceTime(0))
|
||||
|
||||
@@ -53,7 +53,7 @@ export class UpdateToastComponent {
|
||||
|
||||
readonly visible$: Observable<boolean> = merge(
|
||||
this.dismiss$,
|
||||
inject(PatchDB<DataModel>)
|
||||
inject<PatchDB<DataModel>>(PatchDB)
|
||||
.watch$('serverInfo', 'statusInfo', 'updated')
|
||||
.pipe(distinctUntilChanged(), filter(Boolean), endWith(false)),
|
||||
)
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
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 {}
|
||||
@@ -1,35 +0,0 @@
|
||||
<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>
|
||||
@@ -1,118 +0,0 @@
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,261 +0,0 @@
|
||||
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)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -25,9 +25,9 @@ import { StoreIconComponent } from './store-icon.component'
|
||||
</div>
|
||||
<tui-icon
|
||||
*ngIf="registry.selected; else content"
|
||||
icon="tuiIconCheck"
|
||||
icon="@tui.check"
|
||||
[style.color]="'var(--tui-positive)'"
|
||||
></tui-icon>
|
||||
/>
|
||||
<ng-template #content><ng-content></ng-content></ng-template>
|
||||
`,
|
||||
styles: [':host { border-radius: 0.25rem; width: stretch; }'],
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
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')
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
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 {}
|
||||
@@ -1,222 +0,0 @@
|
||||
<<<<<<<< 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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 {}
|
||||
@@ -1,18 +0,0 @@
|
||||
<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>
|
||||
@@ -1,23 +0,0 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user