mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 18:31:52 +00:00
refactor: refactor backups page to get rid of ionic (#2446)
This commit is contained in:
120
frontend/package-lock.json
generated
120
frontend/package-lock.json
generated
@@ -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"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
tui-svg.icon {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
|
||||
.title {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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]',
|
||||
|
||||
@@ -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',
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
]
|
||||
}
|
||||
@@ -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>()
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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' },
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}`,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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' },
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export type BackupType = 'create' | 'restore'
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface DisplayInfo {
|
||||
name: string
|
||||
path: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { BackupInfo } from 'src/app/services/api/api.types'
|
||||
|
||||
export interface RecoverData {
|
||||
targetId: string
|
||||
backupInfo: BackupInfo
|
||||
password: string
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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'],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'),
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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',
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface NavigationItem {
|
||||
readonly routerLink: string
|
||||
readonly icon: string
|
||||
readonly title: string
|
||||
}
|
||||
@@ -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']
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -26,5 +26,6 @@ const routes: Routes = [
|
||||
RouterModule.forChild(routes),
|
||||
],
|
||||
declarations: [BackupHistoryPage, DurationPipe, HasErrorPipe],
|
||||
exports: [DurationPipe, HasErrorPipe],
|
||||
})
|
||||
export class BackupHistoryPageModule {}
|
||||
|
||||
@@ -37,5 +37,6 @@ const routes: Routes = [
|
||||
TuiWrapperModule,
|
||||
],
|
||||
declarations: [BackupJobsPage, ToHumanCronPipe, EditJobComponent],
|
||||
exports: [ToHumanCronPipe],
|
||||
})
|
||||
export class BackupJobsPageModule {}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
.button {
|
||||
width: 100%;
|
||||
height: var(--tui-height-l);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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> {}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user