mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
chore: refactor interfaces and remove UI routes (#2560)
This commit is contained in:
@@ -53,7 +53,6 @@
|
||||
(click)="onResize(drawer)"
|
||||
></button>
|
||||
</div>
|
||||
<widgets *ngIf="drawer.open" [wide]="drawer.width === 600" />
|
||||
</ion-menu>
|
||||
|
||||
<ion-router-outlet
|
||||
|
||||
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.9 KiB |
@@ -20,7 +20,6 @@ import {
|
||||
TuiRootModule,
|
||||
TuiThemeNightModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { WidgetsPageModule } from 'src/app/apps/ui/pages/widgets/widgets.module'
|
||||
import { environment } from '../environments/environment'
|
||||
import { AppComponent } from './app.component'
|
||||
import { APP_PROVIDERS } from './app.providers'
|
||||
@@ -58,7 +57,6 @@ import { RoutingModule } from './routing.module'
|
||||
TuiAlertModule,
|
||||
TuiModeModule,
|
||||
TuiThemeNightModule,
|
||||
WidgetsPageModule,
|
||||
ResponsiveColViewportDirective,
|
||||
DarkThemeModule,
|
||||
LightThemeModule,
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
import { NgIf } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { filter } from 'rxjs'
|
||||
import {
|
||||
FormComponent,
|
||||
FormContext,
|
||||
} from 'src/app/apps/portal/components/form.component'
|
||||
import {
|
||||
getClearnetSpec,
|
||||
REMOVE,
|
||||
} from 'src/app/apps/portal/components/interfaces/interface.utils'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { DomainInfo, NetworkInfo } from 'src/app/services/patch-db/data-model'
|
||||
import { getClearnetAddress } from 'src/app/util/clearnetAddress'
|
||||
import { InterfaceComponent } from './interface.component'
|
||||
import { InterfacesComponent } from './interfaces.component'
|
||||
|
||||
type ClearnetForm = {
|
||||
domain: string
|
||||
subdomain: string | null
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-interface-clearnet',
|
||||
template: `
|
||||
<em>
|
||||
Add clearnet to expose this interface to the public Internet.
|
||||
<a
|
||||
href="https://docs.start9.com/latest/user-manual/interface-addresses#clearnet"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<strong>View instructions</strong>
|
||||
</a>
|
||||
</em>
|
||||
<ng-container
|
||||
*ngIf="interfaces.addressInfo.domainInfo as domainInfo; else noClearnet"
|
||||
>
|
||||
<app-interface
|
||||
label="Clearnet"
|
||||
[hostname]="getClearnet(domainInfo)"
|
||||
[isUi]="interfaces.isUi"
|
||||
/>
|
||||
<div [style.display]="'flex'" [style.gap.rem]="1">
|
||||
<button tuiButton size="s" (click)="add()">Update</button>
|
||||
<button tuiButton size="s" appearance="danger-solid" (click)="remove()">
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
<ng-template #noClearnet>
|
||||
<button
|
||||
tuiButton
|
||||
iconLeft="tuiIconPlus"
|
||||
[style.align-self]="'flex-start'"
|
||||
(click)="add()"
|
||||
>
|
||||
Add Clearnet
|
||||
</button>
|
||||
</ng-template>
|
||||
`,
|
||||
imports: [InterfaceComponent, NgIf, TuiButtonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class InterfaceClearnetComponent {
|
||||
private readonly formDialog = inject(FormDialogService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
readonly interfaces = inject(InterfacesComponent)
|
||||
|
||||
@Input({ required: true }) network!: NetworkInfo
|
||||
|
||||
getClearnet(clearnet: DomainInfo): string {
|
||||
return getClearnetAddress('https', clearnet)
|
||||
}
|
||||
|
||||
async add() {
|
||||
const { domainInfo } = this.interfaces.addressInfo
|
||||
const { domain = '', subdomain = '' } = domainInfo || {}
|
||||
const options: Partial<TuiDialogOptions<FormContext<ClearnetForm>>> = {
|
||||
label: 'Select Domain/Subdomain',
|
||||
data: {
|
||||
value: { domain, subdomain },
|
||||
spec: await getClearnetSpec(this.network),
|
||||
buttons: [
|
||||
{
|
||||
text: 'Manage domains',
|
||||
link: 'portal/system/settings/domains',
|
||||
},
|
||||
{
|
||||
text: 'Save',
|
||||
handler: async value => this.save(value),
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
this.formDialog.open(FormComponent, options)
|
||||
}
|
||||
|
||||
remove() {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, REMOVE)
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(async () => {
|
||||
const loader = this.loader.open('Removing...').subscribe()
|
||||
|
||||
try {
|
||||
if (this.interfaces.packageContext) {
|
||||
await this.api.setInterfaceClearnetAddress({
|
||||
...this.interfaces.packageContext,
|
||||
domainInfo: null,
|
||||
})
|
||||
} else {
|
||||
await this.api.setServerClearnetAddress({ domainInfo: null })
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async save(domainInfo: ClearnetForm): Promise<boolean> {
|
||||
const loader = this.loader.open('Saving...').subscribe()
|
||||
|
||||
try {
|
||||
if (this.interfaces.packageContext) {
|
||||
await this.api.setInterfaceClearnetAddress({
|
||||
...this.interfaces.packageContext,
|
||||
domainInfo,
|
||||
})
|
||||
} else {
|
||||
await this.api.setServerClearnetAddress({ domainInfo })
|
||||
}
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
return false
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { InterfacesComponent } from './interfaces.component'
|
||||
import { InterfaceComponent } from './interface.component'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-interface-local',
|
||||
template: `
|
||||
<em>
|
||||
Local addresses can only be accessed while connected to the same Local
|
||||
Area Network (LAN) as your server, either directly or using a VPN.
|
||||
<a
|
||||
href="https://docs.start9.com/latest/user-manual/interface-addresses#local"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<strong>View instructions</strong>
|
||||
</a>
|
||||
</em>
|
||||
<a
|
||||
*ngIf="!interfaces.packageContext"
|
||||
tuiButton
|
||||
iconLeft="tuiIconDownload"
|
||||
href="/public/eos/local.crt"
|
||||
[download]="interfaces.addressInfo.lanHostname + '.crt'"
|
||||
[style.align-self]="'flex-start'"
|
||||
>
|
||||
Download Root CA
|
||||
</a>
|
||||
<app-interface
|
||||
label="Local"
|
||||
[hostname]="interfaces.addressInfo.lanHostname"
|
||||
[isUi]="interfaces.isUi"
|
||||
></app-interface>
|
||||
<ng-container
|
||||
*ngFor="let iface of interfaces.addressInfo.ipInfo | keyvalue"
|
||||
>
|
||||
<app-interface
|
||||
*ngIf="iface.value.ipv4 as ipv4"
|
||||
[label]="iface.key + ' (IPv4)'"
|
||||
[hostname]="ipv4"
|
||||
[isUi]="interfaces.isUi"
|
||||
></app-interface>
|
||||
<app-interface
|
||||
*ngIf="iface.value.ipv6 as ipv6"
|
||||
[label]="iface.key + ' (IPv6)'"
|
||||
[hostname]="ipv6"
|
||||
[isUi]="interfaces.isUi"
|
||||
></app-interface>
|
||||
</ng-container>
|
||||
`,
|
||||
imports: [InterfaceComponent, CommonModule, TuiButtonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class InterfaceLocalComponent {
|
||||
readonly interfaces = inject(InterfacesComponent)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { InterfaceComponent } from './interface.component'
|
||||
import { InterfacesComponent } from './interfaces.component'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-interface-tor',
|
||||
template: `
|
||||
<em>
|
||||
Use a Tor-enabled browser to access this address. Tor connections can be
|
||||
slow and unreliable.
|
||||
<a
|
||||
href="https://docs.start9.com/latest/user-manual/interface-addresses#tor"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<strong>View instructions</strong>
|
||||
</a>
|
||||
</em>
|
||||
<app-interface
|
||||
label="Tor"
|
||||
[hostname]="interfaces.addressInfo.torHostname"
|
||||
[isUi]="interfaces.isUi"
|
||||
></app-interface>
|
||||
`,
|
||||
imports: [InterfaceComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class InterfaceTorComponent {
|
||||
readonly interfaces = inject(InterfacesComponent)
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NgIf } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { WINDOW } from '@ng-web-apis/common'
|
||||
import { CopyService } from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import {
|
||||
TuiButtonModule,
|
||||
TuiCellModule,
|
||||
TuiTitleModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { QRComponent } from 'src/app/common/qr/qr.component'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-interface',
|
||||
template: `
|
||||
<div tuiCell>
|
||||
<h3 tuiTitle>
|
||||
{{ label }}
|
||||
<span tuiSubtitle>{{ hostname }}</span>
|
||||
</h3>
|
||||
<button
|
||||
*ngIf="isUi"
|
||||
tuiIconButton
|
||||
iconLeft="tuiIconExternalLink"
|
||||
appearance="icon"
|
||||
(click)="launch(hostname)"
|
||||
>
|
||||
Launch
|
||||
</button>
|
||||
<button
|
||||
tuiIconButton
|
||||
iconLeft="tuiIconGrid"
|
||||
appearance="icon"
|
||||
(click)="showQR(hostname)"
|
||||
>
|
||||
Show QR code
|
||||
</button>
|
||||
<button
|
||||
tuiIconButton
|
||||
iconLeft="tuiIconCopy"
|
||||
appearance="icon"
|
||||
(click)="copyService.copy(hostname)"
|
||||
>
|
||||
Copy QR code
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
imports: [NgIf, TuiCellModule, TuiTitleModule, TuiButtonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class InterfaceComponent {
|
||||
private readonly window = inject(WINDOW)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
readonly copyService = inject(CopyService)
|
||||
|
||||
@Input({ required: true }) label = ''
|
||||
@Input({ required: true }) hostname = ''
|
||||
@Input({ required: true }) isUi = false
|
||||
|
||||
launch(url: string): void {
|
||||
this.window.open(url, '_blank', 'noreferrer')
|
||||
}
|
||||
|
||||
showQR(data: string) {
|
||||
this.dialogs
|
||||
.open(new PolymorpheusComponent(QRComponent), {
|
||||
size: 'auto',
|
||||
data,
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Config } from '@start9labs/start-sdk/lib/config/builder/config'
|
||||
import { Value } from '@start9labs/start-sdk/lib/config/builder/value'
|
||||
import { InputSpec } from '@start9labs/start-sdk/lib/config/configTypes'
|
||||
import { TuiDialogOptions } from '@taiga-ui/core'
|
||||
import { TuiPromptData } from '@taiga-ui/kit'
|
||||
import { NetworkInfo } from 'src/app/services/patch-db/data-model'
|
||||
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec'
|
||||
|
||||
export const REMOVE: Partial<TuiDialogOptions<TuiPromptData>> = {
|
||||
label: 'Confirm',
|
||||
size: 's',
|
||||
data: {
|
||||
content: 'Remove clearnet address?',
|
||||
yes: 'Remove',
|
||||
no: 'Cancel',
|
||||
},
|
||||
}
|
||||
|
||||
export function getClearnetSpec({
|
||||
domains,
|
||||
start9ToSubdomain,
|
||||
}: NetworkInfo): Promise<InputSpec> {
|
||||
const start9ToDomain = `${start9ToSubdomain?.value}.start9.to`
|
||||
const base = start9ToSubdomain ? { [start9ToDomain]: start9ToDomain } : {}
|
||||
|
||||
const values = domains.reduce((prev, curr) => {
|
||||
return {
|
||||
[curr.value]: curr.value,
|
||||
...prev,
|
||||
}
|
||||
}, base)
|
||||
|
||||
return configBuilderToSpec(
|
||||
Config.of({
|
||||
domain: Value.select({
|
||||
name: 'Domain',
|
||||
required: { default: null },
|
||||
values,
|
||||
}),
|
||||
subdomain: Value.text({
|
||||
name: 'Subdomain',
|
||||
required: false,
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { TuiCardModule, TuiSurfaceModule } from '@taiga-ui/experimental'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { InterfaceClearnetComponent } from 'src/app/apps/portal/components/interfaces/interface-clearnet.component'
|
||||
import { InterfaceLocalComponent } from 'src/app/apps/portal/components/interfaces/interface-local.component'
|
||||
import { InterfaceTorComponent } from 'src/app/apps/portal/components/interfaces/interface-tor.component'
|
||||
import { AddressInfo, DataModel } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-interfaces',
|
||||
template: `
|
||||
<h3 class="g-title">Clearnet</h3>
|
||||
<app-interface-clearnet
|
||||
*ngIf="network$ | async as network"
|
||||
tuiCardLarge="compact"
|
||||
tuiSurface="elevated"
|
||||
[network]="network"
|
||||
/>
|
||||
|
||||
<h3 class="g-title">Tor</h3>
|
||||
<app-interface-tor tuiCardLarge="compact" tuiSurface="elevated" />
|
||||
|
||||
<h3 class="g-title">Local</h3>
|
||||
<app-interface-local tuiCardLarge="compact" tuiSurface="elevated" />
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
InterfaceTorComponent,
|
||||
InterfaceLocalComponent,
|
||||
InterfaceClearnetComponent,
|
||||
TuiCardModule,
|
||||
TuiSurfaceModule,
|
||||
],
|
||||
})
|
||||
export class InterfacesComponent {
|
||||
readonly network$ = inject(PatchDB<DataModel>).watch$(
|
||||
'server-info',
|
||||
'network',
|
||||
)
|
||||
|
||||
@Input() packageContext?: {
|
||||
packageId: string
|
||||
interfaceId: string
|
||||
}
|
||||
@Input({ required: true }) addressInfo!: AddressInfo
|
||||
@Input({ required: true }) isUi!: boolean
|
||||
}
|
||||
@@ -1,23 +1,23 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { InterfaceAddressesComponentModule } from 'src/app/common/interface-addresses/interface-addresses.module'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { getPkgId } from '@start9labs/shared'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { InterfacesComponent } from 'src/app/apps/portal/components/interfaces/interfaces.component'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<interface-addresses
|
||||
<app-interfaces
|
||||
*ngIf="interfaceInfo$ | async as interfaceInfo"
|
||||
[packageContext]="context"
|
||||
[addressInfo]="interfaceInfo.addressInfo"
|
||||
[isUi]="interfaceInfo.type === 'ui'"
|
||||
></interface-addresses>
|
||||
/>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [CommonModule, InterfaceAddressesComponentModule],
|
||||
imports: [CommonModule, InterfacesComponent],
|
||||
})
|
||||
export class ServiceInterfaceRoute {
|
||||
private readonly route = inject(ActivatedRoute)
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { InterfaceAddressesComponentModule } from 'src/app/common/interface-addresses/interface-addresses.module'
|
||||
import { InterfacesComponent } from 'src/app/apps/portal/components/interfaces/interfaces.component'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<interface-addresses
|
||||
<app-interfaces
|
||||
*ngIf="ui$ | async as ui"
|
||||
[style.max-width.rem]="50"
|
||||
[addressInfo]="ui"
|
||||
[isUi]="true"
|
||||
></interface-addresses>
|
||||
/>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [CommonModule, InterfaceAddressesComponentModule],
|
||||
imports: [CommonModule, InterfacesComponent],
|
||||
})
|
||||
export class SettingsInterfacesComponent {
|
||||
readonly ui$ = inject(PatchDB<DataModel>).watch$('server-info', 'ui')
|
||||
|
||||
@@ -24,18 +24,18 @@ import {
|
||||
TuiToggleModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { catchError, defer, merge, Observable, of, Subject, map } from 'rxjs'
|
||||
import { catchError, defer, map, merge, Observable, of, Subject } from 'rxjs'
|
||||
import {
|
||||
FormComponent,
|
||||
FormContext,
|
||||
} from 'src/app/apps/portal/components/form.component'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { WifiInfoComponent } from './info.component'
|
||||
import { WifiTableComponent } from './table.component'
|
||||
import { parseWifi, WifiData, WiFiForm } from './utils'
|
||||
import { wifiSpec } from '../../../../../../ui/pages/system/wifi/wifi.const'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { wifiSpec } from './wifi.const'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<ion-item-group>
|
||||
<ion-item-divider>
|
||||
Completed: {{ timestamp | date : 'medium' }}
|
||||
</ion-item-divider>
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>System data</h2>
|
||||
<p>
|
||||
<ion-text [color]="system.color">{{ system.result }}</ion-text>
|
||||
</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" [name]="system.icon" [color]="system.color"></ion-icon>
|
||||
</ion-item>
|
||||
<ion-item
|
||||
*ngFor="let pkg of report?.packages | keyvalue"
|
||||
style="--background: transparent"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>{{ pkg.key }}</h2>
|
||||
<p>
|
||||
<ion-text [color]="pkg.value.error ? 'danger' : 'success'">
|
||||
{{ pkg.value.error ? 'Failed: ' + pkg.value.error : 'Succeeded' }}
|
||||
</ion-text>
|
||||
</p>
|
||||
</ion-label>
|
||||
<ion-icon
|
||||
slot="end"
|
||||
[name]="pkg.value.error ? 'remove-circle-outline' : 'checkmark'"
|
||||
[color]="pkg.value.error ? 'danger' : 'success'"
|
||||
></ion-icon>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
@@ -1,52 +0,0 @@
|
||||
import { Component, Inject } from '@angular/core'
|
||||
import { BackupReport } from 'src/app/services/api/api.types'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
|
||||
@Component({
|
||||
selector: 'backup-report',
|
||||
templateUrl: './backup-report.component.html',
|
||||
})
|
||||
export class BackupReportComponent {
|
||||
readonly system: {
|
||||
result: string
|
||||
icon: 'remove' | 'remove-circle-outline' | 'checkmark'
|
||||
color: 'dark' | 'danger' | 'success'
|
||||
}
|
||||
|
||||
constructor(
|
||||
@Inject(POLYMORPHEUS_CONTEXT)
|
||||
private readonly context: TuiDialogContext<
|
||||
void,
|
||||
{ report: BackupReport; timestamp: string }
|
||||
>,
|
||||
) {
|
||||
if (!this.report.server.attempted) {
|
||||
this.system = {
|
||||
result: 'Not Attempted',
|
||||
icon: 'remove',
|
||||
color: 'dark',
|
||||
}
|
||||
} else if (this.report.server.error) {
|
||||
this.system = {
|
||||
result: `Failed: ${this.report.server.error}`,
|
||||
icon: 'remove-circle-outline',
|
||||
color: 'danger',
|
||||
}
|
||||
} else {
|
||||
this.system = {
|
||||
result: 'Succeeded',
|
||||
icon: 'checkmark',
|
||||
color: 'success',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get report(): BackupReport {
|
||||
return this.context.data.report
|
||||
}
|
||||
|
||||
get timestamp(): string {
|
||||
return this.context.data.timestamp
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { BackupReportComponent } from './backup-report.component'
|
||||
|
||||
@NgModule({
|
||||
declarations: [BackupReportComponent],
|
||||
imports: [CommonModule, IonicModule],
|
||||
exports: [BackupReportComponent],
|
||||
})
|
||||
export class BackupReportPageModule {}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ReactiveFormsModule } from '@angular/forms'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { TuiValueChangesModule } from '@taiga-ui/cdk'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { TuiModeModule } from '@taiga-ui/core'
|
||||
import { FormModule } from 'src/app/common/form/form.module'
|
||||
import { FormPage } from './form.page'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
TuiValueChangesModule,
|
||||
TuiButtonModule,
|
||||
TuiModeModule,
|
||||
FormModule,
|
||||
],
|
||||
declarations: [FormPage],
|
||||
exports: [FormPage],
|
||||
})
|
||||
export class FormPageModule {}
|
||||
@@ -1,32 +0,0 @@
|
||||
<form
|
||||
(submit.capture.prevent)="0"
|
||||
(reset.capture.prevent.stop)="onReset()"
|
||||
[formGroup]="form"
|
||||
(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>
|
||||
@@ -1,12 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
OnInit,
|
||||
} from '@angular/core'
|
||||
import { InputSpec } from '@start9labs/start-sdk/lib/config/configTypes'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
import { tuiMarkControlAsTouchedAndValidate } from '@taiga-ui/cdk'
|
||||
import { TuiDialogFormService } from '@taiga-ui/kit'
|
||||
import { FormGroup } from '@angular/forms'
|
||||
import { compare, Operation } from 'fast-json-patch'
|
||||
import { InvalidService } from 'src/app/common/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: InputSpec
|
||||
buttons: ActionButton<T>[]
|
||||
value?: T
|
||||
patch?: Operation[]
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'form-page',
|
||||
templateUrl: './form.page.html',
|
||||
styleUrls: ['./form.page.scss'],
|
||||
providers: [InvalidService],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FormPage<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,40 +0,0 @@
|
||||
<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>
|
||||
@@ -1,13 +0,0 @@
|
||||
.warning {
|
||||
color: var(--tui-warning-fill);
|
||||
}
|
||||
|
||||
.button {
|
||||
pointer-events: auto;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.masked {
|
||||
font-family: text-security-disc;
|
||||
-webkit-text-security: disc;
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
|
||||
import {
|
||||
POLYMORPHEUS_CONTEXT,
|
||||
PolymorpheusComponent,
|
||||
} from '@tinkoff/ng-polymorpheus'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
|
||||
@Component({
|
||||
selector: 'prompt',
|
||||
templateUrl: 'prompt.component.html',
|
||||
styleUrls: ['prompt.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PromptComponent {
|
||||
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(PromptComponent)
|
||||
|
||||
export interface PromptOptions {
|
||||
message: string
|
||||
label?: string
|
||||
warning?: string
|
||||
buttonText?: string
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
useMask?: boolean
|
||||
initialValue?: string | null
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { TuiTextfieldControllerModule } from '@taiga-ui/core'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { TuiInputModule } from '@taiga-ui/kit'
|
||||
import { TuiAutoFocusModule } from '@taiga-ui/cdk'
|
||||
import { PromptComponent } from './prompt.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
TuiInputModule,
|
||||
TuiButtonModule,
|
||||
TuiTextfieldControllerModule,
|
||||
TuiAutoFocusModule,
|
||||
],
|
||||
declarations: [PromptComponent],
|
||||
exports: [PromptComponent],
|
||||
})
|
||||
export class PromptModule {}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { Routes, RouterModule } from '@angular/router'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadChildren: () =>
|
||||
import('./pages/backups/backups.module').then(m => m.BackupsPageModule),
|
||||
},
|
||||
{
|
||||
path: 'jobs',
|
||||
loadChildren: () =>
|
||||
import('./pages/backup-jobs/backup-jobs.module').then(
|
||||
m => m.BackupJobsPageModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'targets',
|
||||
loadChildren: () =>
|
||||
import('./pages/backup-targets/backup-targets.module').then(
|
||||
m => m.BackupTargetsPageModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'history',
|
||||
loadChildren: () =>
|
||||
import('./pages/backup-history/backup-history.module').then(
|
||||
m => m.BackupHistoryPageModule,
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class BackupsModule {}
|
||||
@@ -1,60 +0,0 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button defaultHref="system"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Backup Progress</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding with-widgets">
|
||||
<ion-grid *ngIf="pkgs$ | async as pkgs">
|
||||
<ion-row *ngIf="backupProgress$ | async as backupProgress">
|
||||
<ion-col>
|
||||
<ion-item-group>
|
||||
<ng-container *ngFor="let pkg of pkgs | keyvalue">
|
||||
<ion-item *ngIf="backupProgress[pkg.key] as pkgProgress">
|
||||
<ion-avatar slot="start">
|
||||
<img [src]="pkg.value.icon" />
|
||||
</ion-avatar>
|
||||
<ion-label>{{ pkg.value.manifest.title }}</ion-label>
|
||||
<!-- complete -->
|
||||
<ion-note
|
||||
*ngIf="pkgProgress.complete; else incomplete"
|
||||
class="inline"
|
||||
slot="end"
|
||||
>
|
||||
<ion-icon name="checkmark" color="success"></ion-icon>
|
||||
|
||||
<ion-text color="success">Complete</ion-text>
|
||||
</ion-note>
|
||||
<!-- incomplete -->
|
||||
<ng-template #incomplete>
|
||||
<ng-container
|
||||
*ngIf="pkg.key | pkgMainStatus | async as pkgStatus"
|
||||
>
|
||||
<!-- active -->
|
||||
<ion-note
|
||||
*ngIf="pkgStatus === 'backing-up'; else queued"
|
||||
class="inline"
|
||||
slot="end"
|
||||
>
|
||||
<ion-spinner
|
||||
color="dark"
|
||||
style="height: 12px; width: 12px; margin-right: 6px"
|
||||
></ion-spinner>
|
||||
<ion-text color="dark">Backing up</ion-text>
|
||||
</ion-note>
|
||||
<!-- queued -->
|
||||
<ng-template #queued>
|
||||
<ion-note slot="end">Waiting...</ion-note>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
</ion-item-group>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -1,47 +0,0 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Pipe,
|
||||
PipeTransform,
|
||||
} from '@angular/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { take, Observable } from 'rxjs'
|
||||
import {
|
||||
DataModel,
|
||||
PackageMainStatus,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'backing-up',
|
||||
templateUrl: './backing-up.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BackingUpComponent {
|
||||
readonly pkgs$ = this.patch.watch$('package-data').pipe(take(1))
|
||||
readonly backupProgress$ = this.patch.watch$(
|
||||
'server-info',
|
||||
'status-info',
|
||||
'current-backup',
|
||||
'backup-progress',
|
||||
)
|
||||
|
||||
constructor(private readonly patch: PatchDB<DataModel>) {}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'pkgMainStatus',
|
||||
})
|
||||
export class PkgMainStatusPipe implements PipeTransform {
|
||||
transform(pkgId: string): Observable<PackageMainStatus> {
|
||||
return this.patch.watch$(
|
||||
'package-data',
|
||||
pkgId,
|
||||
'installed',
|
||||
'status',
|
||||
'main',
|
||||
'status',
|
||||
)
|
||||
}
|
||||
|
||||
constructor(private readonly patch: PatchDB<DataModel>) {}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Directive, HostListener } from '@angular/core'
|
||||
import { LoadingService } from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { BackupTarget } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { TargetSelectPage } from '../modals/target-select/target-select.page'
|
||||
import { BackupSelectPage } from '../modals/backup-select/backup-select.page'
|
||||
|
||||
@Directive({
|
||||
selector: '[backupCreate]',
|
||||
})
|
||||
export class BackupCreateDirective {
|
||||
constructor(
|
||||
private readonly loader: LoadingService,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly embassyApi: ApiService,
|
||||
) {}
|
||||
|
||||
@HostListener('click')
|
||||
onClick() {
|
||||
this.presentModalTarget()
|
||||
}
|
||||
|
||||
presentModalTarget() {
|
||||
this.dialogs
|
||||
.open<BackupTarget>(new PolymorpheusComponent(TargetSelectPage), {
|
||||
label: 'Select Backup Target',
|
||||
data: { type: 'create' },
|
||||
})
|
||||
.subscribe(({ id }) => {
|
||||
this.presentModalSelect(id)
|
||||
})
|
||||
}
|
||||
|
||||
private presentModalSelect(targetId: string) {
|
||||
this.dialogs
|
||||
.open<string[]>(new PolymorpheusComponent(BackupSelectPage), {
|
||||
label: 'Select Services to Back Up',
|
||||
data: { btnText: 'Create Backup' },
|
||||
})
|
||||
.subscribe(pkgIds => {
|
||||
this.createBackup(targetId, pkgIds)
|
||||
})
|
||||
}
|
||||
|
||||
private async createBackup(
|
||||
targetId: string,
|
||||
pkgIds: string[],
|
||||
): Promise<void> {
|
||||
const loader = this.loader.open('Beginning backup...').subscribe()
|
||||
|
||||
await this.embassyApi
|
||||
.createBackup({
|
||||
'target-id': targetId,
|
||||
'package-ids': pkgIds,
|
||||
})
|
||||
.finally(() => loader.unsubscribe())
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
import { Directive, HostListener } from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { BackupInfo, BackupTarget } from 'src/app/services/api/api.types'
|
||||
import * as argon2 from '@start9labs/argon2'
|
||||
import { TargetSelectPage } from '../modals/target-select/target-select.page'
|
||||
import {
|
||||
RecoverData,
|
||||
RecoverSelectPage,
|
||||
} from '../modals/recover-select/recover-select.page'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import {
|
||||
PROMPT,
|
||||
PromptOptions,
|
||||
} from 'src/app/apps/ui/modals/prompt/prompt.component'
|
||||
import {
|
||||
catchError,
|
||||
EMPTY,
|
||||
exhaustMap,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
switchMap,
|
||||
take,
|
||||
tap,
|
||||
} from 'rxjs'
|
||||
|
||||
@Directive({
|
||||
selector: '[backupRestore]',
|
||||
})
|
||||
export class BackupRestoreDirective {
|
||||
constructor(
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly loader: LoadingService,
|
||||
) {}
|
||||
|
||||
@HostListener('click') onClick() {
|
||||
this.presentModalTarget()
|
||||
}
|
||||
|
||||
async presentModalTarget() {
|
||||
this.dialogs
|
||||
.open<BackupTarget>(new PolymorpheusComponent(TargetSelectPage), {
|
||||
label: 'Select Backup Source',
|
||||
data: { type: 'restore' },
|
||||
})
|
||||
.subscribe(data => {
|
||||
this.presentModalPassword(data)
|
||||
})
|
||||
}
|
||||
|
||||
presentModalPassword(target: BackupTarget) {
|
||||
const data: PromptOptions = {
|
||||
message:
|
||||
'Enter the master password that was used to encrypt this backup. On the next screen, you will select the individual services you want to restore.',
|
||||
label: 'Master Password',
|
||||
placeholder: 'Enter master password',
|
||||
useMask: true,
|
||||
}
|
||||
|
||||
this.dialogs
|
||||
.open<string>(PROMPT, {
|
||||
label: 'Password Required',
|
||||
data,
|
||||
})
|
||||
.pipe(
|
||||
exhaustMap(password =>
|
||||
this.getRecoverData(
|
||||
target.id,
|
||||
password,
|
||||
target['embassy-os']?.['password-hash'] || '',
|
||||
),
|
||||
),
|
||||
take(1),
|
||||
switchMap(data => this.presentModalSelect(data)),
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.navCtrl.navigateRoot('/services')
|
||||
})
|
||||
}
|
||||
|
||||
private getRecoverData(
|
||||
targetId: string,
|
||||
password: string,
|
||||
hash: string,
|
||||
): Observable<RecoverData> {
|
||||
return of(password).pipe(
|
||||
tap(() => argon2.verify(hash, password)),
|
||||
switchMap(() => this.getBackupInfo(targetId, password)),
|
||||
catchError(e => {
|
||||
this.errorService.handleError(e)
|
||||
|
||||
return EMPTY
|
||||
}),
|
||||
map(backupInfo => ({ targetId, password, backupInfo })),
|
||||
)
|
||||
}
|
||||
|
||||
private async getBackupInfo(
|
||||
targetId: string,
|
||||
password: string,
|
||||
): Promise<BackupInfo> {
|
||||
const loader = this.loader.open('Decrypting drive...').subscribe()
|
||||
|
||||
return this.embassyApi
|
||||
.getBackupInfo({
|
||||
'target-id': targetId,
|
||||
password,
|
||||
})
|
||||
.finally(() => loader.unsubscribe())
|
||||
}
|
||||
|
||||
private presentModalSelect(data: RecoverData): Observable<void> {
|
||||
return this.dialogs.open(new PolymorpheusComponent(RecoverSelectPage), {
|
||||
label: 'Select Services to Restore',
|
||||
data,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { TuiGroupModule } from '@taiga-ui/core'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { TuiCheckboxBlockModule } from '@taiga-ui/kit'
|
||||
import { BackupSelectPage } from './backup-select.page'
|
||||
|
||||
@NgModule({
|
||||
declarations: [BackupSelectPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
TuiButtonModule,
|
||||
TuiGroupModule,
|
||||
TuiCheckboxBlockModule,
|
||||
],
|
||||
exports: [BackupSelectPage],
|
||||
})
|
||||
export class BackupSelectPageModule {}
|
||||
@@ -1,31 +0,0 @@
|
||||
<div
|
||||
*ngIf="pkgs.length; else empty"
|
||||
tuiGroup
|
||||
orientation="vertical"
|
||||
class="pkgs"
|
||||
>
|
||||
<tui-checkbox-block
|
||||
*ngFor="let pkg of pkgs"
|
||||
[disabled]="pkg.disabled"
|
||||
[(ngModel)]="pkg.checked"
|
||||
(ngModelChange)="handleChange()"
|
||||
>
|
||||
<div class="label">
|
||||
<img class="icon" alt="" [src]="pkg.icon" />
|
||||
{{ pkg.title }}
|
||||
</div>
|
||||
</tui-checkbox-block>
|
||||
</div>
|
||||
|
||||
<ng-template #empty>
|
||||
<h2 class="center">No services installed!</h2>
|
||||
</ng-template>
|
||||
|
||||
<footer class="modal-buttons">
|
||||
<button tuiButton appearance="flat" (click)="toggleSelectAll()">
|
||||
Toggle all
|
||||
</button>
|
||||
<button tuiButton [disabled]="!hasSelection" (click)="done()">
|
||||
{{ btnText }}
|
||||
</button>
|
||||
</footer>
|
||||
@@ -1,25 +0,0 @@
|
||||
.center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pkgs {
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
ion-item {
|
||||
--background: transparent;
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Component, Inject, Input } from '@angular/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { firstValueFrom, map } from 'rxjs'
|
||||
import { DataModel, PackageState } from 'src/app/services/patch-db/data-model'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
|
||||
@Component({
|
||||
selector: 'backup-select',
|
||||
templateUrl: './backup-select.page.html',
|
||||
styleUrls: ['./backup-select.page.scss'],
|
||||
})
|
||||
export class BackupSelectPage {
|
||||
@Input() selectedIds: string[] = []
|
||||
|
||||
hasSelection = false
|
||||
pkgs: {
|
||||
id: string
|
||||
title: string
|
||||
icon: string
|
||||
disabled: boolean
|
||||
checked: boolean
|
||||
}[] = []
|
||||
|
||||
constructor(
|
||||
@Inject(POLYMORPHEUS_CONTEXT)
|
||||
private readonly context: TuiDialogContext<string[], { btnText: string }>,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
get btnText(): string {
|
||||
return this.context.data.btnText
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.pkgs = await firstValueFrom(
|
||||
this.patch.watch$('package-data').pipe(
|
||||
map(pkgs => {
|
||||
return Object.values(pkgs)
|
||||
.map(pkg => {
|
||||
const { id, title } = pkg.manifest
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
icon: pkg.icon,
|
||||
disabled: pkg.state !== PackageState.Installed,
|
||||
checked: this.selectedIds.includes(id),
|
||||
}
|
||||
})
|
||||
.sort((a, b) =>
|
||||
b.title.toLowerCase() > a.title.toLowerCase() ? -1 : 1,
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
done() {
|
||||
this.context.completeWith(this.pkgs.filter(p => p.checked).map(p => p.id))
|
||||
}
|
||||
|
||||
handleChange() {
|
||||
this.hasSelection = this.pkgs.some(p => p.checked)
|
||||
}
|
||||
|
||||
toggleSelectAll() {
|
||||
this.pkgs.forEach(pkg => (pkg.checked = !this.hasSelection))
|
||||
this.hasSelection = !this.hasSelection
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { TuiGroupModule } from '@taiga-ui/core'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { TuiCheckboxBlockModule } from '@taiga-ui/kit'
|
||||
import { RecoverSelectPage } from './recover-select.page'
|
||||
import { ToOptionsPipe } from './to-options.pipe'
|
||||
|
||||
@NgModule({
|
||||
declarations: [RecoverSelectPage, ToOptionsPipe],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
TuiButtonModule,
|
||||
TuiGroupModule,
|
||||
TuiCheckboxBlockModule,
|
||||
],
|
||||
exports: [RecoverSelectPage],
|
||||
})
|
||||
export class RecoverSelectPageModule {}
|
||||
@@ -1,36 +0,0 @@
|
||||
<ng-container
|
||||
*ngIf="packageData$ | toOptions : backupInfo['package-backups'] | async as options"
|
||||
>
|
||||
<div tuiGroup orientation="vertical" class="items">
|
||||
<tui-checkbox-block
|
||||
*ngFor="let option of options"
|
||||
[disabled]="option.installed || option['newer-eos']"
|
||||
[(ngModel)]="option.checked"
|
||||
(ngModelChange)="handleChange(options)"
|
||||
>
|
||||
<div class="label">
|
||||
<strong class="title">{{ option.title }}</strong>
|
||||
<div>Version {{ option.version }}</div>
|
||||
<div>Backup made: {{ option.timestamp | date : 'medium' }}</div>
|
||||
<div *ngIf="!option.installed && !option['newer-eos']" class="success">
|
||||
Ready to restore
|
||||
</div>
|
||||
<div *ngIf="option.installed" class="warning">
|
||||
Unavailable. {{ option.title }} is already installed.
|
||||
</div>
|
||||
<div *ngIf="option['newer-eos']" class="danger">
|
||||
Unavailable. Backup was made on a newer version of StartOS.
|
||||
</div>
|
||||
</div>
|
||||
</tui-checkbox-block>
|
||||
</div>
|
||||
|
||||
<button
|
||||
tuiButton
|
||||
class="button"
|
||||
[disabled]="!hasSelection"
|
||||
(click)="restore(options)"
|
||||
>
|
||||
Restore Selected
|
||||
</button>
|
||||
</ng-container>
|
||||
@@ -1,31 +0,0 @@
|
||||
.items {
|
||||
width: 100%;
|
||||
margin: 12px 0 24px;
|
||||
}
|
||||
|
||||
.label {
|
||||
padding: 8px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
margin-bottom: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--tui-success-fill);
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--tui-warning-fill);
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: var(--tui-error-fill);
|
||||
}
|
||||
|
||||
.button {
|
||||
float: right;
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { Component, Inject } from '@angular/core'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
import { BackupInfo } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { AppRecoverOption } from './to-options.pipe'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { take } from 'rxjs'
|
||||
|
||||
export interface RecoverData {
|
||||
targetId: string
|
||||
backupInfo: BackupInfo
|
||||
password: string
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'recover-select',
|
||||
templateUrl: './recover-select.page.html',
|
||||
styleUrls: ['./recover-select.page.scss'],
|
||||
})
|
||||
export class RecoverSelectPage {
|
||||
readonly packageData$ = this.patch.watch$('package-data').pipe(take(1))
|
||||
|
||||
hasSelection = false
|
||||
|
||||
constructor(
|
||||
@Inject(POLYMORPHEUS_CONTEXT)
|
||||
private readonly context: TuiDialogContext<void, RecoverData>,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
get backupInfo(): BackupInfo {
|
||||
return this.context.data.backupInfo
|
||||
}
|
||||
|
||||
handleChange(options: AppRecoverOption[]) {
|
||||
this.hasSelection = options.some(o => o.checked)
|
||||
}
|
||||
|
||||
async restore(options: AppRecoverOption[]): Promise<void> {
|
||||
const ids = options.filter(({ checked }) => !!checked).map(({ id }) => id)
|
||||
const loader = this.loader.open('Initializing...').subscribe()
|
||||
|
||||
try {
|
||||
await this.embassyApi.restorePackages({
|
||||
ids,
|
||||
'target-id': this.context.data.targetId,
|
||||
password: this.context.data.password,
|
||||
})
|
||||
|
||||
this.context.completeWith(undefined)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { Emver } from '@start9labs/shared'
|
||||
import { map, Observable } from 'rxjs'
|
||||
import { PackageBackupInfo } from 'src/app/services/api/api.types'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
export interface AppRecoverOption extends PackageBackupInfo {
|
||||
id: string
|
||||
checked: boolean
|
||||
installed: boolean
|
||||
'newer-eos': boolean
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'toOptions',
|
||||
})
|
||||
export class ToOptionsPipe implements PipeTransform {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly emver: Emver,
|
||||
) {}
|
||||
|
||||
transform(
|
||||
packageData$: Observable<Record<string, PackageDataEntry>>,
|
||||
packageBackups: Record<string, PackageBackupInfo> = {},
|
||||
): Observable<AppRecoverOption[]> {
|
||||
return packageData$.pipe(
|
||||
map(packageData =>
|
||||
Object.keys(packageBackups)
|
||||
.map(id => ({
|
||||
...packageBackups[id],
|
||||
id,
|
||||
installed: !!packageData[id],
|
||||
checked: false,
|
||||
'newer-eos': this.compare(packageBackups[id]['os-version']),
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
b.title.toLowerCase() > a.title.toLowerCase() ? -1 : 1,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private compare(version: string): boolean {
|
||||
// checks to see if backup was made on a newer version of eOS
|
||||
return this.emver.compare(version, this.config.version) === 1
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { TargetSelectPage, TargetStatusComponent } from './target-select.page'
|
||||
import { TargetPipesModule } from '../../pipes/target-pipes.module'
|
||||
import { TextSpinnerComponentModule } from '@start9labs/shared'
|
||||
|
||||
@NgModule({
|
||||
declarations: [TargetSelectPage, TargetStatusComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
TargetPipesModule,
|
||||
TextSpinnerComponentModule,
|
||||
TuiButtonModule,
|
||||
],
|
||||
exports: [TargetSelectPage],
|
||||
})
|
||||
export class TargetSelectPageModule {}
|
||||
@@ -1,40 +0,0 @@
|
||||
<!-- loading -->
|
||||
<text-spinner
|
||||
*ngIf="loading$ | async; else loaded"
|
||||
[text]="type === 'create' ? 'Loading Backup Targets' : 'Loading Backup Sources'"
|
||||
></text-spinner>
|
||||
|
||||
<!-- loaded -->
|
||||
<ng-template #loaded>
|
||||
<ion-item-group>
|
||||
<ion-item-divider>Saved Targets</ion-item-divider>
|
||||
<ion-item
|
||||
button
|
||||
*ngFor="let target of targets"
|
||||
(click)="select(target)"
|
||||
[disabled]="
|
||||
!target.mountable ||
|
||||
(type === 'restore' && !target['embassy-os'])
|
||||
"
|
||||
>
|
||||
<ng-container *ngIf="target | getDisplayInfo as displayInfo">
|
||||
<ion-icon
|
||||
slot="start"
|
||||
[name]="displayInfo.icon"
|
||||
size="large"
|
||||
></ion-icon>
|
||||
<ion-label>
|
||||
<h1 style="font-size: x-large">{{ displayInfo.name }}</h1>
|
||||
<target-status [type]="type" [target]="target"></target-status>
|
||||
<p>{{ displayInfo.description }}</p>
|
||||
<p>{{ displayInfo.path }}</p>
|
||||
</ion-label>
|
||||
</ng-container>
|
||||
</ion-item>
|
||||
|
||||
<div *ngIf="!targets.length" class="ion-text-center ion-padding-top">
|
||||
<h2 class="ion-padding-bottom">No saved targets</h2>
|
||||
<button tuiButton (click)="goToTargets()">Go to Targets</button>
|
||||
</div>
|
||||
</ion-item-group>
|
||||
</ng-template>
|
||||
@@ -1,3 +0,0 @@
|
||||
ion-item {
|
||||
--background: transparent;
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
import { BackupTarget } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { BackupType } from '../../pages/backup-targets/backup-targets.page'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
|
||||
@Component({
|
||||
selector: 'target-select',
|
||||
templateUrl: './target-select.page.html',
|
||||
styleUrls: ['./target-select.page.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TargetSelectPage {
|
||||
targets: BackupTarget[] = []
|
||||
|
||||
loading$ = new BehaviorSubject(true)
|
||||
|
||||
constructor(
|
||||
@Inject(POLYMORPHEUS_CONTEXT)
|
||||
private readonly context: TuiDialogContext<
|
||||
BackupTarget,
|
||||
{ type: BackupType }
|
||||
>,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly api: ApiService,
|
||||
private readonly errorService: ErrorService,
|
||||
) {}
|
||||
|
||||
get type(): BackupType {
|
||||
return this.context.data.type
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.getTargets()
|
||||
}
|
||||
|
||||
select(target: BackupTarget): void {
|
||||
this.context.completeWith(target)
|
||||
}
|
||||
|
||||
goToTargets() {
|
||||
this.context.$implicit.complete()
|
||||
this.navCtrl.navigateForward(`/backups/targets`)
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
await this.getTargets()
|
||||
}
|
||||
|
||||
private async getTargets(): Promise<void> {
|
||||
this.loading$.next(true)
|
||||
try {
|
||||
this.targets = (await this.api.getBackupTargets({})).saved
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading$.next(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'target-status',
|
||||
templateUrl: './target-status.component.html',
|
||||
styleUrls: ['./target-select.page.scss'],
|
||||
})
|
||||
export class TargetStatusComponent {
|
||||
@Input({ required: true }) type!: BackupType
|
||||
@Input({ required: true }) target!: BackupTarget
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
<div class="inline">
|
||||
<h2 *ngIf="!target.mountable; else mountable">
|
||||
<ion-icon name="cellular-outline" color="danger"></ion-icon>
|
||||
Unable to connect
|
||||
</h2>
|
||||
|
||||
<ng-template #mountable>
|
||||
<h2 *ngIf="type === 'create'; else restore">
|
||||
<ion-icon name="cloud-outline" color="success"></ion-icon>
|
||||
{{
|
||||
(target | hasValidBackup)
|
||||
? 'Available, contains existing backup'
|
||||
: 'Available for fresh backup'
|
||||
}}
|
||||
</h2>
|
||||
|
||||
<ng-template #restore>
|
||||
<h2 *ngIf="target | hasValidBackup; else noBackup">
|
||||
<ion-icon name="cloud-done-outline" color="success"></ion-icon>
|
||||
Embassy backup detected
|
||||
</h2>
|
||||
<ng-template #noBackup>
|
||||
<h2>
|
||||
<ion-icon name="cloud-offline-outline" color="danger"></ion-icon>
|
||||
No Embassy backup
|
||||
</h2>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
</div>
|
||||
@@ -1,31 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import {
|
||||
BackupHistoryPage,
|
||||
DurationPipe,
|
||||
HasErrorPipe,
|
||||
} from './backup-history.page'
|
||||
import { TargetPipesModule } from '../../pipes/target-pipes.module'
|
||||
import { BackupReportPageModule } from 'src/app/apps/ui/modals/backup-report/backup-report.module'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: BackupHistoryPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
TargetPipesModule,
|
||||
BackupReportPageModule,
|
||||
RouterModule.forChild(routes),
|
||||
],
|
||||
declarations: [BackupHistoryPage, DurationPipe, HasErrorPipe],
|
||||
exports: [DurationPipe, HasErrorPipe],
|
||||
})
|
||||
export class BackupHistoryPageModule {}
|
||||
@@ -1,93 +0,0 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button defaultHref="/backups"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Backup History</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding-top">
|
||||
<ion-item-divider>
|
||||
Past Events
|
||||
<ion-button
|
||||
class="ion-padding-start"
|
||||
color="danger"
|
||||
strong
|
||||
size="small"
|
||||
(click)="deleteSelected()"
|
||||
[disabled]="empty"
|
||||
>
|
||||
Delete Selected
|
||||
</ion-button>
|
||||
</ion-item-divider>
|
||||
<div class="grid-fixed">
|
||||
<ion-grid class="ion-padding">
|
||||
<ion-row class="grid-headings">
|
||||
<ion-col size="3.5" class="inline">
|
||||
<div class="checkbox" (click)="toggleAll(runs)">
|
||||
<ion-icon
|
||||
[name]="empty ? 'square-outline' : count === runs.length ? 'checkbox-outline' : 'remove-circle-outline'"
|
||||
></ion-icon>
|
||||
</div>
|
||||
Started At
|
||||
</ion-col>
|
||||
<ion-col size="2">Duration</ion-col>
|
||||
<ion-col size="1.5">Result</ion-col>
|
||||
<ion-col size="2.5">Job</ion-col>
|
||||
<ion-col size="2.5">Target</ion-col>
|
||||
</ion-row>
|
||||
|
||||
<!-- loading -->
|
||||
<ng-container *ngIf="loading$ | async; else loaded">
|
||||
<ion-row
|
||||
*ngFor="let row of ['', '', '']"
|
||||
class="ion-align-items-center grid-row-border"
|
||||
>
|
||||
<ion-col>
|
||||
<ion-skeleton-text animated></ion-skeleton-text>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ng-container>
|
||||
<!-- loaded -->
|
||||
<ng-template #loaded>
|
||||
<ion-row
|
||||
*ngFor="let run of runs"
|
||||
class="ion-align-items-center grid-row-border"
|
||||
[class.highlighted]="selected[run.id]"
|
||||
>
|
||||
<ion-col size="3.5" class="inline">
|
||||
<div class="checkbox" (click)="toggleChecked(run.id)">
|
||||
<ion-icon
|
||||
[name]="selected[run.id] ? 'checkbox-outline' : 'square-outline'"
|
||||
></ion-icon>
|
||||
</div>
|
||||
{{ run['started-at'] | date : 'medium' }}
|
||||
</ion-col>
|
||||
<ion-col size="2">
|
||||
{{ run['started-at']| duration : run['completed-at'] }} Minutes
|
||||
</ion-col>
|
||||
<ion-col size="1.5">
|
||||
<ion-icon
|
||||
*ngIf="run.report | hasError; else noError"
|
||||
name="close"
|
||||
color="danger"
|
||||
></ion-icon>
|
||||
<ng-template #noError>
|
||||
<ion-icon name="checkmark" color="success"></ion-icon>
|
||||
</ng-template>
|
||||
<a (click)="presentModalReport(run)">Report</a>
|
||||
</ion-col>
|
||||
<ion-col size="2.5">{{ run.job.name || 'No job' }}</ion-col>
|
||||
<ion-col size="2.5" class="inline">
|
||||
<ion-icon
|
||||
[name]="(run.job.target | getDisplayInfo).icon"
|
||||
size="small"
|
||||
></ion-icon>
|
||||
{{ run.job.target.name }}
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ng-template>
|
||||
</ion-grid>
|
||||
</div>
|
||||
</ion-content>
|
||||
@@ -1,3 +0,0 @@
|
||||
.highlighted {
|
||||
background-color: var(--ion-color-medium-shade);
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { BackupReport, BackupRun } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
import { BackupReportComponent } from '../../../../modals/backup-report/backup-report.component'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
|
||||
@Component({
|
||||
selector: 'backup-history',
|
||||
templateUrl: './backup-history.page.html',
|
||||
styleUrls: ['./backup-history.page.scss'],
|
||||
})
|
||||
export class BackupHistoryPage {
|
||||
selected: Record<string, boolean> = {}
|
||||
runs: BackupRun[] = []
|
||||
loading$ = new BehaviorSubject(true)
|
||||
|
||||
constructor(
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly api: ApiService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
this.runs = await this.api.getBackupRuns({})
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading$.next(false)
|
||||
}
|
||||
}
|
||||
|
||||
get empty() {
|
||||
return this.count === 0
|
||||
}
|
||||
|
||||
get count() {
|
||||
return Object.keys(this.selected).length
|
||||
}
|
||||
|
||||
presentModalReport(run: BackupRun) {
|
||||
this.dialogs
|
||||
.open(new PolymorpheusComponent(BackupReportComponent), {
|
||||
label: 'Backup Report',
|
||||
data: {
|
||||
report: run.report,
|
||||
timestamp: run['completed-at'],
|
||||
},
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
async toggleChecked(id: string) {
|
||||
if (this.selected[id]) {
|
||||
delete this.selected[id]
|
||||
} else {
|
||||
this.selected[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
async toggleAll(runs: BackupRun[]) {
|
||||
if (this.empty) {
|
||||
runs.forEach(r => (this.selected[r.id] = true))
|
||||
} else {
|
||||
this.selected = {}
|
||||
}
|
||||
}
|
||||
|
||||
async deleteSelected(): Promise<void> {
|
||||
const ids = Object.keys(this.selected)
|
||||
const loader = this.loader.open('Deleting...').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.deleteBackupRuns({ ids })
|
||||
this.selected = {}
|
||||
this.runs = this.runs.filter(r => !ids.includes(r.id))
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'duration',
|
||||
})
|
||||
export class DurationPipe implements PipeTransform {
|
||||
transform(start: string, finish: string): number {
|
||||
const diffMs = new Date(finish).valueOf() - new Date(start).valueOf()
|
||||
return diffMs / 100
|
||||
}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'hasError',
|
||||
})
|
||||
export class HasErrorPipe implements PipeTransform {
|
||||
transform(report: BackupReport): boolean {
|
||||
const osErr = !!report.server.error
|
||||
const pkgErr = !!Object.values(report.packages).find(pkg => pkg.error)
|
||||
return osErr || pkgErr
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { TuiNotificationModule, TuiWrapperModule } from '@taiga-ui/core'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { TuiInputModule, TuiToggleModule } from '@taiga-ui/kit'
|
||||
import { BackupJobsPage } from './backup-jobs.page'
|
||||
import { EditJobComponent } from './edit-job/edit-job.component'
|
||||
import { ToHumanCronPipe } from './pipes'
|
||||
import { TargetSelectPageModule } from '../../modals/target-select/target-select.module'
|
||||
import { TargetPipesModule } from '../../pipes/target-pipes.module'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: BackupJobsPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
FormsModule,
|
||||
TargetSelectPageModule,
|
||||
TargetPipesModule,
|
||||
TuiNotificationModule,
|
||||
TuiButtonModule,
|
||||
TuiInputModule,
|
||||
TuiToggleModule,
|
||||
TuiWrapperModule,
|
||||
],
|
||||
declarations: [BackupJobsPage, ToHumanCronPipe, EditJobComponent],
|
||||
exports: [ToHumanCronPipe],
|
||||
})
|
||||
export class BackupJobsPageModule {}
|
||||
@@ -1,88 +0,0 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button defaultHref="/backups"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Backup Jobs</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding-top">
|
||||
<div class="ion-padding-start ion-padding-end">
|
||||
<tui-notification>
|
||||
Scheduling automatic backups is an excellent way to ensure your Embassy
|
||||
data is safely backed up. Your Embassy will issue a notification whenever
|
||||
one of your scheduled backups succeeds or fails.
|
||||
<a [href]="docsUrl" target="_blank" rel="noreferrer">View instructions</a>
|
||||
</tui-notification>
|
||||
</div>
|
||||
|
||||
<ion-item-group>
|
||||
<ion-item-divider>
|
||||
Saved Jobs
|
||||
<ion-button
|
||||
class="ion-padding-start"
|
||||
strong
|
||||
size="small"
|
||||
(click)="presentModalCreate()"
|
||||
>
|
||||
<ion-icon slot="start" name="add"></ion-icon>
|
||||
Create New Job
|
||||
</ion-button>
|
||||
</ion-item-divider>
|
||||
|
||||
<div class="grid-fixed">
|
||||
<ion-grid class="ion-padding">
|
||||
<ion-row class="grid-headings">
|
||||
<ion-col size="2.5">Name</ion-col>
|
||||
<ion-col size="2.5">Target</ion-col>
|
||||
<ion-col size="2">Packages</ion-col>
|
||||
<ion-col size="3">Schedule</ion-col>
|
||||
<ion-col size="2"></ion-col>
|
||||
</ion-row>
|
||||
<!-- loading -->
|
||||
<ng-container *ngIf="loading$ | async; else loaded">
|
||||
<ion-row
|
||||
*ngFor="let row of ['', '']"
|
||||
class="ion-align-items-center grid-row-border"
|
||||
>
|
||||
<ion-col>
|
||||
<ion-skeleton-text animated></ion-skeleton-text>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ng-container>
|
||||
<!-- loaded -->
|
||||
<ng-template #loaded>
|
||||
<ion-row
|
||||
*ngFor="let job of jobs; let i = index"
|
||||
class="ion-align-items-center grid-row-border"
|
||||
>
|
||||
<ion-col size="2.5">{{ job.name }}</ion-col>
|
||||
<ion-col size="2.5" class="inline">
|
||||
<ion-icon
|
||||
[name]="(job.target | getDisplayInfo).icon"
|
||||
size="small"
|
||||
></ion-icon>
|
||||
{{ job.target.name }}
|
||||
</ion-col>
|
||||
<ion-col size="2">{{ job['package-ids'].length }} Packages</ion-col>
|
||||
<ion-col size="3">{{ (job.cron | toHumanCron).message }}</ion-col>
|
||||
<ion-col size="2">
|
||||
<ion-buttons style="float: right">
|
||||
<ion-button size="small" (click)="presentModalUpdate(job)">
|
||||
<ion-icon name="pencil"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button
|
||||
size="small"
|
||||
(click)="presentAlertDelete(job.id, i)"
|
||||
>
|
||||
<ion-icon name="trash"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ng-template>
|
||||
</ion-grid>
|
||||
</div>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
@@ -1,96 +0,0 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { BehaviorSubject, filter } from 'rxjs'
|
||||
import { BackupJob } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { EditJobComponent } from './edit-job/edit-job.component'
|
||||
import { BackupJobBuilder } from './edit-job/job-builder'
|
||||
|
||||
@Component({
|
||||
selector: 'backup-jobs',
|
||||
templateUrl: './backup-jobs.page.html',
|
||||
styleUrls: ['./backup-jobs.page.scss'],
|
||||
})
|
||||
export class BackupJobsPage {
|
||||
readonly docsUrl =
|
||||
'https://docs.start9.com/latest/user-manual/backups/backup-jobs'
|
||||
|
||||
jobs: BackupJob[] = []
|
||||
|
||||
loading$ = new BehaviorSubject(true)
|
||||
|
||||
constructor(
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly api: ApiService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
this.jobs = await this.api.getBackupJobs({})
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading$.next(false)
|
||||
}
|
||||
}
|
||||
|
||||
presentModalCreate() {
|
||||
this.dialogs
|
||||
.open<BackupJob>(new PolymorpheusComponent(EditJobComponent), {
|
||||
label: 'Create New Job',
|
||||
data: new BackupJobBuilder({
|
||||
name: `Backup Job ${this.jobs.length + 1}`,
|
||||
}),
|
||||
})
|
||||
.subscribe(job => this.jobs.push(job))
|
||||
}
|
||||
|
||||
presentModalUpdate(data: BackupJob) {
|
||||
this.dialogs
|
||||
.open<BackupJob>(new PolymorpheusComponent(EditJobComponent), {
|
||||
label: 'Edit Job',
|
||||
data: new BackupJobBuilder(data),
|
||||
})
|
||||
.subscribe(job => {
|
||||
data.name = job.name
|
||||
data.target = job.target
|
||||
data.cron = job.cron
|
||||
data['package-ids'] = job['package-ids']
|
||||
})
|
||||
}
|
||||
|
||||
presentAlertDelete(id: string, index: number) {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, {
|
||||
label: 'Confirm',
|
||||
size: 's',
|
||||
data: {
|
||||
content: 'Delete backup job? This action cannot be undone.',
|
||||
yes: 'Delete',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => {
|
||||
this.delete(id, index)
|
||||
})
|
||||
}
|
||||
|
||||
private async delete(id: string, i: number): Promise<void> {
|
||||
const loader = this.loader.open('Deleting...').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.removeBackupTarget({ id })
|
||||
this.jobs.splice(i, 1)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<form>
|
||||
<tui-input [ngModelOptions]="{ standalone: true }" [(ngModel)]="job.name">
|
||||
Job Name
|
||||
<input tuiTextfield placeholder="My Backup Job" />
|
||||
</tui-input>
|
||||
|
||||
<button
|
||||
tuiWrapper
|
||||
appearance="secondary"
|
||||
type="button"
|
||||
class="button"
|
||||
(click)="presentModalTarget()"
|
||||
>
|
||||
Target
|
||||
<span class="value">{{ job.target.type || 'Select target' }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
tuiWrapper
|
||||
appearance="secondary"
|
||||
type="button"
|
||||
class="button"
|
||||
(click)="presentModalPackages()"
|
||||
>
|
||||
Packages
|
||||
<span class="value">{{ job['package-ids'].length + ' selected' }}</span>
|
||||
</button>
|
||||
|
||||
<tui-input [ngModelOptions]="{ standalone: true }" [(ngModel)]="job.cron">
|
||||
Schedule
|
||||
<input tuiTextfield placeholder="* * * * *" />
|
||||
</tui-input>
|
||||
|
||||
<p *ngIf="job.cron | toHumanCron as human" [style.color]="human.color">
|
||||
{{ human.message }}
|
||||
</p>
|
||||
|
||||
<div *ngIf="!job.job.id" class="toggle">
|
||||
Also Execute Now
|
||||
<tui-toggle
|
||||
size="l"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[(ngModel)]="job.now"
|
||||
></tui-toggle>
|
||||
</div>
|
||||
<button tuiButton class="submit" (click)="save()">Save Job</button>
|
||||
</form>
|
||||
@@ -1,34 +0,0 @@
|
||||
.button {
|
||||
width: 100%;
|
||||
height: var(--tui-height-l);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 1rem 0;
|
||||
padding: 0 1rem;
|
||||
border-radius: var(--tui-radius-m);
|
||||
font: var(--tui-font-text-l);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.value {
|
||||
font: var(--tui-font-text-m);
|
||||
color: var(--tui-positive);
|
||||
}
|
||||
|
||||
.toggle {
|
||||
height: var(--tui-height-l);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1rem;
|
||||
box-shadow: inset 0 0 0 1px var(--tui-base-03);
|
||||
font: var(--tui-font-text-l);
|
||||
font-weight: bold;
|
||||
border-radius: var(--tui-radius-m);
|
||||
}
|
||||
|
||||
.submit {
|
||||
float: right;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
import { Component, Inject } from '@angular/core'
|
||||
import { TuiDialogContext, TuiDialogService } from '@taiga-ui/core'
|
||||
import {
|
||||
POLYMORPHEUS_CONTEXT,
|
||||
PolymorpheusComponent,
|
||||
} from '@tinkoff/ng-polymorpheus'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { BackupJob, BackupTarget } from 'src/app/services/api/api.types'
|
||||
import { TargetSelectPage } from '../../../modals/target-select/target-select.page'
|
||||
import { BackupSelectPage } from '../../../modals/backup-select/backup-select.page'
|
||||
import { BackupJobBuilder } from './job-builder'
|
||||
|
||||
@Component({
|
||||
selector: 'edit-job',
|
||||
templateUrl: './edit-job.component.html',
|
||||
styleUrls: ['./edit-job.component.scss'],
|
||||
})
|
||||
export class EditJobComponent {
|
||||
constructor(
|
||||
@Inject(POLYMORPHEUS_CONTEXT)
|
||||
private readonly context: TuiDialogContext<BackupJob, BackupJobBuilder>,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly api: ApiService,
|
||||
private readonly errorService: ErrorService,
|
||||
) {}
|
||||
|
||||
get job() {
|
||||
return this.context.data
|
||||
}
|
||||
|
||||
async save() {
|
||||
const loader = this.loader.open('Saving Job').subscribe()
|
||||
|
||||
try {
|
||||
const { id } = this.job.job
|
||||
let job: BackupJob
|
||||
|
||||
if (id) {
|
||||
job = await this.api.updateBackupJob(this.job.buildUpdate(id))
|
||||
} else {
|
||||
job = await this.api.createBackupJob(this.job.buildCreate())
|
||||
}
|
||||
|
||||
this.context.completeWith(job)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
presentModalTarget() {
|
||||
this.dialogs
|
||||
.open<BackupTarget>(new PolymorpheusComponent(TargetSelectPage), {
|
||||
label: 'Select Backup Target',
|
||||
data: { type: 'create' },
|
||||
})
|
||||
.subscribe(target => {
|
||||
this.job.target = target
|
||||
})
|
||||
}
|
||||
|
||||
presentModalPackages() {
|
||||
this.dialogs
|
||||
.open<string[]>(new PolymorpheusComponent(BackupSelectPage), {
|
||||
label: 'Select Services to Back Up',
|
||||
data: { btnText: 'Done' },
|
||||
})
|
||||
.subscribe(id => {
|
||||
this.job['package-ids'] = id
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { BackupJob, BackupTarget, RR } from 'src/app/services/api/api.types'
|
||||
|
||||
export class BackupJobBuilder {
|
||||
name: string
|
||||
target: BackupTarget
|
||||
cron: string
|
||||
'package-ids': string[]
|
||||
now = false
|
||||
|
||||
constructor(readonly job: Partial<BackupJob>) {
|
||||
const { name, target, cron } = job
|
||||
this.name = name || ''
|
||||
this.target = target || ({} as BackupTarget)
|
||||
this.cron = cron || '0 2 * * *'
|
||||
this['package-ids'] = job['package-ids'] || []
|
||||
}
|
||||
|
||||
buildCreate(): RR.CreateBackupJobReq {
|
||||
const { name, target, cron, now } = this
|
||||
|
||||
return {
|
||||
name,
|
||||
'target-id': target.id,
|
||||
cron,
|
||||
'package-ids': this['package-ids'],
|
||||
now,
|
||||
}
|
||||
}
|
||||
|
||||
buildUpdate(id: string): RR.UpdateBackupJobReq {
|
||||
const { name, target, cron } = this
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
'target-id': target.id,
|
||||
cron,
|
||||
'package-ids': this['package-ids'],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import cronstrue from 'cronstrue'
|
||||
|
||||
@Pipe({
|
||||
name: 'toHumanCron',
|
||||
})
|
||||
export class ToHumanCronPipe implements PipeTransform {
|
||||
transform(cron: string): { message: string; color: string } {
|
||||
const toReturn = {
|
||||
message: '',
|
||||
color: 'var(--tui-positive)',
|
||||
}
|
||||
|
||||
try {
|
||||
const human = cronstrue.toString(cron, {
|
||||
verbose: true,
|
||||
throwExceptionOnParseError: true,
|
||||
})
|
||||
const zero = Number(cron[0])
|
||||
const one = Number(cron[1])
|
||||
if (Number.isNaN(zero) || Number.isNaN(one)) {
|
||||
throw new Error(
|
||||
`${human}. Cannot run cron jobs more than once per hour`,
|
||||
)
|
||||
}
|
||||
toReturn.message = human
|
||||
} catch (e) {
|
||||
toReturn.message = e as string
|
||||
toReturn.color = 'var(--tui-negative)'
|
||||
}
|
||||
|
||||
return toReturn
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { UnitConversionPipesModule } from '@start9labs/shared'
|
||||
import { SkeletonListComponentModule } from 'src/app/common/skeleton-list/skeleton-list.component.module'
|
||||
import { FormPageModule } from 'src/app/apps/ui/modals/form/form.module'
|
||||
import { BackupTargetsPage } from './backup-targets.page'
|
||||
import { TuiNotificationModule } from '@taiga-ui/core'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: BackupTargetsPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
declarations: [BackupTargetsPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
SkeletonListComponentModule,
|
||||
UnitConversionPipesModule,
|
||||
FormPageModule,
|
||||
RouterModule.forChild(routes),
|
||||
TuiNotificationModule,
|
||||
],
|
||||
})
|
||||
export class BackupTargetsPageModule {}
|
||||
@@ -1,162 +0,0 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button defaultHref="/backups"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Backup Targets</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding-top">
|
||||
<div class="ion-padding-start ion-padding-end">
|
||||
<tui-notification>
|
||||
Backup targets are physical or virtual locations for storing encrypted
|
||||
backups. They can be physical drives plugged into your server, shared
|
||||
folders on your Local Area Network (LAN), or third party clouds such as
|
||||
Dropbox or Google Drive.
|
||||
<a [href]="docsUrl" target="_blank" rel="noreferrer">View instructions</a>
|
||||
</tui-notification>
|
||||
</div>
|
||||
|
||||
<ion-item-group>
|
||||
<!-- unknown disks -->
|
||||
<ion-item-divider>
|
||||
Unknown Physical Drives
|
||||
<ion-button
|
||||
class="ion-padding-start"
|
||||
strong
|
||||
size="small"
|
||||
(click)="refresh()"
|
||||
>
|
||||
<ion-icon slot="start" name="refresh"></ion-icon>
|
||||
Refresh
|
||||
</ion-button>
|
||||
</ion-item-divider>
|
||||
|
||||
<div class="grid-fixed">
|
||||
<ion-grid class="ion-padding">
|
||||
<ion-row class="grid-headings">
|
||||
<ion-col size="3">Make/Model</ion-col>
|
||||
<ion-col size="3">Label</ion-col>
|
||||
<ion-col size="2">Capacity</ion-col>
|
||||
<ion-col size="2">Used</ion-col>
|
||||
<ion-col size="2"></ion-col>
|
||||
</ion-row>
|
||||
<!-- loading -->
|
||||
<ion-row
|
||||
*ngIf="loading$ | async; else loaded"
|
||||
class="ion-align-items-center grid-row-border"
|
||||
>
|
||||
<ion-col>
|
||||
<ion-skeleton-text animated></ion-skeleton-text>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
<!-- loaded -->
|
||||
<ng-template #loaded>
|
||||
<ion-row
|
||||
*ngFor="let disk of targets['unknown-disks']; let i = index"
|
||||
class="ion-align-items-center grid-row-border"
|
||||
>
|
||||
<ion-col size="3">
|
||||
{{ disk.vendor || 'unknown make' }}, {{ disk.model || 'unknown
|
||||
model' }}
|
||||
</ion-col>
|
||||
<ion-col size="3">{{ disk.label }}</ion-col>
|
||||
<ion-col size="2">{{ disk.capacity | convertBytes }}</ion-col>
|
||||
<ion-col size="2">
|
||||
{{ disk.used ? (disk.used | convertBytes) : 'unknown' }}
|
||||
</ion-col>
|
||||
<ion-col size="2">
|
||||
<ion-button
|
||||
strong
|
||||
size="small"
|
||||
style="float: right"
|
||||
(click)="presentModalAddPhysical(disk, i)"
|
||||
>
|
||||
<ion-icon name="add" slot="start"></ion-icon>
|
||||
Save
|
||||
</ion-button>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
|
||||
<p *ngIf="!targets['unknown-disks'].length">
|
||||
To add a new physical backup target, connect the drive and click
|
||||
refresh.
|
||||
</p>
|
||||
</ng-template>
|
||||
</ion-grid>
|
||||
</div>
|
||||
|
||||
<!-- saved targets -->
|
||||
<ion-item-divider>
|
||||
Saved Targets
|
||||
<ion-button
|
||||
class="ion-padding-start"
|
||||
strong
|
||||
size="small"
|
||||
(click)="presentModalAddRemote()"
|
||||
>
|
||||
<ion-icon slot="start" name="add"></ion-icon>
|
||||
Add Target
|
||||
</ion-button>
|
||||
</ion-item-divider>
|
||||
|
||||
<div class="grid-fixed">
|
||||
<ion-grid class="ion-padding">
|
||||
<ion-row class="grid-headings">
|
||||
<ion-col size="3">Name</ion-col>
|
||||
<ion-col>Type</ion-col>
|
||||
<ion-col>Available</ion-col>
|
||||
<ion-col size="4">Path</ion-col>
|
||||
<ion-col size="2"></ion-col>
|
||||
</ion-row>
|
||||
<!-- loading -->
|
||||
<ng-container *ngIf="loading$ | async; else loaded2">
|
||||
<ion-row
|
||||
*ngFor="let row of ['', '']"
|
||||
class="ion-align-items-center grid-row-border"
|
||||
>
|
||||
<ion-col>
|
||||
<ion-skeleton-text animated></ion-skeleton-text>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ng-container>
|
||||
<!-- loaded -->
|
||||
<ng-template #loaded2>
|
||||
<ion-row
|
||||
*ngFor="let target of targets.saved; let i = index"
|
||||
class="ion-align-items-center grid-row-border"
|
||||
>
|
||||
<ion-col size="3">{{ target.name }}</ion-col>
|
||||
<ion-col class="inline">
|
||||
<ion-icon [name]="getIcon(target.type)" size="small"></ion-icon>
|
||||
{{ target.type | titlecase }}
|
||||
</ion-col>
|
||||
<ion-col>
|
||||
<ion-icon
|
||||
[name]="target.mountable ? 'checkmark' : 'close'"
|
||||
[color]="target.mountable ? 'success' : 'danger'"
|
||||
></ion-icon>
|
||||
</ion-col>
|
||||
<ion-col size="4">{{ target.path }}</ion-col>
|
||||
<ion-col size="2">
|
||||
<ion-buttons style="float: right">
|
||||
<ion-button size="small" (click)="presentModalUpdate(target)">
|
||||
<ion-icon name="pencil"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button
|
||||
size="small"
|
||||
(click)="presentAlertDelete(target.id, i)"
|
||||
>
|
||||
<ion-icon name="trash"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
|
||||
<p *ngIf="!targets.saved.length">No saved backup targets.</p>
|
||||
</ng-template>
|
||||
</ion-grid>
|
||||
</div>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
@@ -1,240 +0,0 @@
|
||||
import { Component } from '@angular/core'
|
||||
import {
|
||||
BackupTarget,
|
||||
BackupTargetType,
|
||||
RR,
|
||||
UnknownDisk,
|
||||
} from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import {
|
||||
cifsSpec,
|
||||
diskBackupTargetSpec,
|
||||
dropboxSpec,
|
||||
googleDriveSpec,
|
||||
remoteBackupTargetSpec,
|
||||
} from '../../types/target-types'
|
||||
import { BehaviorSubject, filter } from 'rxjs'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import {
|
||||
InputSpec,
|
||||
unionSelectKey,
|
||||
unionValueKey,
|
||||
} from '@start9labs/start-sdk/lib/config/configTypes'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { FormPage } from 'src/app/apps/ui/modals/form/form.page'
|
||||
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec'
|
||||
|
||||
type BackupConfig =
|
||||
| {
|
||||
type: {
|
||||
[unionSelectKey]: 'dropbox' | 'google-drive'
|
||||
[unionValueKey]: RR.AddCloudBackupTargetReq
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: {
|
||||
[unionSelectKey]: 'cifs'
|
||||
[unionValueKey]: RR.AddCifsBackupTargetReq
|
||||
}
|
||||
}
|
||||
|
||||
export type BackupType = 'create' | 'restore'
|
||||
|
||||
@Component({
|
||||
selector: 'backup-targets',
|
||||
templateUrl: './backup-targets.page.html',
|
||||
styleUrls: ['./backup-targets.page.scss'],
|
||||
})
|
||||
export class BackupTargetsPage {
|
||||
readonly docsUrl =
|
||||
'https://docs.start9.com/latest/user-manual/backups/backup-targets'
|
||||
targets: RR.GetBackupTargetsRes = {
|
||||
'unknown-disks': [],
|
||||
saved: [],
|
||||
}
|
||||
|
||||
loading$ = new BehaviorSubject(true)
|
||||
|
||||
constructor(
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly api: ApiService,
|
||||
private readonly formDialog: FormDialogService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.getTargets()
|
||||
}
|
||||
|
||||
async presentModalAddPhysical(disk: UnknownDisk, index: number) {
|
||||
this.formDialog.open(FormPage, {
|
||||
label: 'New Physical Target',
|
||||
data: {
|
||||
spec: await configBuilderToSpec(diskBackupTargetSpec),
|
||||
value: {
|
||||
name: disk.label || disk.logicalname,
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
text: 'Save',
|
||||
handler: (value: Omit<RR.AddDiskBackupTargetReq, 'logicalname'>) =>
|
||||
this.add('disk', {
|
||||
logicalname: disk.logicalname,
|
||||
...value,
|
||||
}).then(disk => {
|
||||
this.targets['unknown-disks'].splice(index, 1)
|
||||
this.targets.saved.push(disk)
|
||||
|
||||
return true
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async presentModalAddRemote() {
|
||||
this.formDialog.open(FormPage, {
|
||||
label: 'New Remote Target',
|
||||
data: {
|
||||
spec: await configBuilderToSpec(remoteBackupTargetSpec),
|
||||
buttons: [
|
||||
{
|
||||
text: 'Save',
|
||||
handler: ({ type }: BackupConfig) =>
|
||||
this.add(
|
||||
type[unionSelectKey] === 'cifs' ? 'cifs' : 'cloud',
|
||||
type[unionValueKey],
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async presentModalUpdate(target: BackupTarget) {
|
||||
let spec: InputSpec
|
||||
|
||||
switch (target.type) {
|
||||
case 'cifs':
|
||||
spec = await configBuilderToSpec(cifsSpec)
|
||||
break
|
||||
case 'cloud':
|
||||
spec = await configBuilderToSpec(
|
||||
target.provider === 'dropbox' ? dropboxSpec : googleDriveSpec,
|
||||
)
|
||||
break
|
||||
case 'disk':
|
||||
spec = await configBuilderToSpec(diskBackupTargetSpec)
|
||||
break
|
||||
}
|
||||
|
||||
this.formDialog.open(FormPage, {
|
||||
label: 'Update Target',
|
||||
data: {
|
||||
spec,
|
||||
value: target,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Save',
|
||||
handler: (
|
||||
value:
|
||||
| RR.UpdateCifsBackupTargetReq
|
||||
| RR.UpdateCloudBackupTargetReq
|
||||
| RR.UpdateDiskBackupTargetReq,
|
||||
) => this.update(target.type, { ...value, id: target.id }),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
presentAlertDelete(id: string, index: number) {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, {
|
||||
label: 'Confirm',
|
||||
size: 's',
|
||||
data: {
|
||||
content: 'Forget backup target? This actions cannot be undone.',
|
||||
no: 'Cancel',
|
||||
yes: 'Delete',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => this.delete(id, index))
|
||||
}
|
||||
|
||||
async delete(id: string, index: number): Promise<void> {
|
||||
const loader = this.loader.open('Removing...').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.removeBackupTarget({ id })
|
||||
this.targets.saved.splice(index, 1)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.loading$.next(true)
|
||||
await this.getTargets()
|
||||
}
|
||||
|
||||
getIcon(type: BackupTargetType) {
|
||||
switch (type) {
|
||||
case 'disk':
|
||||
return 'save-outline'
|
||||
case 'cifs':
|
||||
return 'folder-open-outline'
|
||||
case 'cloud':
|
||||
return 'cloud-outline'
|
||||
}
|
||||
}
|
||||
|
||||
private async getTargets(): Promise<void> {
|
||||
try {
|
||||
this.targets = await this.api.getBackupTargets({})
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading$.next(false)
|
||||
}
|
||||
}
|
||||
|
||||
private async add(
|
||||
type: BackupTargetType,
|
||||
value:
|
||||
| RR.AddCifsBackupTargetReq
|
||||
| RR.AddCloudBackupTargetReq
|
||||
| RR.AddDiskBackupTargetReq,
|
||||
): Promise<BackupTarget> {
|
||||
const loader = this.loader.open('Saving target...').subscribe()
|
||||
|
||||
try {
|
||||
return await this.api.addBackupTarget(type, value)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async update(
|
||||
type: BackupTargetType,
|
||||
value:
|
||||
| RR.UpdateCifsBackupTargetReq
|
||||
| RR.UpdateCloudBackupTargetReq
|
||||
| RR.UpdateDiskBackupTargetReq,
|
||||
): Promise<BackupTarget> {
|
||||
const loader = this.loader.open('Saving target...').subscribe()
|
||||
|
||||
try {
|
||||
return await this.api.updateBackupTarget(type, value)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { BadgeMenuComponentModule } from 'src/app/common/badge-menu-button/badge-menu.component.module'
|
||||
import { InsecureWarningComponentModule } from 'src/app/common/insecure-warning/insecure-warning.module'
|
||||
import { BackupCreateDirective } from '../../directives/backup-create.directive'
|
||||
import { BackupRestoreDirective } from '../../directives/backup-restore.directive'
|
||||
import {
|
||||
BackingUpComponent,
|
||||
PkgMainStatusPipe,
|
||||
} from '../../components/backing-up/backing-up.component'
|
||||
import { BackupSelectPageModule } from '../../modals/backup-select/backup-select.module'
|
||||
import { RecoverSelectPageModule } from '../../modals/recover-select/recover-select.module'
|
||||
import { TargetPipesModule } from '../../pipes/target-pipes.module'
|
||||
import { BackupsPage } from './backups.page'
|
||||
import { PromptModule } from 'src/app/apps/ui/modals/prompt/prompt.module'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: BackupsPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
BackupSelectPageModule,
|
||||
RecoverSelectPageModule,
|
||||
BadgeMenuComponentModule,
|
||||
InsecureWarningComponentModule,
|
||||
TargetPipesModule,
|
||||
PromptModule,
|
||||
],
|
||||
declarations: [
|
||||
BackupsPage,
|
||||
BackupCreateDirective,
|
||||
BackupRestoreDirective,
|
||||
BackingUpComponent,
|
||||
PkgMainStatusPipe,
|
||||
],
|
||||
})
|
||||
export class BackupsPageModule {}
|
||||
@@ -1,112 +0,0 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Backups</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<badge-menu-button></badge-menu-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<insecure-warning *ngIf="!secure"></insecure-warning>
|
||||
|
||||
<ion-item-group>
|
||||
<ion-item-divider>Options</ion-item-divider>
|
||||
|
||||
<ion-item button backupCreate>
|
||||
<ion-icon slot="start" name="add"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>Create a Backup</h2>
|
||||
<p>Create a one-time backup</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item button backupRestore>
|
||||
<ion-icon slot="start" name="color-wand-outline"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>Restore From Backup</h2>
|
||||
<p>Restore services from backup</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item button routerLink="jobs">
|
||||
<ion-icon slot="start" name="hammer-outline"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>Jobs</h2>
|
||||
<p>Manage backup jobs</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item button routerLink="targets">
|
||||
<ion-icon slot="start" name="server-outline"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>Targets</h2>
|
||||
<p>Manage backup targets</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item button routerLink="history">
|
||||
<ion-icon slot="start" name="archive-outline"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>History</h2>
|
||||
<p>View your entire backup history</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item-divider>Upcoming Jobs</ion-item-divider>
|
||||
|
||||
<div class="grid-fixed">
|
||||
<ion-grid class="ion-padding">
|
||||
<ion-row class="grid-headings">
|
||||
<ion-col size="3">Scheduled</ion-col>
|
||||
<ion-col size="2.5">Job</ion-col>
|
||||
<ion-col size="3">Target</ion-col>
|
||||
<ion-col size="2.5">Packages</ion-col>
|
||||
</ion-row>
|
||||
<!-- loaded -->
|
||||
<ng-container *ngIf="upcoming$ | async as upcoming; else loading;">
|
||||
<ng-container *ngIf="current$ | async as current">
|
||||
<ion-row
|
||||
*ngFor="let upcoming of upcoming"
|
||||
class="ion-align-items-center grid-row-border"
|
||||
>
|
||||
<ion-col size="3">
|
||||
<ion-text
|
||||
*ngIf="current.id === upcoming.id; else notRunning"
|
||||
color="success"
|
||||
>
|
||||
Running
|
||||
</ion-text>
|
||||
<ng-template #notRunning>
|
||||
{{ upcoming.next | date : 'MMM d, y, h:mm a' }}
|
||||
</ng-template>
|
||||
</ion-col>
|
||||
<ion-col size="2.5">{{ upcoming.name }}</ion-col>
|
||||
<ion-col size="3" class="inline">
|
||||
<ion-icon
|
||||
[name]="(upcoming.target | getDisplayInfo).icon"
|
||||
size="small"
|
||||
></ion-icon>
|
||||
{{ upcoming.target.name }}
|
||||
</ion-col>
|
||||
<ion-col size="2.5">
|
||||
{{ upcoming['package-ids'].length }} Packages
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
|
||||
<p *ngIf="!upcoming.length">
|
||||
You have no active or upcoming backup jobs.
|
||||
</p>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<!-- loading -->
|
||||
<ng-template #loading>
|
||||
<ion-row
|
||||
*ngFor="let row of ['', '']"
|
||||
class="ion-align-items-center grid-row-border"
|
||||
>
|
||||
<ion-col>
|
||||
<ion-skeleton-text animated></ion-skeleton-text>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ng-template>
|
||||
</ion-grid>
|
||||
</div>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
@@ -1,42 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { from, map } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { CronJob } from 'cron'
|
||||
|
||||
@Component({
|
||||
selector: 'backups',
|
||||
templateUrl: './backups.page.html',
|
||||
styleUrls: ['./backups.page.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BackupsPage {
|
||||
readonly secure = this.config.isSecure()
|
||||
readonly current$ = this.patch
|
||||
.watch$('server-info', 'status-info', 'current-backup', 'job')
|
||||
.pipe(map(job => job || {}))
|
||||
readonly upcoming$ = from(this.api.getBackupJobs({})).pipe(
|
||||
map(jobs =>
|
||||
jobs
|
||||
.map(job => {
|
||||
const nextDate = new CronJob(job.cron, () => {}).nextDate()
|
||||
const next = nextDate.toISO()
|
||||
const diff = nextDate.diffNow().milliseconds
|
||||
return {
|
||||
...job,
|
||||
next,
|
||||
diff,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.diff - b.diff),
|
||||
),
|
||||
)
|
||||
|
||||
constructor(
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly config: ConfigService,
|
||||
private readonly api: ApiService,
|
||||
) {}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { BackupTarget } from 'src/app/services/api/api.types'
|
||||
|
||||
@Pipe({
|
||||
name: 'getDisplayInfo',
|
||||
})
|
||||
export class GetDisplayInfoPipe implements PipeTransform {
|
||||
transform(target: BackupTarget): DisplayInfo {
|
||||
const toReturn: DisplayInfo = {
|
||||
name: target.name,
|
||||
path: `Path: ${target.path}`,
|
||||
description: '',
|
||||
icon: '',
|
||||
}
|
||||
|
||||
switch (target.type) {
|
||||
case 'cifs':
|
||||
toReturn.description = `Network Folder: ${target.hostname}`
|
||||
toReturn.icon = 'folder-open-outline'
|
||||
break
|
||||
case 'disk':
|
||||
toReturn.description = `Physical Drive: ${
|
||||
target.vendor || 'Unknown Vendor'
|
||||
}, ${target.model || 'Unknown Model'}`
|
||||
toReturn.icon = 'save-outline'
|
||||
break
|
||||
case 'cloud':
|
||||
toReturn.description = `Provider: ${target.provider}`
|
||||
toReturn.icon = 'cloud-outline'
|
||||
break
|
||||
}
|
||||
|
||||
return toReturn
|
||||
}
|
||||
}
|
||||
|
||||
interface DisplayInfo {
|
||||
name: string
|
||||
path: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { BackupTarget } from 'src/app/services/api/api.types'
|
||||
import { Emver } from '@start9labs/shared'
|
||||
|
||||
@Pipe({
|
||||
name: 'hasValidBackup',
|
||||
})
|
||||
export class HasValidBackupPipe implements PipeTransform {
|
||||
constructor(private readonly emver: Emver) {}
|
||||
|
||||
transform(target: BackupTarget): boolean {
|
||||
const backup = target['embassy-os']
|
||||
return !!backup && this.emver.compare(backup.version, '0.3.0') !== -1
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { HasValidBackupPipe } from './has-valid-backup.pipe'
|
||||
import { GetDisplayInfoPipe } from './get-display-info.pipe'
|
||||
|
||||
@NgModule({
|
||||
declarations: [HasValidBackupPipe, GetDisplayInfoPipe],
|
||||
imports: [CommonModule],
|
||||
exports: [HasValidBackupPipe, GetDisplayInfoPipe],
|
||||
})
|
||||
export class TargetPipesModule {}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { Config } from '@start9labs/start-sdk/lib/config/builder/config'
|
||||
import { Value } from '@start9labs/start-sdk/lib/config/builder/value'
|
||||
import { Variants } from '@start9labs/start-sdk/lib/config/builder/variants'
|
||||
|
||||
export const dropboxSpec = Config.of({
|
||||
name: Value.text({
|
||||
name: 'Name',
|
||||
description: 'A friendly name for this Dropbox target',
|
||||
placeholder: 'My Dropbox',
|
||||
required: { default: null },
|
||||
}),
|
||||
token: Value.text({
|
||||
name: 'Access Token',
|
||||
description: 'The secret access token for your custom Dropbox app',
|
||||
required: { default: null },
|
||||
masked: true,
|
||||
}),
|
||||
path: Value.text({
|
||||
name: 'Path',
|
||||
description: 'The fully qualified path to the backup directory',
|
||||
placeholder: 'e.g. /Desktop/my-folder',
|
||||
required: { default: null },
|
||||
}),
|
||||
})
|
||||
|
||||
export const googleDriveSpec = Config.of({
|
||||
name: Value.text({
|
||||
name: 'Name',
|
||||
description: 'A friendly name for this Google Drive target',
|
||||
placeholder: 'My Google Drive',
|
||||
required: { default: null },
|
||||
}),
|
||||
path: Value.text({
|
||||
name: 'Path',
|
||||
description: 'The fully qualified path to the backup directory',
|
||||
placeholder: 'e.g. /Desktop/my-folder',
|
||||
required: { default: null },
|
||||
}),
|
||||
key: Value.file({
|
||||
name: 'Private Key File',
|
||||
description:
|
||||
'Your Google Drive service account private key file (.json file)',
|
||||
required: { default: null },
|
||||
extensions: ['json'],
|
||||
}),
|
||||
})
|
||||
|
||||
export const cifsSpec = Config.of({
|
||||
name: Value.text({
|
||||
name: 'Name',
|
||||
description: 'A friendly name for this Network Folder',
|
||||
placeholder: 'My Network Folder',
|
||||
required: { default: null },
|
||||
}),
|
||||
hostname: Value.text({
|
||||
name: 'Hostname',
|
||||
description:
|
||||
'The hostname of your target device on the Local Area Network.',
|
||||
warning: null,
|
||||
placeholder: `e.g. 'My Computer' OR 'my-computer.local'`,
|
||||
required: { default: null },
|
||||
patterns: [],
|
||||
}),
|
||||
path: Value.text({
|
||||
name: 'Path',
|
||||
description: `On Windows, this is the fully qualified path to the shared folder, (e.g. /Desktop/my-folder).\n\n On Linux and Mac, this is the literal name of the shared folder (e.g. my-shared-folder).`,
|
||||
placeholder: 'e.g. my-shared-folder or /Desktop/my-folder',
|
||||
required: { default: null },
|
||||
}),
|
||||
username: Value.text({
|
||||
name: 'Username',
|
||||
description: `On Linux, this is the samba username you created when sharing the folder.\n\n On Mac and Windows, this is the username of the user who is sharing the folder.`,
|
||||
required: { default: null },
|
||||
placeholder: 'My Network Folder',
|
||||
}),
|
||||
password: Value.text({
|
||||
name: 'Password',
|
||||
description: `On Linux, this is the samba password you created when sharing the folder.\n\n On Mac and Windows, this is the password of the user who is sharing the folder.`,
|
||||
required: false,
|
||||
masked: true,
|
||||
placeholder: 'My Network Folder',
|
||||
}),
|
||||
})
|
||||
|
||||
export const remoteBackupTargetSpec = Config.of({
|
||||
type: Value.union(
|
||||
{
|
||||
name: 'Target Type',
|
||||
required: { default: 'dropbox' },
|
||||
},
|
||||
Variants.of({
|
||||
dropbox: {
|
||||
name: 'Dropbox',
|
||||
spec: dropboxSpec,
|
||||
},
|
||||
'google-drive': {
|
||||
name: 'Google Drive',
|
||||
spec: googleDriveSpec,
|
||||
},
|
||||
cifs: {
|
||||
name: 'Network Folder',
|
||||
spec: cifsSpec,
|
||||
},
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export const diskBackupTargetSpec = Config.of({
|
||||
name: Value.text({
|
||||
name: 'Name',
|
||||
description: 'A friendly name for this physical target',
|
||||
placeholder: 'My Physical Target',
|
||||
required: { default: null },
|
||||
}),
|
||||
path: Value.text({
|
||||
name: 'Path',
|
||||
description: 'The fully qualified path to the backup directory',
|
||||
placeholder: 'e.g. /Backups/my-folder',
|
||||
required: { default: null },
|
||||
}),
|
||||
})
|
||||
@@ -1,26 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { HomePage } from './home.page'
|
||||
import { BadgeMenuComponentModule } from 'src/app/common/badge-menu-button/badge-menu.component.module'
|
||||
import { WidgetListComponentModule } from 'src/app/common/widget-list/widget-list.component.module'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: HomePage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
BadgeMenuComponentModule,
|
||||
WidgetListComponentModule,
|
||||
],
|
||||
declarations: [HomePage],
|
||||
})
|
||||
export class HomePageModule {}
|
||||
@@ -1,13 +0,0 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Home</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<badge-menu-button></badge-menu-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<div class="padding-top">
|
||||
<widget-list></widget-list>
|
||||
</div>
|
||||
</ion-content>
|
||||
@@ -1,9 +0,0 @@
|
||||
.padding-top {
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
@media (min-width: 2000px) {
|
||||
.padding-top {
|
||||
padding-top: 10rem;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Component } from '@angular/core'
|
||||
|
||||
@Component({
|
||||
selector: 'home',
|
||||
templateUrl: 'home.page.html',
|
||||
styleUrls: ['home.page.scss'],
|
||||
})
|
||||
export class HomePage {}
|
||||
@@ -1,134 +0,0 @@
|
||||
import { CommonModule, DOCUMENT } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
HostListener,
|
||||
Inject,
|
||||
Input,
|
||||
inject,
|
||||
} from '@angular/core'
|
||||
import { TuiButtonModule } from '@taiga-ui/core'
|
||||
import { TuiActiveZoneModule } from '@taiga-ui/cdk'
|
||||
import { TuiSidebarModule } from '@taiga-ui/addon-mobile'
|
||||
import { ItemModule, MarketplacePkg } from '@start9labs/marketplace'
|
||||
import { MarketplaceShowControlsComponent } from '../marketplace-show-preview/components/marketplace-show-controls.component'
|
||||
import { MarketplaceShowPreviewModule } from '../marketplace-show-preview/marketplace-show-preview.module'
|
||||
import {
|
||||
DataModel,
|
||||
PackageDataEntry,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { BehaviorSubject, filter, Observable, shareReplay } from 'rxjs'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { SidebarService } from 'src/app/services/sidebar.service'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-item-toggle',
|
||||
template: `
|
||||
<div
|
||||
[id]="pkg.manifest.id"
|
||||
class="block h-full animate"
|
||||
style="--animation-order: {{ index }}"
|
||||
(click)="toggle(true)"
|
||||
(tuiActiveZoneChange)="toggle($event)"
|
||||
>
|
||||
<marketplace-item [pkg]="pkg"></marketplace-item>
|
||||
<marketplace-show-preview
|
||||
[pkg]="pkg"
|
||||
*tuiSidebar="
|
||||
!!(sidebarService.getToggleState(pkg.manifest.id) | async);
|
||||
direction: 'right';
|
||||
autoWidth: true
|
||||
"
|
||||
class="overflow-y-auto max-w-full md:max-w-[30rem]"
|
||||
>
|
||||
<button
|
||||
slot="close"
|
||||
[style.--tui-padding]="0"
|
||||
size="xs"
|
||||
class="place-self-end"
|
||||
tuiIconButton
|
||||
type="button"
|
||||
appearance="icon"
|
||||
icon="tuiIconClose"
|
||||
(tuiActiveZoneChange)="toggle($event)"
|
||||
(click)="toggle(false)"
|
||||
></button>
|
||||
<marketplace-show-controls
|
||||
slot="controls"
|
||||
[pkg]="pkg"
|
||||
[localPkg]="localPkg$ | async"
|
||||
></marketplace-show-controls>
|
||||
</marketplace-show-preview>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
.animate {
|
||||
animation-name: animateIn;
|
||||
animation-duration: 400ms;
|
||||
animation-delay: calc(var(--animation-order) * 200ms);
|
||||
animation-fill-mode: both;
|
||||
animation-timing-function: ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes animateIn {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: scale(0.6) translateY(-20px);
|
||||
}
|
||||
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
TuiActiveZoneModule,
|
||||
TuiButtonModule,
|
||||
TuiSidebarModule,
|
||||
MarketplaceShowPreviewModule,
|
||||
ItemModule,
|
||||
MarketplaceShowControlsComponent,
|
||||
],
|
||||
})
|
||||
export class MarketplaceItemToggleComponent {
|
||||
@Input({ required: true })
|
||||
pkg!: MarketplacePkg
|
||||
|
||||
@Input({ required: true })
|
||||
index!: number
|
||||
|
||||
constructor(
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly activatedRoute: ActivatedRoute,
|
||||
@Inject(DOCUMENT) private readonly document: Document,
|
||||
) {}
|
||||
readonly sidebarService = inject(SidebarService)
|
||||
localPkg$!: Observable<PackageDataEntry>
|
||||
pkgIdQueryParam = new BehaviorSubject<string>('')
|
||||
readonly pkgId = this.activatedRoute.queryParamMap.subscribe(params => {
|
||||
this.pkgIdQueryParam.next(params.get('id')!)
|
||||
})
|
||||
|
||||
ngOnChanges() {
|
||||
this.localPkg$ = this.patch
|
||||
.watch$('package-data', this.pkg.manifest.id)
|
||||
.pipe(filter(Boolean), shareReplay({ bufferSize: 1, refCount: true }))
|
||||
}
|
||||
|
||||
@HostListener('animationend', ['$event.target'])
|
||||
async onAnimationEnd(_target: EventTarget | null) {
|
||||
if (this.pkgIdQueryParam.value === this.pkg.manifest.id) {
|
||||
this.toggle(true)
|
||||
}
|
||||
}
|
||||
|
||||
toggle(open: boolean) {
|
||||
this.sidebarService.toggleState(this.pkg.manifest.id, open)
|
||||
}
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
<menu [iconConfig]="marketplace">
|
||||
<button
|
||||
slot="desktop"
|
||||
tuiIconButton
|
||||
type="button"
|
||||
appearance="icon"
|
||||
icon="tuiIconRepeatLarge"
|
||||
class="hover:opacity-70 bg-zinc-600 rounded-lg -mt-3"
|
||||
(click)="presentModalMarketplaceSettings()"
|
||||
></button>
|
||||
<a
|
||||
slot="mobile"
|
||||
class="flex gap-2 relative hover:no-underline p-5"
|
||||
(click)="presentModalMarketplaceSettings()"
|
||||
>
|
||||
<img
|
||||
alt="Change Registry Icon"
|
||||
width="24"
|
||||
height="24"
|
||||
class="opacity-70 invert"
|
||||
src="svg/repeat-outline.svg"
|
||||
/>
|
||||
<span
|
||||
class="text-base text-zinc-50 text-ellipsis overflow-hidden whitespace-nowrap"
|
||||
>
|
||||
Change Registry
|
||||
</span>
|
||||
</a>
|
||||
</menu>
|
||||
@@ -1,31 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { MarketplaceSettingsPage } from '../../marketplace-list/marketplace-settings/marketplace-settings.page'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-menu',
|
||||
templateUrl: 'marketplace-menu.component.html',
|
||||
styleUrls: ['./marketplace-menu.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class MarketplaceMenuComponent {
|
||||
constructor(
|
||||
@Inject(TuiDialogService) private readonly dialogs: TuiDialogService,
|
||||
readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
readonly marketplace = this.config.marketplace
|
||||
|
||||
async presentModalMarketplaceSettings() {
|
||||
this.dialogs
|
||||
.open<MarketplaceSettingsPage>(
|
||||
new PolymorpheusComponent(MarketplaceSettingsPage),
|
||||
{
|
||||
label: 'Change Registry',
|
||||
},
|
||||
)
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { MarketplaceMenuComponent } from './marketplace-menu.component'
|
||||
import { MenuModule } from '@start9labs/marketplace'
|
||||
import { TuiButtonModule } from '@taiga-ui/core'
|
||||
|
||||
@NgModule({
|
||||
imports: [MenuModule, TuiButtonModule],
|
||||
exports: [MarketplaceMenuComponent],
|
||||
declarations: [MarketplaceMenuComponent],
|
||||
})
|
||||
export class MarketplaceMenuModule {}
|
||||
@@ -1,37 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { ResponsiveColDirective, SharedPipesModule } from '@start9labs/shared'
|
||||
import { FilterPackagesPipeModule } from '@start9labs/marketplace'
|
||||
import { MarketplaceMenuModule } from '../components/marketplace-menu/marketplace-menu.module'
|
||||
import { MarketplaceListPage } from './marketplace-list.page'
|
||||
import { MarketplaceSettingsPageModule } from './marketplace-settings/marketplace-settings.module'
|
||||
import { TuiNotificationModule } from '@taiga-ui/core'
|
||||
import { TuiLetModule } from '@taiga-ui/cdk'
|
||||
import { MarketplaceItemToggleComponent } from '../components/marketplace-item-toggle.component'
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: MarketplaceListPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
SharedPipesModule,
|
||||
FilterPackagesPipeModule,
|
||||
MarketplaceMenuModule,
|
||||
MarketplaceSettingsPageModule,
|
||||
TuiNotificationModule,
|
||||
TuiLetModule,
|
||||
ResponsiveColDirective,
|
||||
MarketplaceItemToggleComponent,
|
||||
],
|
||||
declarations: [MarketplaceListPage],
|
||||
exports: [MarketplaceListPage],
|
||||
})
|
||||
export class MarketplaceListPageModule {}
|
||||
@@ -1,112 +0,0 @@
|
||||
<marketplace-menu></marketplace-menu>
|
||||
<div
|
||||
class="background sm:pl-[34vw] md:pl-[28vw] lg:pl-[22vw] 2xl:pl-[280px] min-h-screen flex justify-between overflow-auto scroll-smooth"
|
||||
>
|
||||
<main class="pt-24 sm:pt-3 md:pb-10 md:px-8">
|
||||
<ng-container *ngIf="details$ | async as details">
|
||||
<!-- icon as empty string displays no icon -->
|
||||
<tui-notification
|
||||
*ngIf="details.url === marketplace.start9"
|
||||
status="success"
|
||||
icon=""
|
||||
class="m-4"
|
||||
>
|
||||
<p>
|
||||
Services from this registry are packaged and maintained by the Start9
|
||||
team. If you experience an issue or have questions related to a
|
||||
service from this registry, one of our dedicated support staff will be
|
||||
happy to assist you.
|
||||
</p>
|
||||
</tui-notification>
|
||||
<tui-notification
|
||||
*ngIf="details.url === marketplace.community"
|
||||
status="info"
|
||||
icon=""
|
||||
class="m-4"
|
||||
>
|
||||
<p>
|
||||
Services from this registry are packaged and maintained by members of
|
||||
the Start9 community.
|
||||
<strong>Install at your own risk</strong>
|
||||
. If you experience an issue or have a question related to a service
|
||||
in this marketplace, please reach out to the package developer for
|
||||
assistance.
|
||||
</p>
|
||||
</tui-notification>
|
||||
<tui-notification
|
||||
*ngIf="details.url.includes('beta')"
|
||||
status="warning"
|
||||
icon=""
|
||||
class="m-4"
|
||||
>
|
||||
<p>
|
||||
Services from this registry are undergoing
|
||||
<strong>beta</strong>
|
||||
testing and may contain bugs.
|
||||
<strong>Install at your own risk</strong>
|
||||
.
|
||||
</p>
|
||||
</tui-notification>
|
||||
<tui-notification
|
||||
*ngIf="details.url.includes('alpha')"
|
||||
status="error"
|
||||
icon=""
|
||||
class="m-4"
|
||||
>
|
||||
<p>
|
||||
Services from this registry are undergoing
|
||||
<strong>alpha</strong>
|
||||
testing. They are expected to contain bugs and could damage your
|
||||
system.
|
||||
<strong>Install at your own risk</strong>
|
||||
.
|
||||
</p>
|
||||
</tui-notification>
|
||||
<tui-notification
|
||||
*ngIf="details.url !== marketplace.community && details.url !== marketplace.start9 && !details.url.includes('beta') && !details.url.includes('alpha')"
|
||||
status="warning"
|
||||
icon=""
|
||||
class="m-4"
|
||||
>
|
||||
<p>
|
||||
This is a Custom Registry. Start9 cannot verify the integrity or
|
||||
functionality of services from this registry, and they could damage
|
||||
your system.
|
||||
<strong>Install at your own risk</strong>
|
||||
.
|
||||
</p>
|
||||
</tui-notification>
|
||||
</ng-container>
|
||||
<div class="mt-8 px-6 mb-10">
|
||||
<h1 class="text-4xl sm:text-5xl font-bold text-zinc-50/80 pb-6">
|
||||
{{ category$ | async | titlecase }}
|
||||
</h1>
|
||||
</div>
|
||||
<ng-container *ngIf="packages$ | async as packages; else loading">
|
||||
<section
|
||||
class="mt-10"
|
||||
*ngIf="
|
||||
packages | filterPackages : (query$ | async) : (category$ | async) as filtered
|
||||
"
|
||||
>
|
||||
<ul
|
||||
class="px-6 md:px-8 grid grid-cols-1 xl:grid-cols-2 2xl:grid-cols-3 gap-16 list-none"
|
||||
>
|
||||
<li *ngFor="let pkg of filtered; index as i">
|
||||
<marketplace-item-toggle
|
||||
[pkg]="pkg"
|
||||
[index]="i"
|
||||
class="block h-full"
|
||||
></marketplace-item-toggle>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</ng-container>
|
||||
<ng-template #loading>
|
||||
<h1 class="text-xl pl-6">
|
||||
Loading
|
||||
<span class="loading-dots"></span>
|
||||
</h1>
|
||||
</ng-template>
|
||||
</main>
|
||||
</div>
|
||||
@@ -1,4 +0,0 @@
|
||||
.background {
|
||||
background: url('/assets/img/background.png') no-repeat center center fixed;
|
||||
z-index: -100;
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
|
||||
import {
|
||||
AbstractCategoryService,
|
||||
AbstractMarketplaceService,
|
||||
} from '@start9labs/marketplace'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { map } from 'rxjs'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { MarketplaceService } from 'src/app/services/marketplace.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { CategoryService } from 'src/app/services/category.service'
|
||||
import { SidebarService } from 'src/app/services/sidebar.service'
|
||||
import { MarketplaceSettingsPage } from './marketplace-settings/marketplace-settings.page'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-list',
|
||||
templateUrl: 'marketplace-list.page.html',
|
||||
styleUrls: ['./marketplace-list.page.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class MarketplaceListPage {
|
||||
constructor(
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
@Inject(AbstractMarketplaceService)
|
||||
private readonly marketplaceService: MarketplaceService,
|
||||
@Inject(AbstractCategoryService)
|
||||
private readonly categoryService: CategoryService,
|
||||
@Inject(TuiDialogService) private readonly dialogs: TuiDialogService,
|
||||
readonly config: ConfigService,
|
||||
readonly sidebarService: SidebarService,
|
||||
) {}
|
||||
|
||||
readonly packages$ = this.marketplaceService.getSelectedStore$().pipe(
|
||||
map(({ packages }) => {
|
||||
this.sidebarService.setMap(packages.map(p => p.manifest.id))
|
||||
return packages
|
||||
}),
|
||||
)
|
||||
readonly localPkgs$ = this.patch.watch$('package-data')
|
||||
readonly category$ = this.categoryService.getCategory$()
|
||||
readonly query$ = this.categoryService.getQuery$()
|
||||
readonly details$ = this.marketplaceService.getSelectedHost$()
|
||||
readonly marketplace = this.config.marketplace
|
||||
|
||||
async presentModalMarketplaceSettings() {
|
||||
this.dialogs
|
||||
.open<MarketplaceSettingsPage>(
|
||||
new PolymorpheusComponent(MarketplaceSettingsPage),
|
||||
{
|
||||
label: 'Change Registry',
|
||||
},
|
||||
)
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
import {
|
||||
TuiDataListModule,
|
||||
TuiHostedDropdownModule,
|
||||
TuiSvgModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { FormPageModule } from 'src/app/apps/ui/modals/form/form.module'
|
||||
import { MarketplaceSettingsPage } from './marketplace-settings.page'
|
||||
import { StoreIconComponentModule } from '@start9labs/marketplace'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
SharedPipesModule,
|
||||
StoreIconComponentModule,
|
||||
TuiHostedDropdownModule,
|
||||
TuiDataListModule,
|
||||
TuiSvgModule,
|
||||
FormPageModule,
|
||||
],
|
||||
declarations: [MarketplaceSettingsPage],
|
||||
})
|
||||
export class MarketplaceSettingsPageModule {}
|
||||
@@ -1,84 +0,0 @@
|
||||
<div class="ion-padding-top">
|
||||
<ion-item-group *ngIf="stores$ | async as stores">
|
||||
<ion-item-divider>Default Registries</ion-item-divider>
|
||||
<ion-item
|
||||
*ngFor="let s of stores.standard"
|
||||
detail="false"
|
||||
[button]="!s.selected"
|
||||
(click)="s.selected ? '' : connect(s.url)"
|
||||
>
|
||||
<ion-avatar slot="start">
|
||||
<store-icon
|
||||
[url]="s.url"
|
||||
[marketplace]="config.marketplace"
|
||||
></store-icon>
|
||||
</ion-avatar>
|
||||
<ion-label>
|
||||
<h2>{{ s.name }}</h2>
|
||||
<p>{{ s.url }}</p>
|
||||
</ion-label>
|
||||
<ion-icon
|
||||
*ngIf="s.selected"
|
||||
slot="end"
|
||||
size="large"
|
||||
name="checkmark"
|
||||
color="success"
|
||||
></ion-icon>
|
||||
</ion-item>
|
||||
|
||||
<ion-item-divider>Custom Registries</ion-item-divider>
|
||||
<ion-item button detail="false" (click)="presentModalAdd()">
|
||||
<ion-icon slot="start" name="add" color="dark"></ion-icon>
|
||||
<ion-label>
|
||||
<ion-text color="dark">
|
||||
<b>Add custom registry</b>
|
||||
</ion-text>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<tui-hosted-dropdown
|
||||
*ngFor="let a of stores.alt"
|
||||
class="host"
|
||||
tuiDropdownLimitWidth="fixed"
|
||||
[canOpen]="!a.selected"
|
||||
[content]="content"
|
||||
>
|
||||
<ion-item detail="false" [button]="!a.selected">
|
||||
<ion-avatar slot="start">
|
||||
<store-icon
|
||||
[url]="a.url"
|
||||
[marketplace]="config.marketplace"
|
||||
size="36px"
|
||||
></store-icon>
|
||||
</ion-avatar>
|
||||
<ion-label>
|
||||
<h2>{{ a.name }}</h2>
|
||||
<p>{{ a.url }}</p>
|
||||
</ion-label>
|
||||
<ion-icon
|
||||
*ngIf="a.selected"
|
||||
slot="end"
|
||||
size="large"
|
||||
name="checkmark"
|
||||
color="success"
|
||||
></ion-icon>
|
||||
</ion-item>
|
||||
<ng-template #content>
|
||||
<tui-data-list>
|
||||
<button
|
||||
tuiOption
|
||||
class="delete"
|
||||
(click)="presentAlertDelete(a.url, a.name)"
|
||||
>
|
||||
Delete
|
||||
<tui-svg src="tuiIconTrash2Large"></tui-svg>
|
||||
</button>
|
||||
<button tuiOption (click)="connect(a.url)">
|
||||
Connect
|
||||
<tui-svg src="tuiIconLogInLarge"></tui-svg>
|
||||
</button>
|
||||
</tui-data-list>
|
||||
</ng-template>
|
||||
</tui-hosted-dropdown>
|
||||
</ion-item-group>
|
||||
</div>
|
||||
@@ -1,16 +0,0 @@
|
||||
ion-item {
|
||||
--background: transparent;
|
||||
}
|
||||
|
||||
.host {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.delete {
|
||||
background: var(--tui-error-bg);
|
||||
color: var(--tui-error-fill);
|
||||
|
||||
&:focus {
|
||||
background: var(--tui-error-bg-hover);
|
||||
}
|
||||
}
|
||||
@@ -1,236 +0,0 @@
|
||||
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
|
||||
import {
|
||||
ErrorService,
|
||||
LoadingService,
|
||||
sameUrl,
|
||||
toUrl,
|
||||
} from '@start9labs/shared'
|
||||
import { AbstractMarketplaceService } from '@start9labs/marketplace'
|
||||
import { ValueSpecObject } from '@start9labs/start-sdk/lib/config/configTypes'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { combineLatest, filter, firstValueFrom, map, Subscription } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { DataModel, UIStore } from 'src/app/services/patch-db/data-model'
|
||||
import { MarketplaceService } from 'src/app/services/marketplace.service'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { FormPage } from 'src/app/apps/ui/modals/form/form.page'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-settings',
|
||||
templateUrl: 'marketplace-settings.page.html',
|
||||
styleUrls: ['marketplace-settings.page.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class MarketplaceSettingsPage {
|
||||
constructor(
|
||||
private readonly api: ApiService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly formDialog: FormDialogService,
|
||||
private readonly errorService: ErrorService,
|
||||
@Inject(AbstractMarketplaceService)
|
||||
private readonly marketplaceService: MarketplaceService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
stores$ = combineLatest([
|
||||
this.marketplaceService.getKnownHosts$(),
|
||||
this.marketplaceService.getSelectedHost$(),
|
||||
]).pipe(
|
||||
map(([stores, selected]) => {
|
||||
const toSlice = stores.map(s => ({
|
||||
...s,
|
||||
selected: sameUrl(s.url, selected.url),
|
||||
}))
|
||||
// 0 and 1 are prod and community
|
||||
const standard = toSlice.slice(0, 2)
|
||||
// 2 and beyond are alts
|
||||
const alt = toSlice.slice(2)
|
||||
|
||||
return { standard, alt }
|
||||
}),
|
||||
)
|
||||
|
||||
async presentModalAdd() {
|
||||
const { name, spec } = getMarketplaceValueSpec()
|
||||
|
||||
this.formDialog.open(FormPage, {
|
||||
label: name,
|
||||
data: {
|
||||
spec,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Save for Later',
|
||||
handler: async (value: { url: string }) => this.saveOnly(value.url),
|
||||
},
|
||||
{
|
||||
text: 'Save and Connect',
|
||||
handler: async (value: { url: string }) =>
|
||||
this.saveAndConnect(value.url),
|
||||
isSubmit: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async presentAlertDelete(url: string, name: string = '') {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, {
|
||||
label: 'Confirm',
|
||||
size: 's',
|
||||
data: {
|
||||
content: `Are you sure you want to delete ${name}?`,
|
||||
yes: 'Delete',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => this.delete(url))
|
||||
}
|
||||
|
||||
async connect(
|
||||
url: string,
|
||||
loader: Subscription = new Subscription(),
|
||||
): Promise<void> {
|
||||
loader.unsubscribe()
|
||||
loader.closed = false
|
||||
loader.add(this.loader.open('Changing Registry...').subscribe())
|
||||
|
||||
try {
|
||||
await this.api.setDbValue<string>(['marketplace', 'selected-url'], url)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async saveOnly(rawUrl: string): Promise<boolean> {
|
||||
const loader = this.loader.open('Loading').subscribe()
|
||||
|
||||
try {
|
||||
const url = new URL(rawUrl).toString()
|
||||
await this.validateAndSave(url, loader)
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
return false
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async saveAndConnect(rawUrl: string): Promise<boolean> {
|
||||
const loader = this.loader.open('Loading').subscribe()
|
||||
|
||||
try {
|
||||
const url = new URL(rawUrl).toString()
|
||||
await this.validateAndSave(url, loader)
|
||||
await this.connect(url, loader)
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
return false
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async validateAndSave(
|
||||
url: string,
|
||||
loader: Subscription,
|
||||
): Promise<void> {
|
||||
// Error on duplicates
|
||||
const hosts = await firstValueFrom(
|
||||
this.patch.watch$('ui', 'marketplace', 'known-hosts'),
|
||||
)
|
||||
const currentUrls = Object.keys(hosts).map(toUrl)
|
||||
if (currentUrls.includes(url)) throw new Error('marketplace already added')
|
||||
|
||||
// Validate
|
||||
loader.unsubscribe()
|
||||
loader.closed = false
|
||||
loader.add(this.loader.open('Validating marketplace...').subscribe())
|
||||
|
||||
const { name } = await firstValueFrom(
|
||||
this.marketplaceService.fetchInfo$(url),
|
||||
)
|
||||
|
||||
// Save
|
||||
loader.unsubscribe()
|
||||
loader.closed = false
|
||||
loader.add(this.loader.open('Saving...').subscribe())
|
||||
|
||||
await this.api.setDbValue<{ name: string }>(
|
||||
['marketplace', 'known-hosts', url],
|
||||
{ name },
|
||||
)
|
||||
}
|
||||
|
||||
private async delete(url: string): Promise<void> {
|
||||
const loader = this.loader.open('Deleting...').subscribe()
|
||||
|
||||
const hosts = await firstValueFrom(
|
||||
this.patch.watch$('ui', 'marketplace', 'known-hosts'),
|
||||
)
|
||||
|
||||
const filtered: { [url: string]: UIStore } = Object.keys(hosts)
|
||||
.filter(key => !sameUrl(key, url))
|
||||
.reduce((prev, curr) => {
|
||||
const name = hosts[curr]
|
||||
return {
|
||||
...prev,
|
||||
[curr]: name,
|
||||
}
|
||||
}, {})
|
||||
|
||||
try {
|
||||
await this.api.setDbValue<{ [url: string]: UIStore }>(
|
||||
['marketplace', 'known-hosts'],
|
||||
filtered,
|
||||
)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getMarketplaceValueSpec(): ValueSpecObject {
|
||||
return {
|
||||
type: 'object',
|
||||
name: 'Add Custom Registry',
|
||||
description: null,
|
||||
warning: null,
|
||||
spec: {
|
||||
url: {
|
||||
type: 'text',
|
||||
name: 'URL',
|
||||
description: 'A fully-qualified URL of the custom registry',
|
||||
inputmode: 'url',
|
||||
required: true,
|
||||
masked: false,
|
||||
minLength: null,
|
||||
maxLength: null,
|
||||
patterns: [
|
||||
{
|
||||
regex: `https?:\/\/[a-zA-Z0-9][a-zA-Z0-9-\.]+[a-zA-Z0-9]\.[^\s]{2,}`,
|
||||
description: 'Must be a valid URL',
|
||||
},
|
||||
],
|
||||
placeholder: 'e.g. https://example.org',
|
||||
default: null,
|
||||
warning: null,
|
||||
disabled: false,
|
||||
immutable: false,
|
||||
generate: null,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Inject,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import {
|
||||
AbstractMarketplaceService,
|
||||
MarketplacePkg,
|
||||
AboutModule,
|
||||
AdditionalModule,
|
||||
DependenciesModule,
|
||||
} from '@start9labs/marketplace'
|
||||
import {
|
||||
Emver,
|
||||
ErrorService,
|
||||
isEmptyObject,
|
||||
LoadingService,
|
||||
pauseFor,
|
||||
sameUrl,
|
||||
EmverPipesModule,
|
||||
MarkdownPipeModule,
|
||||
SharedPipesModule,
|
||||
TextSpinnerComponentModule,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { filter, firstValueFrom, of, Subscription, switchMap } from 'rxjs'
|
||||
import {
|
||||
DataModel,
|
||||
PackageDataEntry,
|
||||
PackageState,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { ClientStorageService } from 'src/app/services/client-storage.service'
|
||||
import { MarketplaceService } from 'src/app/services/marketplace.service'
|
||||
import { hasCurrentDeps } from 'src/app/util/has-deps'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { getAllPackages } from 'src/app/util/get-package-data'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { dryUpdate } from 'src/app/util/dry-update'
|
||||
import { Router } from '@angular/router'
|
||||
import { SidebarService } from 'src/app/services/sidebar.service'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule } from '@angular/router'
|
||||
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-show-controls',
|
||||
template: `
|
||||
<div class="flex justify-start">
|
||||
<button
|
||||
tuiButton
|
||||
type="button"
|
||||
class="mr-2"
|
||||
appearance="primary"
|
||||
*ngIf="localPkg"
|
||||
(click)="showService()"
|
||||
>
|
||||
View Installed
|
||||
</button>
|
||||
<ng-container *ngIf="localPkg; else install">
|
||||
<ng-container *ngIf="localPkg.state === PackageState.Installed">
|
||||
<button
|
||||
tuiButton
|
||||
type="button"
|
||||
class="mr-2"
|
||||
appearance="warning-solid"
|
||||
*ngIf="(localVersion | compareEmver: pkg.manifest.version) === -1"
|
||||
(click)="tryInstall()"
|
||||
>
|
||||
Update
|
||||
</button>
|
||||
<button
|
||||
tuiButton
|
||||
type="button"
|
||||
class="mr-2"
|
||||
appearance="secondary-solid"
|
||||
*ngIf="(localVersion | compareEmver: pkg.manifest.version) === 1"
|
||||
(click)="tryInstall()"
|
||||
>
|
||||
Downgrade
|
||||
</button>
|
||||
<ng-container *ngIf="showDevTools$ | async">
|
||||
<button
|
||||
tuiButton
|
||||
type="button"
|
||||
class="mr-2"
|
||||
appearance="tertiary-solid"
|
||||
*ngIf="(localVersion | compareEmver: pkg.manifest.version) === 0"
|
||||
(click)="tryInstall()"
|
||||
>
|
||||
Reinstall
|
||||
</button>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #install>
|
||||
<button
|
||||
tuiButton
|
||||
type="button"
|
||||
appearance="primary"
|
||||
(click)="tryInstall()"
|
||||
>
|
||||
Install
|
||||
</button>
|
||||
</ng-template>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
button {
|
||||
--tui-padding: 1.5rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule,
|
||||
TextSpinnerComponentModule,
|
||||
SharedPipesModule,
|
||||
EmverPipesModule,
|
||||
MarkdownPipeModule,
|
||||
AboutModule,
|
||||
DependenciesModule,
|
||||
AdditionalModule,
|
||||
TuiButtonModule,
|
||||
],
|
||||
})
|
||||
export class MarketplaceShowControlsComponent {
|
||||
@Input()
|
||||
url?: string
|
||||
|
||||
@Input({ required: true })
|
||||
pkg!: MarketplacePkg
|
||||
|
||||
@Input()
|
||||
localPkg!: PackageDataEntry | null
|
||||
|
||||
readonly showDevTools$ = this.ClientStorageService.showDevTools$
|
||||
readonly PackageState = PackageState
|
||||
private readonly router = inject(Router)
|
||||
readonly sidebarService = inject(SidebarService)
|
||||
|
||||
constructor(
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly ClientStorageService: ClientStorageService,
|
||||
@Inject(AbstractMarketplaceService)
|
||||
private readonly marketplaceService: MarketplaceService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly emver: Emver,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
get localVersion(): string {
|
||||
return this.localPkg?.manifest.version || ''
|
||||
}
|
||||
|
||||
async tryInstall() {
|
||||
this.sidebarService.toggleState(this.pkg.manifest.id, false)
|
||||
const currentMarketplace = await firstValueFrom(
|
||||
this.marketplaceService.getSelectedHost$(),
|
||||
)
|
||||
const url = this.url || currentMarketplace.url
|
||||
|
||||
if (!this.localPkg) {
|
||||
this.alertInstall(url)
|
||||
} else {
|
||||
const originalUrl = this.localPkg.installed?.['marketplace-url']
|
||||
|
||||
if (!sameUrl(url, originalUrl)) {
|
||||
const proceed = await this.presentAlertDifferentMarketplace(
|
||||
url,
|
||||
originalUrl,
|
||||
)
|
||||
if (!proceed) return
|
||||
}
|
||||
|
||||
const currentDeps = hasCurrentDeps(this.localPkg)
|
||||
if (
|
||||
currentDeps &&
|
||||
this.emver.compare(this.localVersion, this.pkg.manifest.version) !== 0
|
||||
) {
|
||||
this.dryInstall(url)
|
||||
} else {
|
||||
this.install(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async showService() {
|
||||
this.sidebarService.toggleState(this.pkg.manifest.id, false)
|
||||
// @TODO code smell - needed to close preview - likely due to sidebar animation
|
||||
await pauseFor(300)
|
||||
this.router.navigate(['/services', this.pkg.manifest.id])
|
||||
}
|
||||
|
||||
private async presentAlertDifferentMarketplace(
|
||||
url: string,
|
||||
originalUrl: string | null | undefined,
|
||||
): Promise<boolean> {
|
||||
const marketplaces = await firstValueFrom(
|
||||
this.patch.watch$('ui', 'marketplace'),
|
||||
)
|
||||
|
||||
const name: string = marketplaces['known-hosts'][url]?.name || url
|
||||
|
||||
let originalName: string | undefined
|
||||
if (originalUrl) {
|
||||
originalName =
|
||||
marketplaces['known-hosts'][originalUrl]?.name || originalUrl
|
||||
}
|
||||
|
||||
return new Promise(async resolve => {
|
||||
this.dialogs
|
||||
.open<boolean>(TUI_PROMPT, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content: `This service was originally ${
|
||||
originalName ? 'installed from ' + originalName : 'side loaded'
|
||||
}, but you are currently connected to ${name}. To install from ${name} anyway, click "Continue".`,
|
||||
yes: 'Continue',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.subscribe(response => resolve(response))
|
||||
})
|
||||
}
|
||||
|
||||
private async dryInstall(url: string) {
|
||||
const breakages = dryUpdate(
|
||||
this.pkg.manifest,
|
||||
await getAllPackages(this.patch),
|
||||
this.emver,
|
||||
)
|
||||
|
||||
if (isEmptyObject(breakages)) {
|
||||
this.install(url)
|
||||
} else {
|
||||
const proceed = await this.presentAlertBreakages(breakages)
|
||||
if (proceed) {
|
||||
this.install(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private alertInstall(url: string) {
|
||||
of(this.pkg.manifest.alerts.install)
|
||||
.pipe(
|
||||
switchMap(content =>
|
||||
!content
|
||||
? of(true)
|
||||
: this.dialogs.open<boolean>(TUI_PROMPT, {
|
||||
label: 'Alert',
|
||||
size: 's',
|
||||
data: {
|
||||
content,
|
||||
yes: 'Install',
|
||||
no: 'Cancel',
|
||||
},
|
||||
}),
|
||||
),
|
||||
filter(Boolean),
|
||||
)
|
||||
.subscribe(() => this.install(url))
|
||||
}
|
||||
|
||||
private async install(url: string, loader?: Subscription) {
|
||||
const message = 'Beginning Install...'
|
||||
|
||||
if (loader) {
|
||||
loader.unsubscribe()
|
||||
loader.closed = false
|
||||
loader.add(this.loader.open(message).subscribe())
|
||||
} else {
|
||||
loader = this.loader.open(message).subscribe()
|
||||
}
|
||||
|
||||
const { id, version } = this.pkg.manifest
|
||||
|
||||
try {
|
||||
await this.marketplaceService.installPackage(id, version, url)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async presentAlertBreakages(breakages: string[]): Promise<boolean> {
|
||||
let content: string =
|
||||
'As a result of this update, the following services will no longer work properly and may crash:<ul>'
|
||||
const bullets = breakages.map(title => `<li><b>${title}</b></li>`)
|
||||
content = `${content}${bullets.join('')}</ul>`
|
||||
|
||||
return new Promise(async resolve => {
|
||||
this.dialogs
|
||||
.open<boolean>(TUI_PROMPT, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content,
|
||||
yes: 'Continue',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.subscribe(response => resolve(response))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
<div class="grid gap-8 p-7 justify-center">
|
||||
<!-- close button -->
|
||||
<ng-content select="[slot=close]"></ng-content>
|
||||
<marketplace-package-hero [pkg]="pkg">
|
||||
<!-- control buttons -->
|
||||
<ng-content select="[slot=controls]"></ng-content>
|
||||
</marketplace-package-hero>
|
||||
<a
|
||||
*ngIf="url$ | async as url"
|
||||
href="{{ url + '/marketplace/' + pkg.manifest.id }}"
|
||||
tuiButton
|
||||
appearance="tertiary-solid"
|
||||
type="button"
|
||||
class="tui-space_right-3 tui-space_bottom-3"
|
||||
iconRight="tuiIconExternalLink"
|
||||
target="_blank"
|
||||
style="margin: 0"
|
||||
>
|
||||
View more details
|
||||
</a>
|
||||
<div class="grid grid-cols-1 gap-x-8">
|
||||
<marketplace-about [pkg]="pkg"></marketplace-about>
|
||||
<div
|
||||
*ngIf="!(pkg.manifest.dependencies | empty)"
|
||||
class="rounded-xl bg-gradient-to-bl from-zinc-400/75 to-zinc-600 p-px shadow-lg shadow-zinc-400/10 mt-6"
|
||||
>
|
||||
<div class="lg:col-span-5 xl:col-span-4 bg-zinc-800 rounded-xl p-7">
|
||||
<h2 class="text-lg font-bold small-caps my-2 pb-3">Dependencies</h2>
|
||||
<div class="grid grid-row-auto gap-3">
|
||||
<div *ngFor="let dep of pkg.manifest.dependencies | keyvalue">
|
||||
<marketplace-dependencies
|
||||
[dep]="dep"
|
||||
[pkg]="pkg"
|
||||
(click)="sidebarService.toggleState(dep.key, true)"
|
||||
></marketplace-dependencies>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<release-notes [pkg]="pkg"></release-notes>
|
||||
<marketplace-additional
|
||||
class="mt-6"
|
||||
[pkg]="pkg"
|
||||
(version)="version$.next($event)"
|
||||
></marketplace-additional>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,59 +0,0 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Inject,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { BehaviorSubject, map } from 'rxjs'
|
||||
import {
|
||||
TuiDialogContext,
|
||||
TuiDialogService,
|
||||
TuiDurationOptions,
|
||||
tuiFadeIn,
|
||||
} from '@taiga-ui/core'
|
||||
import { tuiPure } from '@taiga-ui/cdk'
|
||||
import { PolymorpheusContent } from '@tinkoff/ng-polymorpheus'
|
||||
import { isPlatform } from '@ionic/angular'
|
||||
import {
|
||||
AbstractMarketplaceService,
|
||||
MarketplacePkg,
|
||||
} from '@start9labs/marketplace'
|
||||
import { SidebarService } from 'src/app/services/sidebar.service'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-show-preview',
|
||||
templateUrl: './marketplace-show-preview.component.html',
|
||||
styleUrls: ['./marketplace-show-preview.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
animations: [tuiFadeIn],
|
||||
})
|
||||
export class MarketplaceShowPreviewComponent {
|
||||
@Input({ required: true })
|
||||
pkg!: MarketplacePkg
|
||||
|
||||
constructor(
|
||||
@Inject(TuiDialogService) private readonly dialogs: TuiDialogService,
|
||||
) {}
|
||||
|
||||
readonly sidebarService = inject(SidebarService)
|
||||
private readonly marketplaceService = inject(AbstractMarketplaceService)
|
||||
readonly version$ = new BehaviorSubject('*')
|
||||
index = 0
|
||||
speed = 1000
|
||||
isMobile = isPlatform(window, 'ios') || isPlatform(window, 'android')
|
||||
url$ = this.marketplaceService.getSelectedHost$().pipe(map(({ url }) => url))
|
||||
|
||||
@tuiPure
|
||||
getAnimation(duration: number): TuiDurationOptions {
|
||||
return { value: '', params: { duration } }
|
||||
}
|
||||
|
||||
presentModalImg(content: PolymorpheusContent<TuiDialogContext>) {
|
||||
this.dialogs
|
||||
.open(content, {
|
||||
size: 'l',
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import {
|
||||
SharedPipesModule,
|
||||
TextSpinnerComponentModule,
|
||||
} from '@start9labs/shared'
|
||||
import {
|
||||
AboutModule,
|
||||
AdditionalModule,
|
||||
DependenciesModule,
|
||||
MarketplacePackageHeroComponent,
|
||||
ReleaseNotesModule,
|
||||
} from '@start9labs/marketplace'
|
||||
import { MarketplaceShowPreviewComponent } from './marketplace-show-preview.component'
|
||||
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { RouterModule } from '@angular/router'
|
||||
|
||||
@NgModule({
|
||||
declarations: [MarketplaceShowPreviewComponent],
|
||||
exports: [MarketplaceShowPreviewComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
SharedPipesModule,
|
||||
TextSpinnerComponentModule,
|
||||
RouterModule,
|
||||
DependenciesModule,
|
||||
AdditionalModule,
|
||||
ReleaseNotesModule,
|
||||
TuiButtonModule,
|
||||
AboutModule,
|
||||
MarketplacePackageHeroComponent,
|
||||
],
|
||||
})
|
||||
export class MarketplaceShowPreviewModule {}
|
||||
@@ -1,32 +0,0 @@
|
||||
<ng-container *ngIf="localPkg" [ngSwitch]="localPkg.state">
|
||||
<div *ngSwitchCase="PackageState.Installed">
|
||||
<ion-text
|
||||
*ngIf="(version | compareEmver : localVersion) !== 1"
|
||||
color="primary"
|
||||
>
|
||||
Installed
|
||||
</ion-text>
|
||||
<ion-text
|
||||
*ngIf="(version | compareEmver : localVersion) === 1"
|
||||
color="success"
|
||||
>
|
||||
Update Available
|
||||
</ion-text>
|
||||
</div>
|
||||
<div *ngSwitchCase="PackageState.Removing">
|
||||
<ion-text color="danger">
|
||||
Removing
|
||||
<span class="loading-dots"></span>
|
||||
</ion-text>
|
||||
</div>
|
||||
<div *ngSwitchDefault>
|
||||
<ion-text
|
||||
*ngIf="localPkg['install-progress'] | installProgressDisplay as progress"
|
||||
color="primary"
|
||||
>
|
||||
Installing
|
||||
<span class="loading-dots"></span>
|
||||
{{ progress }}
|
||||
</ion-text>
|
||||
</div>
|
||||
</ng-container>
|
||||
@@ -1,3 +0,0 @@
|
||||
ion-text {
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import {
|
||||
PackageDataEntry,
|
||||
PackageState,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-status',
|
||||
templateUrl: 'marketplace-status.component.html',
|
||||
styleUrls: ['marketplace-status.component.scss'],
|
||||
})
|
||||
export class MarketplaceStatusComponent {
|
||||
@Input({ required: true }) version!: string
|
||||
@Input() localPkg?: PackageDataEntry
|
||||
|
||||
PackageState = PackageState
|
||||
|
||||
get localVersion(): string {
|
||||
return this.localPkg?.manifest.version || ''
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { EmverPipesModule } from '@start9labs/shared'
|
||||
import { InstallProgressPipeModule } from 'src/app/common/install-progress/install-progress.module'
|
||||
import { MarketplaceStatusComponent } from './marketplace-status.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
EmverPipesModule,
|
||||
InstallProgressPipeModule,
|
||||
],
|
||||
declarations: [MarketplaceStatusComponent],
|
||||
exports: [MarketplaceStatusComponent],
|
||||
})
|
||||
export class MarketplaceStatusModule {}
|
||||
@@ -1,19 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
loadChildren: () =>
|
||||
import('./marketplace-list/marketplace-list.module').then(
|
||||
m => m.MarketplaceListPageModule,
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class MarketplaceModule {}
|
||||
@@ -1,30 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
import { TuiPromptModule } from '@taiga-ui/kit'
|
||||
import { NotificationsPage } from './notifications.page'
|
||||
import { BadgeMenuComponentModule } from 'src/app/common/badge-menu-button/badge-menu.component.module'
|
||||
import { BackupReportPageModule } from '../../modals/backup-report/backup-report.module'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: NotificationsPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
BadgeMenuComponentModule,
|
||||
SharedPipesModule,
|
||||
BackupReportPageModule,
|
||||
TuiPromptModule,
|
||||
],
|
||||
declarations: [NotificationsPage],
|
||||
})
|
||||
export class NotificationsPageModule {}
|
||||
@@ -1,142 +0,0 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start" *ngIf="fromToast">
|
||||
<ion-back-button></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Notifications</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<badge-menu-button></badge-menu-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="with-widgets">
|
||||
<!-- loading -->
|
||||
<ion-item-group *ngIf="loading; else loaded">
|
||||
<ion-item-divider>
|
||||
<ion-button slot="end" fill="clear">
|
||||
<ion-skeleton-text
|
||||
style="width: 90px; height: 14px; border-radius: 0"
|
||||
animated
|
||||
></ion-skeleton-text>
|
||||
</ion-button>
|
||||
</ion-item-divider>
|
||||
<ion-item *ngFor="let entry of ['', '', '', '']">
|
||||
<ion-label>
|
||||
<ion-skeleton-text
|
||||
animated
|
||||
style="width: 15%; height: 20px; margin-bottom: 12px"
|
||||
></ion-skeleton-text>
|
||||
<ion-skeleton-text
|
||||
animated
|
||||
style="width: 50%; margin-bottom: 18px"
|
||||
></ion-skeleton-text>
|
||||
<ion-skeleton-text animated style="width: 20%"></ion-skeleton-text>
|
||||
</ion-label>
|
||||
<ion-button slot="end" fill="clear">
|
||||
<ion-skeleton-text
|
||||
animated
|
||||
style="width: 20px; height: 20px; border-radius: 0"
|
||||
></ion-skeleton-text>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
|
||||
<!-- not loading -->
|
||||
<ng-template #loaded>
|
||||
<!-- no notifications -->
|
||||
<ion-item-group *ngIf="!notifications.length; else hasNotifications">
|
||||
<div
|
||||
style="
|
||||
text-align: center;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
"
|
||||
>
|
||||
<ion-icon
|
||||
style="font-size: 84px; color: #767676"
|
||||
name="mail-outline"
|
||||
></ion-icon>
|
||||
<h4 style="color: #767676; margin-top: 0px; font-weight: 600">
|
||||
Important system alerts and notifications from StartOS will display
|
||||
here
|
||||
</h4>
|
||||
</div>
|
||||
</ion-item-group>
|
||||
|
||||
<!-- has notifications -->
|
||||
<ng-template #hasNotifications>
|
||||
<ion-item-group
|
||||
*ngIf="packageData$ | async as packageData"
|
||||
style="margin-bottom: 16px"
|
||||
>
|
||||
<ion-item-divider>
|
||||
<ion-button
|
||||
slot="end"
|
||||
fill="clear"
|
||||
(click)="presentAlertDeleteAll()"
|
||||
strong
|
||||
>
|
||||
Delete All
|
||||
</ion-button>
|
||||
</ion-item-divider>
|
||||
<ion-item *ngFor="let not of notifications; let i = index">
|
||||
<ion-label>
|
||||
<h2>
|
||||
<b>
|
||||
<span *ngIf="not['package-id'] as pkgId">
|
||||
{{ $any(packageData[pkgId])?.manifest.title || pkgId }} -
|
||||
</span>
|
||||
<ion-text [color]="getColor(not)">{{ not.title }}</ion-text>
|
||||
</b>
|
||||
</h2>
|
||||
<h2 class="notification-message">{{ truncate(not.message) }}</h2>
|
||||
<p class="view-message-tag">
|
||||
<a
|
||||
class="view-message-tag"
|
||||
*ngIf="not.message.length > 240"
|
||||
color="dark"
|
||||
(click)="viewFullMessage(not.title, not.message)"
|
||||
>
|
||||
View Full Message
|
||||
</a>
|
||||
</p>
|
||||
<p>{{ not['created-at'] | date: 'medium' }}</p>
|
||||
</ion-label>
|
||||
<ion-button
|
||||
*ngIf="not.code === 1"
|
||||
slot="end"
|
||||
fill="clear"
|
||||
color="dark"
|
||||
(click)="viewBackupReport(not)"
|
||||
>
|
||||
View Report
|
||||
</ion-button>
|
||||
<ion-button
|
||||
*ngIf="not['package-id'] && packageData[not['package-id']]"
|
||||
slot="end"
|
||||
fill="clear"
|
||||
color="dark"
|
||||
[routerLink]="['/services', not['package-id']]"
|
||||
>
|
||||
View Service
|
||||
</ion-button>
|
||||
<ion-button slot="end" fill="clear" (click)="delete(not.id, i)">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
|
||||
<ion-infinite-scroll
|
||||
[disabled]="!needInfinite"
|
||||
(ionInfinite)="doInfinite($event)"
|
||||
>
|
||||
<ion-infinite-scroll-content
|
||||
loadingSpinner="lines"
|
||||
></ion-infinite-scroll-content>
|
||||
</ion-infinite-scroll>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
</ion-content>
|
||||
@@ -1,11 +0,0 @@
|
||||
.notification-message {
|
||||
margin: 6px 0 8px 0;
|
||||
}
|
||||
|
||||
.view-message-tag {
|
||||
margin-bottom: 8px;
|
||||
font-size: 16px;
|
||||
a {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { filter, first } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import {
|
||||
ServerNotifications,
|
||||
NotificationLevel,
|
||||
ServerNotification,
|
||||
} from 'src/app/services/api/api.types'
|
||||
import { BackupReportComponent } from '../../modals/backup-report/backup-report.component'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'notifications',
|
||||
templateUrl: 'notifications.page.html',
|
||||
styleUrls: ['notifications.page.scss'],
|
||||
})
|
||||
export class NotificationsPage {
|
||||
loading = true
|
||||
notifications: ServerNotifications = []
|
||||
beforeCursor?: number
|
||||
needInfinite = false
|
||||
fromToast = !!this.route.snapshot.queryParamMap.get('toast')
|
||||
readonly perPage = 40
|
||||
readonly packageData$ = this.patch.watch$('package-data').pipe(first())
|
||||
|
||||
constructor(
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.notifications = await this.getNotifications()
|
||||
this.loading = false
|
||||
}
|
||||
|
||||
async doInfinite(e: any) {
|
||||
const notifications = await this.getNotifications()
|
||||
this.notifications = this.notifications.concat(notifications)
|
||||
e.target.complete()
|
||||
}
|
||||
|
||||
async getNotifications(): Promise<ServerNotifications> {
|
||||
try {
|
||||
const notifications = await this.embassyApi.getNotifications({
|
||||
before: this.beforeCursor,
|
||||
limit: this.perPage,
|
||||
})
|
||||
|
||||
if (!notifications) return []
|
||||
|
||||
this.beforeCursor = notifications[notifications.length - 1]?.id
|
||||
this.needInfinite = notifications.length >= this.perPage
|
||||
|
||||
return notifications
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
async delete(id: number, index: number): Promise<void> {
|
||||
const loader = this.loader.open('Deleting...').subscribe()
|
||||
|
||||
try {
|
||||
// await this.embassyApi.deleteNotification({ id })
|
||||
this.notifications.splice(index, 1)
|
||||
this.beforeCursor = this.notifications[this.notifications.length - 1]?.id
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
presentAlertDeleteAll() {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, {
|
||||
label: 'Delete All?',
|
||||
size: 's',
|
||||
data: {
|
||||
content: 'Are you sure you want to delete all notifications?',
|
||||
yes: 'Delete',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => this.deleteAll())
|
||||
}
|
||||
|
||||
async viewBackupReport(notification: ServerNotification<number>) {
|
||||
this.dialogs
|
||||
.open(new PolymorpheusComponent(BackupReportComponent), {
|
||||
label: 'Backup Report',
|
||||
data: {
|
||||
report: notification.data,
|
||||
timestamp: notification['created-at'],
|
||||
},
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
viewFullMessage(label: string, message: string) {
|
||||
this.dialogs.open(message, { label }).subscribe()
|
||||
}
|
||||
|
||||
truncate(message: string): string {
|
||||
return message.length <= 240 ? message : '...' + message.substr(-240)
|
||||
}
|
||||
|
||||
getColor({ level }: ServerNotification<number>): string {
|
||||
switch (level) {
|
||||
case NotificationLevel.Info:
|
||||
return 'primary'
|
||||
case NotificationLevel.Success:
|
||||
return 'success'
|
||||
case NotificationLevel.Warning:
|
||||
return 'warning'
|
||||
case NotificationLevel.Error:
|
||||
return 'danger'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteAll(): Promise<void> {
|
||||
const loader = this.loader.open('Deleting...').subscribe()
|
||||
|
||||
try {
|
||||
// await this.embassyApi.deleteAllNotifications({
|
||||
// before: this.notifications[0].id + 1,
|
||||
// })
|
||||
this.notifications = []
|
||||
this.beforeCursor = undefined
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { QrCodeModule } from 'ng-qrcode'
|
||||
|
||||
import { ActionSuccessPage } from './action-success.page'
|
||||
|
||||
@NgModule({
|
||||
declarations: [ActionSuccessPage],
|
||||
imports: [CommonModule, IonicModule, QrCodeModule],
|
||||
exports: [ActionSuccessPage],
|
||||
})
|
||||
export class ActionSuccessPageModule {}
|
||||
@@ -1,22 +0,0 @@
|
||||
<h2 class="ion-padding">{{ actionRes.message }}</h2>
|
||||
|
||||
<div *ngIf="actionRes.value" class="ion-text-center" style="padding: 48px 0">
|
||||
<div *ngIf="actionRes.qr" class="ion-padding-bottom">
|
||||
<qr-code [value]="actionRes.value" size="240"></qr-code>
|
||||
</div>
|
||||
|
||||
<p *ngIf="!actionRes.copyable">{{ actionRes.value }}</p>
|
||||
<a
|
||||
*ngIf="actionRes.copyable"
|
||||
style="cursor: copy"
|
||||
(click)="copyService.copy(actionRes.value)"
|
||||
>
|
||||
<b>{{ actionRes.value }}</b>
|
||||
<sup>
|
||||
<ion-icon
|
||||
name="copy-outline"
|
||||
style="padding-left: 6px; font-size: small"
|
||||
></ion-icon>
|
||||
</sup>
|
||||
</a>
|
||||
</div>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user