refactor: refactor backups page to get rid of ionic (#2446)

This commit is contained in:
Alex Inkin
2023-10-13 19:04:20 +04:00
committed by GitHub
parent cb36754c46
commit df7a30bd14
54 changed files with 2313 additions and 166 deletions

View File

@@ -23,13 +23,13 @@
"@start9labs/argon2": "^0.1.0",
"@start9labs/emver": "^0.1.5",
"@start9labs/start-sdk": "0.4.0-rev0.lib0.rc8.beta2",
"@taiga-ui/addon-charts": "3.45.0",
"@taiga-ui/cdk": "3.45.0",
"@taiga-ui/core": "3.45.0",
"@taiga-ui/experimental": "3.45.0",
"@taiga-ui/icons": "3.45.0",
"@taiga-ui/kit": "3.45.0",
"@taiga-ui/styles": "3.45.0",
"@taiga-ui/addon-charts": "3.47.0",
"@taiga-ui/cdk": "3.47.0",
"@taiga-ui/core": "3.47.0",
"@taiga-ui/experimental": "3.47.0",
"@taiga-ui/icons": "3.47.0",
"@taiga-ui/kit": "3.47.0",
"@taiga-ui/styles": "3.47.0",
"@tinkoff/ng-dompurify": "4.0.0",
"ansi-to-html": "^0.7.2",
"base64-js": "^1.5.1",
@@ -3632,9 +3632,9 @@
}
},
"node_modules/@ng-web-apis/common": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@ng-web-apis/common/-/common-3.0.2.tgz",
"integrity": "sha512-PWMegIsuxfmya8AgSx4fQR5mt4ozaSflJARN6I4W6kGKxX/MnHGt86+djN3P6KVoWjI+bcQt2UlF1jlW9DgWiQ==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@ng-web-apis/common/-/common-3.0.3.tgz",
"integrity": "sha512-CJm/NYQ4JrN0qNVbPcKeRnZ5nL0zL6RrJrNwBW/LnZEGp9t0mxgLYKw52fM4xRm0OVXOXoRwCbjr8gSUD6vstQ==",
"dependencies": {
"tslib": "^2.2.0"
},
@@ -3645,9 +3645,9 @@
}
},
"node_modules/@ng-web-apis/intersection-observer": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@ng-web-apis/intersection-observer/-/intersection-observer-3.1.2.tgz",
"integrity": "sha512-AojVoHWCS62lJ6LE4BHzyY9E0CXIX8OLmdBw4q6PBJOSZan4vlpup/f9Pl2FPMvw2tVu986IvORFShu1d98y0g==",
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@ng-web-apis/intersection-observer/-/intersection-observer-3.1.3.tgz",
"integrity": "sha512-mGxUcPOJ/y8oXY85c9k2UnZpGElu1wgAwN66brfFNKswwCYM8GLbrIOm0Zsdb6vyJiNFgaoZ+tG+dEZPobCzGQ==",
"dependencies": {
"tslib": "^2.2.0"
},
@@ -3657,9 +3657,9 @@
}
},
"node_modules/@ng-web-apis/mutation-observer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@ng-web-apis/mutation-observer/-/mutation-observer-3.0.2.tgz",
"integrity": "sha512-x1cq/Vznmz4aJ7STbZmA+4HCE+jxDiw2J359+iyiB+xyCVfZTECrJYP9g/hhzIRxyVPFznrPp61TDCRnLVyNWw==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@ng-web-apis/mutation-observer/-/mutation-observer-3.0.3.tgz",
"integrity": "sha512-gl2OGn7+N8w0VuBLzGP5Ypw2nMqbnV3TgNdnQSyCC5I7+3Rz/Q3OzQqciTNUPAqd5HWWwW/IKFPvgI6ePYWXog==",
"dependencies": {
"tslib": "^2.2.0"
},
@@ -3669,9 +3669,9 @@
}
},
"node_modules/@ng-web-apis/resize-observer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@ng-web-apis/resize-observer/-/resize-observer-3.0.2.tgz",
"integrity": "sha512-4aTZNHztwyJe4nJY/++0diUcd8jL7kQS+doPCREE6U4niM8Xvc98uK4qD340Faw9pmybkgsKD7EinyyPE5DIFQ==",
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@ng-web-apis/resize-observer/-/resize-observer-3.0.3.tgz",
"integrity": "sha512-2EVqcl/HTzObQmIgtXEs2KHrPUXC8r6ePPfbAAUbuVdlDAZm6vKsXYHvH+Zkm/JKNp1MZJb/3kb6UkkZtf8ewA==",
"dependencies": {
"tslib": "^2.2.0"
},
@@ -4041,9 +4041,9 @@
}
},
"node_modules/@taiga-ui/addon-charts": {
"version": "3.45.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-3.45.0.tgz",
"integrity": "sha512-KR9d95Hix/+5oiXiyfnqnzpyxw7am689tZ69y+2Unv2kYENQqc0LXf2SG981rIHLJVr4mVDuBfmJ7I32M+zvVw==",
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/addon-charts/-/addon-charts-3.47.0.tgz",
"integrity": "sha512-winbbnpo1hJv6vq/6ov2TEF1OnGCNeLgZAfokJ4/dYTysT9xUPgEiQAff55sqo6zlY2sYLBgHtxn3djeW5bj+Q==",
"dependencies": {
"tslib": ">=2.0.0"
},
@@ -4051,19 +4051,19 @@
"@angular/common": ">=12.0.0",
"@angular/core": ">=12.0.0",
"@ng-web-apis/common": ">=3.0.0",
"@taiga-ui/cdk": ">=3.45.0",
"@taiga-ui/core": ">=3.45.0",
"@taiga-ui/cdk": ">=3.47.0",
"@taiga-ui/core": ">=3.47.0",
"@tinkoff/ng-polymorpheus": ">=4.0.0"
}
},
"node_modules/@taiga-ui/cdk": {
"version": "3.45.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-3.45.0.tgz",
"integrity": "sha512-pFdy5yxkzPGYrtyA1e92SYYXel3uHb+3b2NFjNqbFzLnW3p0NYILZfQPb4I4U9X5cZBhVaFlPGsHXwswCZKqGw==",
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/cdk/-/cdk-3.47.0.tgz",
"integrity": "sha512-SCEnKZq5Psac4NexCPDKmb+38YAcowaWWZNBuOgCJZp63aesHkRkH0KjMCoTBWe19F4EPsE48ARndJa/1wPQkA==",
"dependencies": {
"@ng-web-apis/common": "3.0.2",
"@ng-web-apis/mutation-observer": "3.0.2",
"@ng-web-apis/resize-observer": "3.0.2",
"@ng-web-apis/common": "3.0.3",
"@ng-web-apis/mutation-observer": "3.0.3",
"@ng-web-apis/resize-observer": "3.0.3",
"@tinkoff/ng-event-plugins": "3.1.0",
"@tinkoff/ng-polymorpheus": "4.1.0",
"tslib": "2.6.2"
@@ -4081,11 +4081,11 @@
}
},
"node_modules/@taiga-ui/core": {
"version": "3.45.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-3.45.0.tgz",
"integrity": "sha512-16mCoBlorIx9PHZUGRWfX2K6LTMNo62h4bKOkZEz/l5nxzP+Wsa/vHgmditwE4eKg7v7nHGSPrdmNxlgzcs2dQ==",
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/core/-/core-3.47.0.tgz",
"integrity": "sha512-+NKA/yvvOr/XZvv0DRDV2kTqdZ8W3weQX4t80lS8SvCM++yl4Ep8p7/tSv16LZYSpaFPxzWx7o4bPJkgD5CwzQ==",
"dependencies": {
"@taiga-ui/i18n": "^3.45.0",
"@taiga-ui/i18n": "^3.47.0",
"tslib": ">=2.0.0"
},
"peerDependencies": {
@@ -4097,34 +4097,34 @@
"@angular/router": ">=12.0.0",
"@ng-web-apis/common": ">=3.0.0",
"@ng-web-apis/mutation-observer": ">=3.0.0",
"@taiga-ui/cdk": ">=3.45.0",
"@taiga-ui/i18n": ">=3.45.0",
"@taiga-ui/cdk": ">=3.47.0",
"@taiga-ui/i18n": ">=3.47.0",
"@tinkoff/ng-event-plugins": ">=3.1.0",
"@tinkoff/ng-polymorpheus": ">=4.0.0",
"rxjs": ">=6.0.0"
}
},
"node_modules/@taiga-ui/experimental": {
"version": "3.45.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/experimental/-/experimental-3.45.0.tgz",
"integrity": "sha512-XsYKHl+CSGd/Te4UtQb/nHOOo9jI6UjAcqna2D5aPi/sntgP1m2hY7Wu0mxtk9ExajuqZjxocJSx1mSorwDC/Q==",
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/experimental/-/experimental-3.47.0.tgz",
"integrity": "sha512-cmgVXL5aXPys2qL92Xk/fLcLpU/8EPGkayYN7UtT7MNnz3bFM+vbfiY7qbbXrNUvbPjjt5El81hG4NLu9/XJag==",
"dependencies": {
"tslib": ">=2.0.0"
},
"peerDependencies": {
"@angular/common": ">=12.0.0",
"@angular/core": ">=12.0.0",
"@taiga-ui/cdk": ">=3.45.0",
"@taiga-ui/core": ">=3.45.0",
"@taiga-ui/kit": ">=3.45.0",
"@taiga-ui/cdk": ">=3.47.0",
"@taiga-ui/core": ">=3.47.0",
"@taiga-ui/kit": ">=3.47.0",
"@tinkoff/ng-polymorpheus": ">=4.0.0",
"rxjs": ">=6.0.0"
}
},
"node_modules/@taiga-ui/i18n": {
"version": "3.45.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-3.45.0.tgz",
"integrity": "sha512-Dx8QvGaEu/i7M/F0QXa4fRygk5pL8ZXCnIyvRVWcGoJG9Bzfueb+1gsyBp/b7ogHK3FSgj88QsN1EBW1L0IiXQ==",
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/i18n/-/i18n-3.47.0.tgz",
"integrity": "sha512-41C+ZBm8+rSR5/8ODmBn14dr5Z5MLYTpadXeV7u+AnrfsGQ3hA4D/AeiFHMNhp8zdXnsndDh0m/smB7Vkf2k5w==",
"dependencies": {
"tslib": ">=2.0.0"
},
@@ -4135,25 +4135,25 @@
}
},
"node_modules/@taiga-ui/icons": {
"version": "3.45.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-3.45.0.tgz",
"integrity": "sha512-Dn3ImJx2o3vEGtUn05IeEj0JPEYU3wEUtyXcOpK1mqN2AxYnLmAFU/5AtYMeJeQoGEuFsRlGbZfcihieR1CPsQ==",
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/icons/-/icons-3.47.0.tgz",
"integrity": "sha512-natMScHcxN4o9p7cG1dZncGkqDD7PsCqtx+s9x7hivUz5V/CPxJC8p0qBZRxgwcVZfSvvBbl9iP6Pubazkztxw==",
"dependencies": {
"tslib": ">=2.0.0"
},
"peerDependencies": {
"@taiga-ui/cdk": ">=3.45.0"
"@taiga-ui/cdk": ">=3.47.0"
}
},
"node_modules/@taiga-ui/kit": {
"version": "3.45.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-3.45.0.tgz",
"integrity": "sha512-BlQeWh6x041YOsxHU+e0BZaZofo8QZ04gAtKewvWXXUret0keBEbnXhFpyiQLQR0GjM1/0ls3rPHORW4rYYvUw==",
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/kit/-/kit-3.47.0.tgz",
"integrity": "sha512-un5nqmhxp9Q8Oa1aM5OjHxRTJb920raVImiXmaqJGyFIapgm8evqU6Ru9eaBH82giwMRUrVsjAa/zrzclvLsLg==",
"dependencies": {
"@maskito/angular": "1.7.0",
"@maskito/core": "1.7.0",
"@maskito/kit": "1.7.0",
"@ng-web-apis/intersection-observer": "3.1.2",
"@ng-web-apis/intersection-observer": "3.1.3",
"text-mask-core": "5.1.2",
"tslib": ">=2.0.0"
},
@@ -4165,19 +4165,19 @@
"@ng-web-apis/common": ">=3.0.0",
"@ng-web-apis/mutation-observer": ">=3.0.0",
"@ng-web-apis/resize-observer": ">=3.0.0",
"@taiga-ui/cdk": ">=3.45.0",
"@taiga-ui/core": ">=3.45.0",
"@taiga-ui/i18n": ">=3.45.0",
"@taiga-ui/cdk": ">=3.47.0",
"@taiga-ui/core": ">=3.47.0",
"@taiga-ui/i18n": ">=3.47.0",
"@tinkoff/ng-polymorpheus": ">=4.0.0",
"rxjs": ">=6.0.0"
}
},
"node_modules/@taiga-ui/styles": {
"version": "3.45.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/styles/-/styles-3.45.0.tgz",
"integrity": "sha512-WxJh5/U0JUsiJmPebw5x6PbCcfad7rV+soUhM3pdUb86uQs+grpqinIEXhhyeuIZrylofkyo70lgV13gJHRr/w==",
"version": "3.47.0",
"resolved": "https://registry.npmjs.org/@taiga-ui/styles/-/styles-3.47.0.tgz",
"integrity": "sha512-zgW9IhVhUpax7VuH3K4KL53OkZxy5JIxQ5JuSmLE+XIHsQ3sEDkoqnGSmvkDVbex5fo6Kbs/7iS5G1yKrSWw+Q==",
"peerDependencies": {
"@taiga-ui/cdk": ">=3.45.0",
"@taiga-ui/cdk": ">=3.47.0",
"tslib": ">=2.0.0"
}
},

