chore: refactor interfaces and remove UI routes (#2560)

This commit is contained in:
Alex Inkin
2024-02-17 00:45:30 +04:00
committed by GitHub
parent 513fb3428a
commit c0a55142b5
295 changed files with 465 additions and 13902 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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()
}
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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()
}
}

View File

@@ -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,
}),
}),
)
}

View File

@@ -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
}

View File

@@ -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)

View File

@@ -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')

View File

@@ -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: `

View File

@@ -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>

View File

@@ -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
}
}

View File

@@ -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 {}

View File

@@ -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 {}

View File

@@ -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>

View File

@@ -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);
}

View File

@@ -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()
})
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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
}

View File

@@ -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 {}

View File

@@ -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 {}

View File

@@ -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>
&nbsp;
<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>

View File

@@ -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>) {}
}

View File

@@ -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())
}
}

View File

@@ -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,
})
}
}

View File

@@ -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 {}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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
}
}

View File

@@ -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 {}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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()
}
}
}

View File

@@ -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
}
}

View File

@@ -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 {}

View File

@@ -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>

View File

@@ -1,3 +0,0 @@
ion-item {
--background: transparent;
}

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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 {}

View File

@@ -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>
&nbsp; {{ run.job.target.name }}
</ion-col>
</ion-row>
</ng-template>
</ion-grid>
</div>
</ion-content>

View File

@@ -1,3 +0,0 @@
.highlighted {
background-color: var(--ion-color-medium-shade);
}

View File

@@ -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
}
}

View File

@@ -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 {}

View File

@@ -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>
&nbsp; {{ 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>

View File

@@ -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()
}
}
}

View File

@@ -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>

View File

@@ -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;
}

View File

@@ -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
})
}
}

View File

@@ -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'],
}
}
}

View File

@@ -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
}
}

View File

@@ -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 {}

View File

@@ -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>
&nbsp; {{ 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>

View File

@@ -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()
}
}
}

View File

@@ -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 {}

View File

@@ -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>
&nbsp; {{ 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>

View File

@@ -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,
) {}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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 {}

View File

@@ -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 },
}),
})

View File

@@ -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 {}

View File

@@ -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>

View File

@@ -1,9 +0,0 @@
.padding-top {
padding-top: 2rem;
}
@media (min-width: 2000px) {
.padding-top {
padding-top: 10rem;
}
}

View File

@@ -1,8 +0,0 @@
import { Component } from '@angular/core'
@Component({
selector: 'home',
templateUrl: 'home.page.html',
styleUrls: ['home.page.scss'],
})
export class HomePage {}

View File

@@ -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)
}
}

View File

@@ -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>

View File

@@ -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()
}
}

View File

@@ -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 {}

View File

@@ -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 {}

View File

@@ -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>

View File

@@ -1,4 +0,0 @@
.background {
background: url('/assets/img/background.png') no-repeat center center fixed;
z-index: -100;
}

View File

@@ -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()
}
}

View File

@@ -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 {}

View File

@@ -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>

View File

@@ -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);
}
}

View File

@@ -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,
},
},
}
}

View File

@@ -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))
})
}
}

View File

@@ -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>

View File

@@ -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()
}
}

View File

@@ -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 {}

View File

@@ -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>

View File

@@ -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 || ''
}
}

View File

@@ -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 {}

View File

@@ -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 {}

View File

@@ -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 {}

View File

@@ -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>

View File

@@ -1,11 +0,0 @@
.notification-message {
margin: 6px 0 8px 0;
}
.view-message-tag {
margin-bottom: 8px;
font-size: 16px;
a {
cursor: pointer;
}
}

View File

@@ -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()
}
}
}

View File

@@ -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 {}

View File

@@ -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