View File

@@ -44,13 +44,13 @@
"@materia-ui/ngx-monaco-editor": "^6.0.0",
"@start9labs/argon2": "^0.1.0",
"@start9labs/emver": "^0.1.5",
"@taiga-ui/addon-charts": "3.45.0",
"@taiga-ui/cdk": "3.45.0",
"@taiga-ui/core": "3.45.0",
"@taiga-ui/experimental": "3.45.0",
"@taiga-ui/icons": "3.45.0",
"@taiga-ui/kit": "3.45.0",
"@taiga-ui/styles": "3.45.0",
"@taiga-ui/addon-charts": "3.47.0",
"@taiga-ui/cdk": "3.47.0",
"@taiga-ui/core": "3.47.0",
"@taiga-ui/experimental": "3.47.0",
"@taiga-ui/icons": "3.47.0",
"@taiga-ui/kit": "3.47.0",
"@taiga-ui/styles": "3.47.0",
"@tinkoff/ng-dompurify": "4.0.0",
"ansi-to-html": "^0.7.2",
"base64-js": "^1.5.1",

View File

@@ -1,5 +1,12 @@
<span class="link">
<img alt="" class="icon" [src]="icon" />
<tui-svg
*ngIf="icon.startsWith('tuiIcon'); else url"
class="icon"
[src]="icon"
></tui-svg>
<ng-template #url>
<img alt="" class="icon" [src]="icon" />
</ng-template>
<label ticker class="title">{{ title }}</label>
</span>
<span *ngIf="isService" class="side">

View File

@@ -29,6 +29,10 @@
border-radius: 100%;
}
tui-svg.icon {
transform: scale(1.5);
}
.title {
max-width: 100%;
}

View File

@@ -14,7 +14,7 @@ import {
TuiHostedDropdownModule,
TuiSvgModule,
} from '@taiga-ui/core'
import { NavigationService } from '../navigation/navigation.service'
import { NavigationService } from '../../services/navigation.service'
import { Action, ActionsComponent } from '../actions/actions.component'
import { toRouterLink } from '../../utils/to-router-link'

View File

@@ -21,9 +21,9 @@ import {
import { TuiInputModule } from '@taiga-ui/kit'
import { CardComponent } from '../card/card.component'
import { ServicesService } from '../../services/services.service'
import { SYSTEM_UTILITIES } from './drawer.const'
import { toRouterLink } from '../../utils/to-router-link'
import { DrawerItemDirective } from './drawer-item.directive'
import { SYSTEM_UTILITIES } from '../../constants/system-utilities'
@Component({
selector: 'app-drawer',

View File

@@ -4,7 +4,7 @@
routerLinkActive="tab_active"
[routerLinkActiveOptions]="{ exact: true }"
>
<tui-svg src="tuiIconHomeLarge" class="home"></tui-svg>
<tui-svg src="tuiIconHomeLarge" class="icon"></tui-svg>
</a>
<a
*ngFor="let tab of tabs$ | async"
@@ -14,7 +14,14 @@
[routerLinkActiveOptions]="{ exact: true }"
[routerLink]="tab.routerLink"
>
<img class="icon" [src]="tab.icon" [alt]="tab.title" />
<tui-svg
*ngIf="tab.icon.startsWith('tuiIcon'); else url"
class="icon"
[src]="tab.icon"
></tui-svg>
<ng-template #url>
<img class="icon" [src]="tab.icon" [alt]="tab.title" />
</ng-template>
<button
tuiIconButton
size="xs"

View File

@@ -22,6 +22,7 @@
width: 2rem;
height: 2rem;
border-radius: 100%;
color: var(--tui-base-08);
}
.close {
@@ -29,7 +30,3 @@
top: 0;
right: 0;
}
.home {
color: var(--tui-base-08);
}

View File

@@ -2,7 +2,8 @@ import { CommonModule, Location } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { RouterModule } from '@angular/router'
import { TuiButtonModule, TuiSvgModule } from '@taiga-ui/core'
import { NavigationItem, NavigationService } from './navigation.service'
import { NavigationService } from '../../services/navigation.service'
import { NavigationItem } from '../../types/navigation-item'
@Component({
selector: 'nav[appNavigation]',

View File

@@ -1,5 +1,9 @@
export const SYSTEM_UTILITIES: Record<string, { icon: string; title: string }> =
{
'/portal/system/backups': {
icon: 'tuiIconSaveLarge',
title: 'Backups',
},
'/portal/system/devices': {
icon: 'assets/img/icon_transparent.png',
title: 'Devices',

View File

@@ -1,8 +1,7 @@
import { Pipe, PipeTransform } from '@angular/core'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { SYSTEM_UTILITIES } from '../components/drawer/drawer.const'
import { NavigationItem } from '../components/navigation/navigation.service'
import { toRouterLink } from '../utils/to-router-link'
import { NavigationItem } from '../types/navigation-item'
import { toDesktopItem } from '../utils/to-desktop-item'
@Pipe({
name: 'toDesktopItem',
@@ -13,23 +12,6 @@ export class ToDesktopItemPipe implements PipeTransform {
packages: Record<string, PackageDataEntry>,
id: string,
): NavigationItem | null {
if (!id) return null
const item = SYSTEM_UTILITIES[id]
const routerLink = toRouterLink(id)
if (SYSTEM_UTILITIES[id]) {
return {
icon: item.icon,
title: item.title,
routerLink,
}
}
return {
icon: packages[id]?.icon,
title: packages[id]?.manifest.title,
routerLink,
}
return id ? toDesktopItem(id, packages) : null
}
}

View File

@@ -33,7 +33,7 @@ import { GroupActionsPipe } from '../pipes/group-actions.pipe'
template: `
<ng-container *ngIf="pkg$ | async as pkg">
<section>
<h3>Standard Actions</h3>
<h3 class="g-title">Standard Actions</h3>
<button
class="g-action"
[action]="action"
@@ -57,17 +57,6 @@ import { GroupActionsPipe } from '../pipes/group-actions.pipe'
</ng-container>
</ng-container>
`,
styles: [
`
h3 {
text-transform: uppercase;
font-weight: bold;
font-size: 1rem;
margin: 2rem 0 1rem;
color: var(--tui-text-02);
}
`,
],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, ServiceActionComponent, GroupActionsPipe],

View File

@@ -11,7 +11,7 @@
<ng-template #installed>
<ng-container *ngIf="service | toStatus as status">
<section>
<h3 class="title">Status</h3>
<h3 class="g-title">Status</h3>
<service-status
class="status"
[connected]="connected"
@@ -26,7 +26,7 @@
<ng-container *ngIf="isInstalled(service) && !isBackingUp(status)">
<section>
<h3 class="title">Interfaces</h3>
<h3 class="g-title">Interfaces</h3>
<button
*ngFor="let info of service | interfaceInfo"
class="g-action"
@@ -38,7 +38,7 @@
<ng-container *ngIf="isRunning(status)">
<section *ngIf="health$ | async as checks">
<h3 class="title">Health Checks</h3>
<h3 class="g-title">Health Checks</h3>
<service-health-check
*ngFor="let check of checks"
class="g-action"
@@ -50,7 +50,7 @@
<ng-container *ngIf="service | toDependencies as dependencies">
<section *ngIf="dependencies.length">
<h3 class="title">Dependencies</h3>
<h3 class="g-title">Dependencies</h3>
<button
*ngFor="let dep of dependencies"
class="g-action"
@@ -61,7 +61,7 @@
</ng-container>
<section>
<h3 class="title">Menu</h3>
<h3 class="g-title">Menu</h3>
<button
*ngFor="let menu of service | toMenu"
class="g-action"
@@ -82,7 +82,7 @@
</section>
<section>
<h3 class="title">Additional Info</h3>
<h3 class="g-title">Additional Info</h3>
<ng-container *ngFor="let additional of service | toAdditional">
<a
*ngIf="additional.description.startsWith('http'); else button"
@@ -94,7 +94,7 @@
class="g-action"
[class.g-action_static]="!additional.icon"
[additional]="additional"
(click)="additional.action && additional.action()"
(click)="additional.action?.()"
></button>
</ng-template>
</ng-container>

View File

@@ -1,26 +1,15 @@
@import '@taiga-ui/core/styles/taiga-ui-local';
:host {
display: block;
height: 100%;
padding: 1px 2rem 3rem;
box-sizing: border-box;
overflow: auto;
// TODO: Theme
background: #373a3f;
}
.title {
text-transform: uppercase;
font-weight: bold;
font-size: 1rem;
margin: 2rem 0 1rem;
color: var(--tui-text-02);
}
.status {
font-size: x-large;
margin: 1em 0;
display: block;
}
.g-action_static {
cursor: default;
&:hover {
background: transparent;
}
}

View File

@@ -17,7 +17,7 @@ import {
StatusRendering,
} from 'src/app/services/pkg-status-rendering.service'
import { ConnectionService } from 'src/app/services/connection.service'
import { NavigationService } from '../../components/navigation/navigation.service'
import { NavigationService } from '../../services/navigation.service'
import { toRouterLink } from '../../utils/to-router-link'
const STATES = [
@@ -29,6 +29,7 @@ const STATES = [
@Component({
templateUrl: 'service.component.html',
styleUrls: ['service.component.scss'],
host: { class: 'g-page' },
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ServiceComponent {

View File

@@ -0,0 +1,79 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { TuiDialogService, TuiSvgModule } from '@taiga-ui/core'
import { BackupsCreateService } from './services/create.service'
import { BackupsRestoreService } from './services/restore.service'
import { BackupsUpcomingComponent } from './components/upcoming.component'
import { TARGETS } from './modals/targets.component'
import { HISTORY } from './modals/history.component'
import { JOBS } from './modals/jobs.component'
@Component({
template: `
<section>
<h3 class="g-title">Options</h3>
<button
*ngFor="let option of options"
class="g-action"
(click)="option.action()"
>
<tui-svg [src]="option.icon"></tui-svg>
<div>
<strong>{{ option.name }}</strong>
<div>{{ option.description }}</div>
</div>
</button>
</section>
<section>
<h3 class="g-title">Upcoming Jobs</h3>
<table backupsUpcoming class="g-table"></table>
</section>
`,
host: { class: 'g-page' },
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, TuiSvgModule, BackupsUpcomingComponent],
})
export class BackupsComponent {
private readonly dialogs = inject(TuiDialogService)
readonly options = [
{
name: 'Create a Backup',
icon: 'tuiIconPlusLarge',
description: 'Create a one-time backup',
action: inject(BackupsCreateService).handle,
},
{
name: 'Restore from Backup',
icon: 'tuiIconShareLarge',
description: 'Restore services from a backup',
action: inject(BackupsRestoreService).handle,
},
{
name: 'Jobs',
icon: 'tuiIconToolLarge',
description: 'Manage backup jobs',
action: () =>
this.dialogs
.open(JOBS, { label: 'Backup Jobs', size: 'l' })
.subscribe(),
},
{
name: 'Targets',
icon: 'tuiIconDatabaseLarge',
description: 'Manage backup targets',
action: () =>
this.dialogs.open(TARGETS, { label: 'Backup Targets' }).subscribe(),
},
{
name: 'History',
icon: 'tuiIconArchiveLarge',
description: 'View your entire backup history',
action: () =>
this.dialogs
.open(HISTORY, { label: 'Backup History', size: 'l' })
.subscribe(),
},
]
}

View File

@@ -0,0 +1,81 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
Output,
} from '@angular/core'
import { TuiForModule } from '@taiga-ui/cdk'
import { TuiButtonModule, TuiSvgModule } from '@taiga-ui/core'
import { UnknownDisk } from 'src/app/services/api/api.types'
import { IonicModule } from '@ionic/angular'
import { UnitConversionPipesModule } from '@start9labs/shared'
@Component({
selector: 'table[backupsPhysical]',
template: `
<thead>
<tr>
<th>Make/Model</th>
<th>Label</th>
<th>Capacity</th>
<th>Used</th>
<th [style.width.rem]="4.25"></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let disk of backupsPhysical; else: loading; empty: blank">
<td>
{{ disk.vendor || 'unknown make' }},
{{ disk.model || 'unknown model' }}
</td>
<td>{{ disk.label }}</td>
<td>{{ disk.capacity | convertBytes }}</td>
<td>{{ disk.used ? (disk.used | convertBytes) : 'Unknown' }}</td>
<td>
<button
tuiButton
size="xs"
icon="tuiIconPlus"
(click)="add.emit(disk)"
>
Save
</button>
</td>
</tr>
<ng-template #loading>
<tr>
<td colspan="5">
<div class="tui-skeleton">Loading</div>
</td>
</tr>
</ng-template>
<ng-template #blank>
<tr>
<td colspan="5">
To add a new physical backup target, connect the drive and click
refresh.
</td>
</tr>
</ng-template>
</tbody>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
CommonModule,
TuiForModule,
TuiSvgModule,
TuiButtonModule,
IonicModule,
UnitConversionPipesModule,
],
})
export class BackupsPhysicalComponent {
@Input()
backupsPhysical: readonly UnknownDisk[] | null = null
@Output()
readonly add = new EventEmitter<UnknownDisk>()
}

View File

@@ -0,0 +1,69 @@
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
} from '@angular/core'
import { Emver } from '@start9labs/shared'
import { TuiSvgModule } from '@taiga-ui/core'
import { BackupTarget } from 'src/app/services/api/api.types'
import { BackupType } from '../types/backup-type'
@Component({
selector: 'backups-status',
template: `
<tui-svg [src]="status.icon" [style.color]="status.color"></tui-svg>
{{ status.text }}
`,
styles: [':host { display: flex; gap: 0.5rem; align-items: center }'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiSvgModule],
})
export class BackupsStatusComponent {
private readonly emver = inject(Emver)
@Input({ required: true }) type!: BackupType
@Input({ required: true }) target!: BackupTarget
get status() {
if (!this.target.mountable) {
return {
icon: 'tuiIconBarChartLarge',
color: 'var(--tui-negative)',
text: 'Unable to connect',
}
}
if (this.type === 'create') {
return {
icon: 'tuiIconCloudLarge',
color: 'var(--tui-positive)',
text: this.hasBackup
? 'Available, contains existing backup'
: 'Available for fresh backup',
}
}
if (this.hasBackup) {
return {
icon: 'tuiIconCloudLarge',
color: 'var(--tui-positive)',
text: 'Embassy backup detected',
}
}
return {
icon: 'tuiIconCloudOffLarge',
color: 'var(--tui-negative)',
text: 'No Embassy backup',
}
}
private get hasBackup(): boolean {
return (
!!this.target['embassy-os'] &&
this.emver.compare(this.target['embassy-os'].version, '0.3.0') !== -1
)
}
}

View File

@@ -0,0 +1,124 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
inject,
Input,
Output,
} from '@angular/core'
import { TuiForModule } from '@taiga-ui/cdk'
import {
TuiButtonModule,
TuiDialogOptions,
TuiDialogService,
TuiSvgModule,
} from '@taiga-ui/core'
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
import { filter, map, Subject, switchMap } from 'rxjs'
import { BackupTarget } from 'src/app/services/api/api.types'
import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
@Component({
selector: 'table[backupsTargets]',
template: `
<thead>
<tr>
<th>Name</th>
<th>Type</th>
<th>Available</th>
<th>Path</th>
<th [style.width.rem]="3.5"></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let target of backupsTargets; else: loading; empty: blank">
<td>{{ target.name }}</td>
<td>
<tui-svg [src]="target.type | getBackupIcon"></tui-svg>
{{ target.type | titlecase }}
</td>
<td>
<tui-svg
[src]="target.mountable ? 'tuiIconCheck' : 'tuiIconClose'"
[style.color]="
target.mountable ? 'var(--tui-positive)' : 'var(--tui-negative)'
"
></tui-svg>
</td>
<td>{{ target.path }}</td>
<td>
<button
tuiIconButton
size="xs"
appearance="icon"
icon="tuiIconEdit2"
(click)="update.emit(target)"
>
Update
</button>
<button
tuiIconButton
size="xs"
appearance="icon"
icon="tuiIconTrash2"
(click)="delete$.next(target.id)"
>
Delete
</button>
</td>
</tr>
<ng-template #loading>
<tr *ngFor="let i of ['', '']">
<td colspan="5">
<div class="tui-skeleton">Loading</div>
</td>
</tr>
</ng-template>
<ng-template #blank>
<tr><td colspan="5">No saved backup targets.</td></tr>
</ng-template>
</tbody>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
CommonModule,
TuiForModule,
TuiSvgModule,
TuiButtonModule,
GetBackupIconPipe,
],
})
export class BackupsTargetsComponent {
private readonly dialogs = inject(TuiDialogService)
readonly delete$ = new Subject<string>()
readonly update$ = new Subject<BackupTarget>()
@Input()
backupsTargets: readonly BackupTarget[] | null = null
@Output()
readonly update = new EventEmitter<BackupTarget>()
@Output()
readonly delete = this.delete$.pipe(
switchMap(id =>
this.dialogs.open(TUI_PROMPT, OPTIONS).pipe(
filter(Boolean),
map(() => id),
),
),
)
}
const OPTIONS: Partial<TuiDialogOptions<TuiPromptData>> = {
label: 'Confirm',
size: 's',
data: {
content: 'Forget backup target? This actions cannot be undone.',
no: 'Cancel',
yes: 'Delete',
},
}

View File

@@ -0,0 +1,78 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { TuiForModule } from '@taiga-ui/cdk'
import { TuiSvgModule } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { from, map, Observable } from 'rxjs'
import { CronJob } from 'cron'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { BackupJob } from 'src/app/services/api/api.types'
import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
@Component({
selector: 'table[backupsUpcoming]',
template: `
<thead>
<tr>
<th>Scheduled</th>
<th>Job</th>
<th>Target</th>
<th>Packages</th>
</tr>
</thead>
<tbody *ngIf="current$ | async as current">
<tr *ngFor="let job of upcoming$ | async; else: loading; empty: blank">
<td>
<span
*ngIf="current.id === job.id; else notRunning"
[style.color]="'var(--tui-positive)'"
>
Running
</span>
<ng-template #notRunning>
{{ job.next | date : 'MMM d, y, h:mm a' }}
</ng-template>
</td>
<td>{{ job.name }}</td>
<td>
<tui-svg [src]="job.target.type | getBackupIcon"></tui-svg>
{{ job.target.name }}
</td>
<td>Packages: {{ job['package-ids'].length }}</td>
</tr>
<ng-template #blank>
<tr><td colspan="5">You have no active or upcoming backup jobs</td></tr>
</ng-template>
<ng-template #loading>
<tr *ngFor="let row of ['', '']">
<td colspan="5"><div class="tui-skeleton">Loading</div></td>
</tr>
</ng-template>
</tbody>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, TuiForModule, TuiSvgModule, GetBackupIconPipe],
})
export class BackupsUpcomingComponent {
readonly current$: Observable<BackupJob> = inject<PatchDB<DataModel>>(PatchDB)
.watch$('server-info', 'status-info', 'current-backup', 'job')
.pipe(map(job => job || {}))
readonly upcoming$ = from(inject(ApiService).getBackupJobs({})).pipe(
map(jobs =>
jobs
.map(job => {
const nextDate = new CronJob(job.cron, () => {}).nextDate()
return {
...job,
next: nextDate.toISO(),
diff: nextDate.diffNow().milliseconds,
}
})
.sort((a, b) => a.diff - b.diff),
),
)
}

View File

@@ -0,0 +1,131 @@
import { CommonModule } from '@angular/common'
import { Component, inject } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { TuiForModule } from '@taiga-ui/cdk'
import {
TuiButtonModule,
TuiDialogContext,
TuiDialogOptions,
TuiGroupModule,
TuiLoaderModule,
} from '@taiga-ui/core'
import { TuiCheckboxBlockModule } from '@taiga-ui/kit'
import {
POLYMORPHEUS_CONTEXT,
PolymorpheusComponent,
} from '@tinkoff/ng-polymorpheus'
import { PatchDB } from 'patch-db-client'
import { firstValueFrom, map } from 'rxjs'
import { DataModel, PackageState } from 'src/app/services/patch-db/data-model'
interface Package {
id: string
title: string
icon: string
disabled: boolean
checked: boolean
}
@Component({
template: `
<div tuiGroup orientation="vertical">
<tui-checkbox-block
*ngFor="let pkg of pkgs; else: loading; empty: blank"
[disabled]="pkg.disabled"
[(ngModel)]="pkg.checked"
(ngModelChange)="handleChange()"
>
<div class="g-action">
<img class="icon" alt="" [src]="pkg.icon" />
{{ pkg.title }}
</div>
</tui-checkbox-block>
<ng-template #loading><tui-loader></tui-loader></ng-template>
<ng-template #blank>No services installed!</ng-template>
</div>
<footer class="g-buttons">
<button tuiButton appearance="flat" (click)="toggleSelectAll()">
Toggle all
</button>
<button tuiButton [disabled]="!hasSelection" (click)="done()">
{{ context.data.btnText || 'Done' }}
</button>
</footer>
`,
styles: [
`
:host {
display: flex;
flex-direction: column;
margin-top: 1.5rem;
}
.icon {
width: 2.5rem;
border-radius: 100%;
}
`,
],
standalone: true,
imports: [
CommonModule,
FormsModule,
TuiForModule,
TuiButtonModule,
TuiGroupModule,
TuiCheckboxBlockModule,
TuiLoaderModule,
],
})
export class BackupsBackupModal {
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
readonly context =
inject<TuiDialogContext<string[], { btnText: string }>>(
POLYMORPHEUS_CONTEXT,
)
hasSelection = false
pkgs: readonly Package[] | null = null
async ngOnInit() {
this.pkgs = await firstValueFrom(
this.patch.watch$('package-data').pipe(
map(pkgs =>
Object.values(pkgs)
.map(({ manifest: { id, title }, icon, state }) => ({
id,
title,
icon,
disabled: state !== PackageState.Installed,
checked: false,
}))
.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(p => (p.checked = !this.hasSelection && !p.disabled))
this.hasSelection = !this.hasSelection
}
}
export const BACKUP = new PolymorpheusComponent(BackupsBackupModal)
export const BACKUP_OPTIONS: Partial<TuiDialogOptions<unknown>> = {
label: 'Select Services to Back Up',
}

View File

@@ -0,0 +1,152 @@
import { CommonModule } from '@angular/common'
import { Component, inject } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { ErrorService, LoadingService } from '@start9labs/shared'
import {
TuiDialogContext,
TuiDialogService,
TuiWrapperModule,
} from '@taiga-ui/core'
import { TuiBadgeModule, TuiButtonModule } from '@taiga-ui/experimental'
import {
TuiInputModule,
TuiInputNumberModule,
TuiToggleModule,
} from '@taiga-ui/kit'
import {
POLYMORPHEUS_CONTEXT,
PolymorpheusComponent,
} from '@tinkoff/ng-polymorpheus'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { BackupJob, BackupTarget } from 'src/app/services/api/api.types'
import { TARGET, TARGET_CREATE } from './target.component'
import { BACKUP, BACKUP_OPTIONS } from './backup.component'
import { BackupJobBuilder } from '../utils/job-builder'
import { ToHumanCronPipe } from '../pipes/to-human-cron.pipe'
@Component({
template: `
<form class="form">
<tui-input name="name" [(ngModel)]="job.name">
Job Name
<input tuiTextfield placeholder="My Backup Job" />
</tui-input>
<button
tuiButton
appearance="secondary"
type="button"
class="button"
(click)="selectTarget()"
>
Target
<tui-badge [appearance]="job.target.type ? 'success' : 'warning'">
{{ job.target.type || 'Select target' }}
</tui-badge>
</button>
<button
tuiButton
appearance="secondary"
type="button"
class="button"
(click)="selectPackages()"
>
Packages
<tui-badge
[appearance]="job['package-ids'].length ? 'success' : 'warning'"
>
{{ job['package-ids'].length + ' selected' }}
</tui-badge>
</button>
<tui-input name="cron" [(ngModel)]="job.cron">
Schedule
<input tuiTextfield placeholder="* * * * *" />
</tui-input>
<div *ngIf="job.cron | toHumanCron as human" [style.color]="human.color">
{{ human.message }}
</div>
<div *ngIf="!job.job.id" class="g-toggle">
Also Execute Now
<tui-toggle size="l" name="now" [(ngModel)]="job.now"></tui-toggle>
</div>
<button
tuiButton
size="m"
class="submit"
[style.margin-left]="'auto'"
(click)="save()"
>
Save Job
</button>
</form>
`,
styles: [
`
.form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.button[data-size] {
width: unset;
padding: 1rem;
text-indent: 0;
justify-content: space-between;
}
`,
],
standalone: true,
imports: [
CommonModule,
FormsModule,
TuiInputModule,
TuiInputNumberModule,
TuiToggleModule,
TuiWrapperModule,
TuiButtonModule,
TuiBadgeModule,
ToHumanCronPipe,
],
})
export class BackupsEditModal {
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
private readonly loader = inject(LoadingService)
private readonly dialogs = inject(TuiDialogService)
private readonly context =
inject<TuiDialogContext<BackupJob, BackupJobBuilder>>(POLYMORPHEUS_CONTEXT)
get job() {
return this.context.data
}
async save() {
const loader = this.loader.open('Saving Job').subscribe()
try {
const job = this.job.job.id
? await this.api.updateBackupJob(this.job.buildUpdate(this.job.job.id))
: await this.api.createBackupJob(this.job.buildCreate())
this.context.completeWith(job)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
selectTarget() {
this.dialogs.open<BackupTarget>(TARGET, TARGET_CREATE).subscribe(target => {
this.job.target = target
})
}
selectPackages() {
this.dialogs.open<string[]>(BACKUP, BACKUP_OPTIONS).subscribe(id => {
this.job['package-ids'] = id
})
}
}
export const EDIT = new PolymorpheusComponent(BackupsEditModal)

View File

@@ -0,0 +1,198 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import {
ALWAYS_FALSE_HANDLER,
ALWAYS_TRUE_HANDLER,
TuiForModule,
} from '@taiga-ui/cdk'
import {
TuiButtonModule,
TuiDialogService,
TuiLinkModule,
TuiSvgModule,
} from '@taiga-ui/core'
import { TuiCheckboxModule } from '@taiga-ui/kit'
import { BehaviorSubject } from 'rxjs'
import { BackupRun } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DurationPipe } from '../pipes/duration.pipe'
import { HasErrorPipe } from '../pipes/has-error.pipe'
import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
import { REPORT } from './report.component'
@Component({
template: `
<ng-container *ngIf="loading$ | async"></ng-container>
<h3 class="g-title">
Past Events
<button
tuiButton
size="m"
appearance="secondary-destructive"
[disabled]="disabled"
(click)="delete()"
>
Delete Selected
</button>
</h3>
<table class="g-table">
<thead>
<tr>
<th>
<tui-checkbox
[disabled]="!selected.length"
[ngModel]="all"
(ngModelChange)="toggle()"
></tui-checkbox>
</th>
<th>Started At</th>
<th>Duration</th>
<th>Result</th>
<th>Job</th>
<th>Target</th>
</tr>
</thead>
<tbody>
<tr
*ngFor="
let run of runs;
let index = index;
else: loading;
empty: blank
"
[style.background]="selected[index] ? 'var(--tui-clear)' : ''"
>
<td><tui-checkbox [(ngModel)]="selected[index]"></tui-checkbox></td>
<td>{{ run['started-at'] | date : 'medium' }}</td>
<td>
{{ run['started-at'] | duration : run['completed-at'] }} Minutes
</td>
<td>
<tui-svg
*ngIf="run.report | hasError; else noError"
src="tuiIconClose"
[style.color]="'var(--tui-negative)'"
></tui-svg>
<ng-template #noError>
<tui-svg
src="tuiIconCheck"
[style.color]="'var(--tui-positive)'"
></tui-svg>
</ng-template>
<button tuiLink (click)="showReport(run)">Report</button>
</td>
<td>{{ run.job.name || 'No job' }}</td>
<td>
<tui-svg [src]="run.job.target.type | getBackupIcon"></tui-svg>
{{ run.job.target.name }}
</td>
</tr>
<ng-template #loading>
<tr *ngFor="let row of ['', '', '']">
<td colspan="6"><div class="tui-skeleton">Loading</div></td>
</tr>
</ng-template>
<ng-template #blank>
<tr><td colspan="6">No backups have been run yet.</td></tr>
</ng-template>
</tbody>
</table>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
CommonModule,
FormsModule,
TuiForModule,
TuiButtonModule,
TuiCheckboxModule,
TuiSvgModule,
TuiLinkModule,
DurationPipe,
HasErrorPipe,
GetBackupIconPipe,
],
})
export class BackupsHistoryModal {
private readonly api = inject(ApiService)
private readonly dialogs = inject(TuiDialogService)
private readonly errorService = inject(ErrorService)
private readonly loader = inject(LoadingService)
readonly loading$ = new BehaviorSubject(true)
runs: BackupRun[] | null = null
selected: boolean[] = []
get all(): boolean | null {
if (this.selected.length === 0) return false
const response = this.selected[0]
for (let i = 1; i < this.selected.length; i++) {
if (this.selected[i] !== response) {
return null
}
}
return response
}
get disabled() {
return !this.selected.length || !this.selected.some(Boolean)
}
async ngOnInit() {
try {
this.runs = await this.api.getBackupRuns({})
this.selected = this.runs.map(ALWAYS_FALSE_HANDLER)
} catch (e: any) {
this.runs = []
this.errorService.handleError(e)
} finally {
this.loading$.next(false)
}
}
async delete() {
const loader = this.loader.open('Deleting...').subscribe()
const ids = this.selected
.filter(Boolean)
.map((_, i) => this.runs?.[i].id || '')
try {
await this.api.deleteBackupRuns({ ids })
this.runs = this.runs?.filter(r => !ids.includes(r.id)) || []
this.selected = this.runs.map(ALWAYS_FALSE_HANDLER)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
showReport(run: BackupRun) {
this.dialogs
.open(REPORT, {
label: 'Backup Report',
data: {
report: run.report,
timestamp: run['completed-at'],
},
})
.subscribe()
}
toggle() {
if (this.all) {
this.selected = this.selected.map(ALWAYS_FALSE_HANDLER)
} else {
this.selected = this.selected.map(ALWAYS_TRUE_HANDLER)
}
}
}
export const HISTORY = new PolymorpheusComponent(BackupsHistoryModal)

View File

@@ -0,0 +1,176 @@
import { CommonModule } from '@angular/common'
import { Component, inject, OnInit } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiForModule } from '@taiga-ui/cdk'
import {
TuiButtonModule,
TuiDialogOptions,
TuiDialogService,
TuiNotificationModule,
TuiSvgModule,
} from '@taiga-ui/core'
import { TUI_PROMPT, TuiPromptData } 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 { BackupJobBuilder } from '../utils/job-builder'
import { ToHumanCronPipe } from '../pipes/to-human-cron.pipe'
import { GetBackupIconPipe } from '../pipes/get-backup-icon.pipe'
import { EDIT } from './edit.component'
@Component({
template: `
<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="https://docs.start9.com/latest/user-manual/backups/backup-jobs"
target="_blank"
rel="noreferrer"
>
View instructions
</a>
</tui-notification>
<h3 class="g-title">
Saved Jobs
<button tuiButton size="s" icon="tuiIconPlus" (click)="create()">
Create New Job
</button>
</h3>
<table class="g-table">
<thead>
<tr>
<th>Name</th>
<th>Target</th>
<th>Packages</th>
<th>Schedule</th>
<th [style.width.rem]="3.5"></th>
</tr>
</thead>
<tbody>
<tr *ngFor="let job of jobs || null; else: loading; empty: blank">
<td>{{ job.name }}</td>
<td>
<tui-svg [src]="job.target.type | getBackupIcon"></tui-svg>
{{ job.target.name }}
</td>
<td>Packages: {{ job['package-ids'].length }}</td>
<td>{{ (job.cron | toHumanCron).message }}</td>
<td>
<button
tuiIconButton
appearance="icon"
size="xs"
icon="tuiIconEdit2"
(click)="update(job)"
></button>
<button
tuiIconButton
appearance="icon"
size="xs"
icon="tuiIconTrash2"
(click)="delete(job.id)"
></button>
</td>
</tr>
<ng-template #loading>
<tr *ngFor="let i of ['', '']">
<td colspan="5"><div class="tui-skeleton">Loading</div></td>
</tr>
</ng-template>
<ng-template #blank>
<tr><td colspan="5">No jobs found.</td></tr>
</ng-template>
</tbody>
</table>
`,
standalone: true,
imports: [
CommonModule,
TuiForModule,
TuiNotificationModule,
TuiButtonModule,
TuiSvgModule,
ToHumanCronPipe,
GetBackupIconPipe,
],
})
export class BackupsJobsModal implements OnInit {
private readonly dialogs = inject(TuiDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
readonly loading$ = new BehaviorSubject(true)
jobs?: BackupJob[]
async ngOnInit() {
try {
this.jobs = await this.api.getBackupJobs({})
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading$.next(false)
}
}
create() {
this.dialogs
.open<BackupJob>(EDIT, {
label: 'Create New Job',
data: new BackupJobBuilder({
name: `Backup Job ${(this.jobs?.length || 0) + 1}`,
}),
})
.subscribe(job => {
this.jobs = this.jobs?.concat(job)
})
}
update(data: BackupJob) {
this.dialogs
.open<BackupJob>(EDIT, {
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']
})
}
delete(id: string) {
this.dialogs
.open(TUI_PROMPT, PROMPT_OPTIONS)
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Deleting...').subscribe()
try {
await this.api.removeBackupTarget({ id })
this.jobs = this.jobs?.filter(a => a.id !== id)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
})
}
}
const PROMPT_OPTIONS: Partial<TuiDialogOptions<TuiPromptData>> = {
label: 'Confirm',
size: 's',
data: {
content: 'Delete backup job? This action cannot be undone.',
yes: 'Delete',
no: 'Cancel',
},
}
export const JOBS = new PolymorpheusComponent(BackupsJobsModal)

View File

@@ -0,0 +1,131 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { ErrorService, LoadingService } from '@start9labs/shared'
import {
TuiButtonModule,
TuiDialogContext,
TuiGroupModule,
} from '@taiga-ui/core'
import { TuiCheckboxBlockModule } from '@taiga-ui/kit'
import {
POLYMORPHEUS_CONTEXT,
PolymorpheusComponent,
} from '@tinkoff/ng-polymorpheus'
import { PatchDB } from 'patch-db-client'
import { take } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { PackageBackupInfo } from 'src/app/services/api/api.types'
import { ToOptionsPipe } from '../pipes/to-options.pipe'
import { RecoverOption } from '../types/recover-option'
import { RecoverData } from '../types/recover-data'
import { TuiMapperPipeModule } from '@taiga-ui/cdk'
@Component({
template: `
<ng-container *ngIf="packageData$ | toOptions : backups | async as options">
<div tuiGroup orientation="vertical" [style.width.%]="100">
<tui-checkbox-block
*ngFor="let option of options"
[disabled]="option.installed || option['newer-eos']"
[(ngModel)]="option.checked"
>
<div [style.margin]="'0.75rem 0'">
<strong>{{ option.title }}</strong>
<div>Version {{ option.version }}</div>
<div>Backup made: {{ option.timestamp | date : 'medium' }}</div>
<div
*ngIf="option | tuiMapper : toMessage as message"
[style.color]="message.color"
>
{{ message.text }}
</div>
</div>
</tui-checkbox-block>
</div>
<footer class="g-buttons">
<button
tuiButton
[disabled]="isDisabled(options)"
(click)="restore(options)"
>
Restore Selected
</button>
</footer>
</ng-container>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
CommonModule,
FormsModule,
ToOptionsPipe,
TuiButtonModule,
TuiCheckboxBlockModule,
TuiGroupModule,
TuiMapperPipeModule,
],
})
export class BackupsRecoverModal {
private readonly api = inject(ApiService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly context =
inject<TuiDialogContext<void, RecoverData>>(POLYMORPHEUS_CONTEXT)
readonly packageData$ = inject<PatchDB<DataModel>>(PatchDB)
.watch$('package-data')
.pipe(take(1))
readonly toMessage = (option: RecoverOption) => {
if (option['newer-eos']) {
return {
text: `Unavailable. Backup was made on a newer version of StartOS.`,
color: 'var(--tui-error-fill)',
}
}
if (option.installed) {
return {
text: `Unavailable. ${option.title} is already installed.`,
color: 'var(--tui-warning-fill)',
}
}
return {
text: 'Ready to restore',
color: 'var(--tui-success-fill)',
}
}
get backups(): Record<string, PackageBackupInfo> {
return this.context.data.backupInfo['package-backups']
}
isDisabled(options: RecoverOption[]): boolean {
return options.every(o => !o.checked)
}
async restore(options: RecoverOption[]): Promise<void> {
const ids = options.filter(({ checked }) => !!checked).map(({ id }) => id)
const loader = this.loader.open('Initializing...').subscribe()
try {
await this.api.restorePackages({
ids,
'target-id': this.context.data.targetId,
password: this.context.data.password,
})
this.context.$implicit.complete()
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}
export const RECOVER = new PolymorpheusComponent(BackupsRecoverModal)

View File

@@ -0,0 +1,86 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { TuiDialogContext, TuiSvgModule } from '@taiga-ui/core'
import {
POLYMORPHEUS_CONTEXT,
PolymorpheusComponent,
} from '@tinkoff/ng-polymorpheus'
import { BackupReport } from 'src/app/services/api/api.types'
@Component({
template: `
<h3 class="g-title">Completed: {{ timestamp | date : 'medium' }}</h3>
<div class="g-action">
<div [style.flex]="1">
<strong>System data</strong>
<div [style.color]="system.color">{{ system.result }}</div>
</div>
<tui-svg [src]="system.icon" [style.color]="system.color"></tui-svg>
</div>
<div *ngFor="let pkg of report?.packages | keyvalue" class="g-action">
<div [style.flex]="1">
<strong>{{ pkg.key }}</strong>
<div [style.color]="getColor(pkg.value.error)">
{{ pkg.value.error ? 'Failed: ' + pkg.value.error : 'Succeeded' }}
</div>
</div>
<tui-svg
[src]="getIcon(pkg.value.error)"
[style.color]="getColor(pkg.value.error)"
></tui-svg>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [CommonModule, TuiSvgModule],
})
export class BackupsReportModal {
private readonly context =
inject<TuiDialogContext<void, { report: BackupReport; timestamp: string }>>(
POLYMORPHEUS_CONTEXT,
)
readonly system = this.getSystem()
get report(): BackupReport {
return this.context.data.report
}
get timestamp(): string {
return this.context.data.timestamp
}
getColor(error: unknown) {
return error ? 'var(--tui-negative)' : 'var(--tui-positive)'
}
getIcon(error: unknown) {
return error ? 'tuiIconMinusCircleLarge' : 'tuiIconCheckLarge'
}
private getSystem() {
if (!this.report.server.attempted) {
return {
result: 'Not Attempted',
icon: 'tuiIconMinusLarge',
color: 'var(--tui-text-02)',
}
}
if (this.report.server.error) {
return {
result: `Failed: ${this.report.server.error}`,
icon: 'tuiIconMinusCircleLarge',
color: 'var(--tui-negative)',
}
}
return {
result: 'Succeeded',
icon: 'tuiIconCheckLarge',
color: 'var(--tui-positive)',
}
}
}
export const REPORT = new PolymorpheusComponent(BackupsReportModal)

View File

@@ -0,0 +1,125 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ErrorService } from '@start9labs/shared'
import { TuiForModule } from '@taiga-ui/cdk'
import {
TuiButtonModule,
TuiDialogContext,
TuiDialogOptions,
TuiDialogService,
TuiLoaderModule,
TuiSvgModule,
} from '@taiga-ui/core'
import {
POLYMORPHEUS_CONTEXT,
PolymorpheusComponent,
} from '@tinkoff/ng-polymorpheus'
import { BehaviorSubject } from 'rxjs'
import { BackupTarget } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { BackupType } from '../types/backup-type'
import { BackupsStatusComponent } from '../components/status.component'
import { GetDisplayInfoPipe } from '../pipes/get-display-info.pipe'
import { TARGETS } from './targets.component'
@Component({
template: `
<tui-loader
*ngIf="loading$ | async; else loaded"
size="l"
[textContent]="loading"
></tui-loader>
<ng-template #loaded>
<h3 class="g-title">Saved Targets</h3>
<button
*ngFor="let target of targets; empty: blank"
class="g-action"
[disabled]="isDisabled(target)"
(click)="context.completeWith(target)"
>
<ng-container *ngIf="target | getDisplayInfo as displayInfo">
<tui-svg [src]="displayInfo.icon"></tui-svg>
<div>
<strong>{{ displayInfo.name }}</strong>
<backups-status
[type]="context.data.type"
[target]="target"
></backups-status>
<div [style.color]="'var(--tui-text-02'">
{{ displayInfo.description }}
<br />
{{ displayInfo.path }}
</div>
</div>
</ng-container>
</button>
<ng-template #blank>
<p>No saved targets</p>
<button tuiButton (click)="goToTargets()">Go to Targets</button>
</ng-template>
</ng-template>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
CommonModule,
TuiLoaderModule,
TuiForModule,
TuiButtonModule,
TuiSvgModule,
BackupsStatusComponent,
GetDisplayInfoPipe,
],
})
export class BackupsTargetModal {
private readonly dialogs = inject(TuiDialogService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
readonly context =
inject<TuiDialogContext<BackupTarget, { type: BackupType }>>(
POLYMORPHEUS_CONTEXT,
)
readonly loading$ = new BehaviorSubject(true)
readonly loading =
this.context.data.type === 'create'
? 'Loading Backup Targets'
: 'Loading Backup Sources'
targets: BackupTarget[] = []
async ngOnInit() {
try {
this.targets = (await this.api.getBackupTargets({})).saved
} catch (e: any) {
this.errorService.handleError(e)
} finally {
this.loading$.next(false)
}
}
isDisabled(target: BackupTarget): boolean {
return (
!target.mountable ||
(this.context.data.type === 'restore' && !target['embassy-os'])
)
}
goToTargets() {
this.dialogs.open(TARGETS, { label: 'Backup Targets' }).subscribe()
this.context.$implicit.complete()
}
}
export const TARGET = new PolymorpheusComponent(BackupsTargetModal)
export const TARGET_CREATE: Partial<TuiDialogOptions<{ type: BackupType }>> = {
label: 'Select Backup Target',
data: { type: 'create' },
}
export const TARGET_RESTORE: Partial<TuiDialogOptions<{ type: BackupType }>> = {
label: 'Select Backup Source',
data: { type: 'restore' },
}

View File

@@ -0,0 +1,249 @@
import { CommonModule } from '@angular/common'
import { Component, inject, OnInit } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import {
unionSelectKey,
unionValueKey,
} from '@start9labs/start-sdk/lib/config/configTypes'
import { TuiButtonModule, TuiNotificationModule } from '@taiga-ui/core'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import {
BehaviorSubject,
catchError,
from,
Observable,
of,
share,
startWith,
switchMap,
} from 'rxjs'
import { FormPage } from 'src/app/apps/ui/modals/form/form.page'
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec'
import {
cifsSpec,
diskBackupTargetSpec,
dropboxSpec,
googleDriveSpec,
remoteBackupTargetSpec,
} from 'src/app/apps/ui/pages/backups/types/target-types'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import {
BackupTarget,
BackupTargetType,
RR,
UnknownDisk,
} from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { BackupConfig } from '../types/backup-config'
import { BackupsPhysicalComponent } from '../components/physical.component'
import { BackupsTargetsComponent } from '../components/targets.component'
@Component({
template: `
<ng-container *ngIf="loading$ | async"></ng-container>
<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="https://docs.start9.com/latest/user-manual/backups/backup-targets"
target="_blank"
rel="noreferrer"
>
View instructions
</a>
</tui-notification>
<h3 class="g-title">
Unknown Physical Drives
<button tuiButton size="s" icon="tuiIconRefreshCw" (click)="refresh()">
Refresh
</button>
</h3>
<table
class="g-table"
[backupsPhysical]="targets?.['unknown-disks'] || null"
(add)="addPhysical($event)"
></table>
<h3 class="g-title">
Saved Targets
<button tuiButton size="s" icon="tuiIconPlus" (click)="addRemote()">
Add Target
</button>
</h3>
<table
class="g-table"
[backupsTargets]="targets?.saved || null"
(delete)="onDelete($event)"
(update)="onUpdate($event)"
></table>
`,
standalone: true,
imports: [
CommonModule,
TuiNotificationModule,
TuiButtonModule,
BackupsPhysicalComponent,
BackupsTargetsComponent,
],
})
export class BackupsTargetsModal implements OnInit {
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
private readonly formDialog = inject(FormDialogService)
private readonly loader = inject(LoadingService)
readonly loading$ = new BehaviorSubject(true)
targets?: RR.GetBackupTargetsRes
ngOnInit() {
this.refresh()
}
async refresh() {
this.loading$.next(true)
try {
this.targets = await this.api.getBackupTargets({})
} catch (e: any) {
this.errorService.handleError(e)
this.targets = { 'unknown-disks': [], saved: [] }
} finally {
this.loading$.next(false)
}
}
async onDelete(id: string) {
const loader = this.loader.open('Removing...').subscribe()
try {
await this.api.removeBackupTarget({ id })
this.setTargets(this.targets?.saved.filter(a => a.id !== id))
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
async onUpdate(value: BackupTarget) {
this.formDialog.open(FormPage, {
label: 'Update Target',
data: {
value,
spec: await this.getSpec(value),
buttons: [
{
text: 'Save',
handler: (
response:
| RR.UpdateCifsBackupTargetReq
| RR.UpdateCloudBackupTargetReq
| RR.UpdateDiskBackupTargetReq,
) => this.update(value.type, { ...response, id: value.id }),
},
],
},
})
}
async addPhysical(disk: UnknownDisk) {
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(response => {
this.setTargets(
this.targets?.saved.concat(response),
this.targets?.['unknown-disks'].filter(a => a !== disk),
)
return true
}),
},
],
},
})
}
async addRemote() {
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],
),
},
],
},
})
}
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()
}
}
private setTargets(
saved: BackupTarget[] = this.targets?.saved || [],
unknown: UnknownDisk[] = this.targets?.['unknown-disks'] || [],
) {
this.targets = { ['unknown-disks']: unknown, saved }
}
private async getSpec(target: BackupTarget) {
switch (target.type) {
case 'cifs':
return await configBuilderToSpec(cifsSpec)
case 'cloud':
return await configBuilderToSpec(
target.provider === 'dropbox' ? dropboxSpec : googleDriveSpec,
)
case 'disk':
return await configBuilderToSpec(diskBackupTargetSpec)
}
}
}
export const TARGETS = new PolymorpheusComponent(BackupsTargetsModal)

View File

@@ -0,0 +1,11 @@
import { Pipe, PipeTransform } from '@angular/core'
@Pipe({
name: 'duration',
standalone: true,
})
export class DurationPipe implements PipeTransform {
transform(start: string, finish: string): number {
return (new Date(finish).valueOf() - new Date(start).valueOf()) / 100
}
}

View File

@@ -0,0 +1,19 @@
import { Pipe, PipeTransform } from '@angular/core'
import { BackupTargetType } from 'src/app/services/api/api.types'
@Pipe({
name: 'getBackupIcon',
standalone: true,
})
export class GetBackupIconPipe implements PipeTransform {
transform(type: BackupTargetType) {
switch (type) {
case 'cifs':
return 'tuiIconFolder'
case 'cloud':
return 'tuiIconCloud'
case 'disk':
return 'tuiIconSave'
}
}
}

View File

@@ -0,0 +1,40 @@
import { Pipe, PipeTransform } from '@angular/core'
import { BackupTarget } from 'src/app/services/api/api.types'
import { DisplayInfo } from '../types/display-info'
import { GetBackupIconPipe } from './get-backup-icon.pipe'
@Pipe({
name: 'getDisplayInfo',
standalone: true,
})
export class GetDisplayInfoPipe implements PipeTransform {
readonly icon = new GetBackupIconPipe()
transform(target: BackupTarget): DisplayInfo {
const result = {
name: target.name,
path: `Path: ${target.path}`,
icon: this.icon.transform(target.type),
}
switch (target.type) {
case 'cifs':
return {
...result,
description: `Network Folder: ${target.hostname}`,
}
case 'cloud':
return {
...result,
description: `Provider: ${target.provider}`,
}
case 'disk':
return {
...result,
description: `Physical Drive: ${target.vendor || 'Unknown Vendor'}, ${
target.model || 'Unknown Model'
}`,
}
}
}
}

View File

@@ -0,0 +1,15 @@
import { Pipe, PipeTransform } from '@angular/core'
import { BackupReport } from 'src/app/services/api/api.types'
@Pipe({
name: 'hasError',
standalone: true,
})
export class HasErrorPipe implements PipeTransform {
transform(report: BackupReport): boolean {
return (
!!report.server.error ||
!!Object.values(report.packages).find(({ error }) => error)
)
}
}

View File

@@ -0,0 +1,35 @@
import { Pipe, PipeTransform } from '@angular/core'
import cronstrue from 'cronstrue'
@Pipe({
name: 'toHumanCron',
standalone: true,
})
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

@@ -0,0 +1,42 @@
import { inject, 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'
import { RecoverOption } from '../types/recover-option'
@Pipe({
name: 'toOptions',
standalone: true,
})
export class ToOptionsPipe implements PipeTransform {
private readonly config = inject(ConfigService)
private readonly emver = inject(Emver)
transform(
packageData$: Observable<Record<string, PackageDataEntry>>,
packageBackups: Record<string, PackageBackupInfo> = {},
): Observable<RecoverOption[]> {
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

@@ -0,0 +1,46 @@
import { inject, Injectable } from '@angular/core'
import { LoadingService } from '@start9labs/shared'
import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core'
import { from, switchMap } from 'rxjs'
import { BackupTarget } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { TARGET, TARGET_CREATE } from '../modals/target.component'
import { BACKUP, BACKUP_OPTIONS } from '../modals/backup.component'
@Injectable({
providedIn: 'root',
})
export class BackupsCreateService {
private readonly loader = inject(LoadingService)
private readonly dialogs = inject(TuiDialogService)
private readonly api = inject(ApiService)
readonly handle = () => {
this.dialogs
.open<BackupTarget>(TARGET, TARGET_CREATE)
.pipe(
switchMap(({ id }) =>
this.dialogs
.open<string[]>(BACKUP, OPTIONS)
.pipe(switchMap(ids => from(this.createBackup(id, ids)))),
),
)
.subscribe()
}
private async createBackup(
targetId: string,
pkgIds: string[],
): Promise<void> {
const loader = this.loader.open('Beginning backup...').subscribe()
await this.api
.createBackup({ 'target-id': targetId, 'package-ids': pkgIds })
.finally(() => loader.unsubscribe())
}
}
const OPTIONS: Partial<TuiDialogOptions<{ btnText: string }>> = {
...BACKUP_OPTIONS,
data: { btnText: 'Create Backup' },
}

View File

@@ -0,0 +1,97 @@
import { inject, Injectable } from '@angular/core'
import { Router } from '@angular/router'
import * as argon2 from '@start9labs/argon2'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiDialogOptions, TuiDialogService } from '@taiga-ui/core'
import {
catchError,
EMPTY,
exhaustMap,
map,
Observable,
of,
switchMap,
take,
tap,
} from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { BackupTarget } from 'src/app/services/api/api.types'
import {
PROMPT,
PromptOptions,
} from 'src/app/apps/ui/modals/prompt/prompt.component'
import { TARGET, TARGET_RESTORE } from '../modals/target.component'
import { RECOVER } from '../modals/recover.component'
import { RecoverData } from '../types/recover-data'
@Injectable({
providedIn: 'root',
})
export class BackupsRestoreService {
private readonly errorService = inject(ErrorService)
private readonly dialogs = inject(TuiDialogService)
private readonly router = inject(Router)
private readonly api = inject(ApiService)
private readonly loader = inject(LoadingService)
readonly handle = () => {
this.dialogs
.open<BackupTarget>(TARGET, TARGET_RESTORE)
.pipe(
switchMap(target =>
this.dialogs.open<string>(PROMPT, PROMPT_OPTIONS).pipe(
exhaustMap(password =>
this.getRecoverData(
target.id,
password,
target['embassy-os']?.['password-hash'] || '',
),
),
take(1),
switchMap(data =>
this.dialogs.open(RECOVER, {
label: 'Select Services to Restore',
data,
}),
),
),
),
)
.subscribe(() => {
this.router.navigate(['/portal/desktop'])
})
}
private getRecoverData(
targetId: string,
password: string,
hash: string,
): Observable<RecoverData> {
return of(password).pipe(
tap(() => argon2.verify(hash, password)),
switchMap(() => {
const loader = this.loader.open('Decrypting drive...').subscribe()
return this.api
.getBackupInfo({ 'target-id': targetId, password })
.finally(() => loader.unsubscribe())
}),
catchError(e => {
this.errorService.handleError(e)
return EMPTY
}),
map(backupInfo => ({ targetId, password, backupInfo })),
)
}
}
const PROMPT_OPTIONS: Partial<TuiDialogOptions<PromptOptions>> = {
label: 'Password Required',
data: {
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,
},
}

View File

@@ -0,0 +1,19 @@
import {
unionSelectKey,
unionValueKey,
} from '@start9labs/start-sdk/lib/config/configTypes'
import { RR } from 'src/app/services/api/api.types'
export type BackupConfig =
| {
type: {
[unionSelectKey]: 'dropbox' | 'google-drive'
[unionValueKey]: RR.AddCloudBackupTargetReq
}
}
| {
type: {
[unionSelectKey]: 'cifs'
[unionValueKey]: RR.AddCifsBackupTargetReq
}
}

View File

@@ -0,0 +1 @@
export type BackupType = 'create' | 'restore'

View File

@@ -0,0 +1,6 @@
export interface DisplayInfo {
name: string
path: string
description: string
icon: string
}

View File

@@ -0,0 +1,7 @@
import { BackupInfo } from 'src/app/services/api/api.types'
export interface RecoverData {
targetId: string
backupInfo: BackupInfo
password: string
}

View File

@@ -0,0 +1,8 @@
import { PackageBackupInfo } from 'src/app/services/api/api.types'
export interface RecoverOption extends PackageBackupInfo {
id: string
checked: boolean
installed: boolean
'newer-eos': boolean
}

View File

@@ -0,0 +1,41 @@
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,11 +1,22 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { systemTabResolver } from '../../utils/system-tab-resolver'
import { toDesktopItem } from '../../utils/to-desktop-item'
const ROUTES: Routes = [
{
title: systemTabResolver,
path: 'backups',
loadComponent: () =>
import('./backups/backups.component').then(m => m.BackupsComponent),
data: toDesktopItem('/portal/system/backups'),
},
{
title: systemTabResolver,
path: 'snek',
loadComponent: () =>
import('./snek/snek.component').then(m => m.SnekComponent),
data: toDesktopItem('/portal/system/snek'),
},
]

View File

@@ -1,11 +1,6 @@
import { Injectable } from '@angular/core'
import { BehaviorSubject, Observable } from 'rxjs'
export interface NavigationItem {
readonly routerLink: string
readonly icon: string
readonly title: string
}
import { NavigationItem } from '../types/navigation-item'
@Injectable({
providedIn: 'root',

View File

@@ -0,0 +1,5 @@
export interface NavigationItem {
readonly routerLink: string
readonly icon: string
readonly title: string
}

View File

@@ -0,0 +1,10 @@
import { ActivatedRouteSnapshot } from '@angular/router'
import { inject } from '@angular/core'
import { NavigationService } from '../services/navigation.service'
import { NavigationItem } from '../types/navigation-item'
export function systemTabResolver({ data }: ActivatedRouteSnapshot): string {
inject(NavigationService).addTab(data as NavigationItem)
return data['title']
}

View File

@@ -0,0 +1,26 @@
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { SYSTEM_UTILITIES } from '../constants/system-utilities'
import { NavigationItem } from '../types/navigation-item'
import { toRouterLink } from './to-router-link'
export function toDesktopItem(
id: string,
packages: Record<string, PackageDataEntry> = {},
): NavigationItem {
const item = SYSTEM_UTILITIES[id]
const routerLink = toRouterLink(id)
if (SYSTEM_UTILITIES[id]) {
return {
icon: item.icon,
title: item.title,
routerLink,
}
}
return {
icon: packages[id]?.icon,
title: packages[id]?.manifest.title,
routerLink,
}
}

View File

@@ -16,7 +16,7 @@
[placeholder]="options.placeholder || ''"
/>
</tui-input>
<footer class="modal-buttons">
<footer class="g-buttons">
<button tuiButton type="button" appearance="secondary" (click)="cancel()">
Cancel
</button>

View File

@@ -26,5 +26,6 @@ const routes: Routes = [
RouterModule.forChild(routes),
],
declarations: [BackupHistoryPage, DurationPipe, HasErrorPipe],
exports: [DurationPipe, HasErrorPipe],
})
export class BackupHistoryPageModule {}

View File

@@ -37,5 +37,6 @@ const routes: Routes = [
TuiWrapperModule,
],
declarations: [BackupJobsPage, ToHumanCronPipe, EditJobComponent],
exports: [ToHumanCronPipe],
})
export class BackupJobsPageModule {}

View File

@@ -1,4 +1,5 @@
.button {
width: 100%;
height: var(--tui-height-l);
display: flex;
align-items: center;

View File

@@ -1,14 +0,0 @@
:host {
height: var(--tui-height-l);
display: flex;
align-items: center;
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);
}
tui-toggle {
margin-left: auto;
}

View File

@@ -5,6 +5,6 @@ import { Control } from '../control'
@Component({
selector: 'form-toggle',
templateUrl: './form-toggle.component.html',
styleUrls: ['./form-toggle.component.scss'],
host: { class: 'g-toggle' },
})
export class FormToggleComponent extends Control<ValueSpecToggle, boolean> {}

View File

@@ -75,6 +75,8 @@
--ion-background-color-rgb: var(--ion-color-medium-rgb);
--ion-text-color: var(--ion-color-dark);
--ion-text-color-rgb: var(--ion-color-dark-rgb);
--tui-skeleton-radius: 1rem;
}
$subheader-height: 48px;
@@ -367,13 +369,64 @@ ul {
margin: 0 12px 6px 0;
}
.g-page {
display: block;
height: 100%;
padding: 1px 2rem 3rem;
box-sizing: border-box;
overflow: auto;
// TODO: Theme
background: #373a3f;
}
.g-table {
width: 100%;
td,
th {
font: var(--tui-font-text-s);
text-align: left;
height: 2rem;
padding: 0 0.25rem;
box-shadow: inset 0 -1px var(--tui-clear);
}
th {
background: var(--tui-clear);
font-weight: bold;
}
.tui-skeleton {
max-height: 0.5rem;
}
}
.g-title {
display: flex;
align-items: center;
gap: 1rem;
text-transform: uppercase;
font-weight: bold;
font-size: 1rem;
margin: 2rem 0 1rem;
color: var(--tui-text-02);
}
.g-buttons {
display: flex;
justify-content: flex-end;
gap: 16px;
margin-top: 24px;
}
.g-action {
@include transition(background);
@include clearbtn();
display: flex;
align-items: center;
width: 100%;
width: stretch;
gap: 1rem;
text-align: left;
font-size: 0.85rem;
@@ -382,11 +435,15 @@ ul {
line-height: 1.25rem;
border-radius: 0.5rem;
color: var(--tui-text-01);
--tui-skeleton-radius: 1rem;
}
a.g-action,
button.g-action {
&:disabled {
pointer-events: none;
opacity: var(--tui-disabled-opacity);
}
&:hover {
background: var(--tui-clear);
}
@@ -394,12 +451,19 @@ button.g-action {
&:not(:last-child) {
box-shadow: 0 0.51rem 0 -0.5rem;
}
}
&_static {
cursor: default;
.g-toggle {
height: var(--tui-height-l);
display: flex;
align-items: center;
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);
&:hover {
background: transparent;
}
tui-toggle {
margin-left: auto;
}
}