diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 90c1ddf7f..176c9c6f3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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" } }, diff --git a/frontend/package.json b/frontend/package.json index 592463a34..4e17618ee 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/projects/ui/src/app/apps/portal/components/card/card.component.html b/frontend/projects/ui/src/app/apps/portal/components/card/card.component.html index db497170b..db41e3af8 100644 --- a/frontend/projects/ui/src/app/apps/portal/components/card/card.component.html +++ b/frontend/projects/ui/src/app/apps/portal/components/card/card.component.html @@ -1,5 +1,12 @@ - + + + + {{ title }} diff --git a/frontend/projects/ui/src/app/apps/portal/components/card/card.component.scss b/frontend/projects/ui/src/app/apps/portal/components/card/card.component.scss index 0af6ca6c3..32d5b1271 100644 --- a/frontend/projects/ui/src/app/apps/portal/components/card/card.component.scss +++ b/frontend/projects/ui/src/app/apps/portal/components/card/card.component.scss @@ -29,6 +29,10 @@ border-radius: 100%; } +tui-svg.icon { + transform: scale(1.5); +} + .title { max-width: 100%; } diff --git a/frontend/projects/ui/src/app/apps/portal/components/card/card.component.ts b/frontend/projects/ui/src/app/apps/portal/components/card/card.component.ts index 2c3b87760..2f1c2c258 100644 --- a/frontend/projects/ui/src/app/apps/portal/components/card/card.component.ts +++ b/frontend/projects/ui/src/app/apps/portal/components/card/card.component.ts @@ -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' diff --git a/frontend/projects/ui/src/app/apps/portal/components/drawer/drawer.component.ts b/frontend/projects/ui/src/app/apps/portal/components/drawer/drawer.component.ts index d516a0887..f22dccb1f 100644 --- a/frontend/projects/ui/src/app/apps/portal/components/drawer/drawer.component.ts +++ b/frontend/projects/ui/src/app/apps/portal/components/drawer/drawer.component.ts @@ -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', diff --git a/frontend/projects/ui/src/app/apps/portal/components/navigation/navigation.component.html b/frontend/projects/ui/src/app/apps/portal/components/navigation/navigation.component.html index d57dc2cfe..ba901e01c 100644 --- a/frontend/projects/ui/src/app/apps/portal/components/navigation/navigation.component.html +++ b/frontend/projects/ui/src/app/apps/portal/components/navigation/navigation.component.html @@ -4,7 +4,7 @@ routerLinkActive="tab_active" [routerLinkActiveOptions]="{ exact: true }" > - + - + + + + = { + '/portal/system/backups': { + icon: 'tuiIconSaveLarge', + title: 'Backups', + }, '/portal/system/devices': { icon: 'assets/img/icon_transparent.png', title: 'Devices', diff --git a/frontend/projects/ui/src/app/apps/portal/pipes/to-desktop-item.ts b/frontend/projects/ui/src/app/apps/portal/pipes/to-desktop-item.ts index 07fef2336..f3204b58a 100644 --- a/frontend/projects/ui/src/app/apps/portal/pipes/to-desktop-item.ts +++ b/frontend/projects/ui/src/app/apps/portal/pipes/to-desktop-item.ts @@ -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, 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 } } diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/modals/actions.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/modals/actions.component.ts index 686eb4a99..cae649863 100644 --- a/frontend/projects/ui/src/app/apps/portal/routes/service/modals/actions.component.ts +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/modals/actions.component.ts @@ -33,7 +33,7 @@ import { GroupActionsPipe } from '../pipes/group-actions.pipe' template: ` - Standard Actions + Standard Actions `, - 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], diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/service.component.html b/frontend/projects/ui/src/app/apps/portal/routes/service/service.component.html index d58506443..06a263287 100644 --- a/frontend/projects/ui/src/app/apps/portal/routes/service/service.component.html +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/service.component.html @@ -11,7 +11,7 @@ - Status + Status - Interfaces + Interfaces - Health Checks + Health Checks - Dependencies + Dependencies - Menu + Menu - Additional Info + Additional Info diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/service.component.scss b/frontend/projects/ui/src/app/apps/portal/routes/service/service.component.scss index 3f186a990..913a5907f 100644 --- a/frontend/projects/ui/src/app/apps/portal/routes/service/service.component.scss +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/service.component.scss @@ -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; + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/service/service.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/service/service.component.ts index 7e08c9687..d7f44ddf0 100644 --- a/frontend/projects/ui/src/app/apps/portal/routes/service/service.component.ts +++ b/frontend/projects/ui/src/app/apps/portal/routes/service/service.component.ts @@ -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 { diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/backups.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/backups.component.ts new file mode 100644 index 000000000..871ed87b8 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/backups.component.ts @@ -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: ` + + Options + + + + {{ option.name }} + {{ option.description }} + + + + + Upcoming Jobs + + + `, + 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(), + }, + ] +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/components/physical.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/components/physical.component.ts new file mode 100644 index 000000000..9b041819c --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/components/physical.component.ts @@ -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: ` + + + Make/Model + Label + Capacity + Used + + + + + + + {{ disk.vendor || 'unknown make' }}, + {{ disk.model || 'unknown model' }} + + {{ disk.label }} + {{ disk.capacity | convertBytes }} + {{ disk.used ? (disk.used | convertBytes) : 'Unknown' }} + + + Save + + + + + + + Loading + + + + + + + To add a new physical backup target, connect the drive and click + refresh. + + + + + `, + 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() +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/components/status.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/components/status.component.ts new file mode 100644 index 000000000..a021438f3 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/components/status.component.ts @@ -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: ` + + {{ 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 + ) + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/components/targets.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/components/targets.component.ts new file mode 100644 index 000000000..0b955a35c --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/components/targets.component.ts @@ -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: ` + + + Name + Type + Available + Path + + + + + + {{ target.name }} + + + {{ target.type | titlecase }} + + + + + {{ target.path }} + + + Update + + + Delete + + + + + + + Loading + + + + + No saved backup targets. + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [ + CommonModule, + TuiForModule, + TuiSvgModule, + TuiButtonModule, + GetBackupIconPipe, + ], +}) +export class BackupsTargetsComponent { + private readonly dialogs = inject(TuiDialogService) + + readonly delete$ = new Subject() + readonly update$ = new Subject() + + @Input() + backupsTargets: readonly BackupTarget[] | null = null + + @Output() + readonly update = new EventEmitter() + + @Output() + readonly delete = this.delete$.pipe( + switchMap(id => + this.dialogs.open(TUI_PROMPT, OPTIONS).pipe( + filter(Boolean), + map(() => id), + ), + ), + ) +} + +const OPTIONS: Partial> = { + label: 'Confirm', + size: 's', + data: { + content: 'Forget backup target? This actions cannot be undone.', + no: 'Cancel', + yes: 'Delete', + }, +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/components/upcoming.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/components/upcoming.component.ts new file mode 100644 index 000000000..6473356c7 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/components/upcoming.component.ts @@ -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: ` + + + Scheduled + Job + Target + Packages + + + + + + + Running + + + {{ job.next | date : 'MMM d, y, h:mm a' }} + + + {{ job.name }} + + + {{ job.target.name }} + + Packages: {{ job['package-ids'].length }} + + + You have no active or upcoming backup jobs + + + + Loading + + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [CommonModule, TuiForModule, TuiSvgModule, GetBackupIconPipe], +}) +export class BackupsUpcomingComponent { + readonly current$: Observable = inject>(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), + ), + ) +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/modals/backup.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/modals/backup.component.ts new file mode 100644 index 000000000..c45a0ad23 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/modals/backup.component.ts @@ -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: ` + + + + + {{ pkg.title }} + + + + No services installed! + + + `, + 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) + readonly context = + inject>( + 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> = { + label: 'Select Services to Back Up', +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/modals/edit.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/modals/edit.component.ts new file mode 100644 index 000000000..de21a5d04 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/modals/edit.component.ts @@ -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: ` + + + Job Name + + + + Target + + {{ job.target.type || 'Select target' }} + + + + Packages + + {{ job['package-ids'].length + ' selected' }} + + + + Schedule + + + + {{ human.message }} + + + Also Execute Now + + + + Save Job + + + `, + 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>(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(TARGET, TARGET_CREATE).subscribe(target => { + this.job.target = target + }) + } + + selectPackages() { + this.dialogs.open(BACKUP, BACKUP_OPTIONS).subscribe(id => { + this.job['package-ids'] = id + }) + } +} + +export const EDIT = new PolymorpheusComponent(BackupsEditModal) diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/modals/history.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/modals/history.component.ts new file mode 100644 index 000000000..ea5330fe3 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/modals/history.component.ts @@ -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: ` + + + Past Events + + Delete Selected + + + + + + + + + Started At + Duration + Result + Job + Target + + + + + + {{ run['started-at'] | date : 'medium' }} + + {{ run['started-at'] | duration : run['completed-at'] }} Minutes + + + + + + + Report + + {{ run.job.name || 'No job' }} + + + {{ run.job.target.name }} + + + + + Loading + + + + No backups have been run yet. + + + + `, + 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) diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/modals/jobs.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/modals/jobs.component.ts new file mode 100644 index 000000000..132afaf71 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/modals/jobs.component.ts @@ -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: ` + + 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. + + View instructions + + + + Saved Jobs + + Create New Job + + + + + + Name + Target + Packages + Schedule + + + + + + {{ job.name }} + + + {{ job.target.name }} + + Packages: {{ job['package-ids'].length }} + {{ (job.cron | toHumanCron).message }} + + + + + + + + Loading + + + + No jobs found. + + + + `, + 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(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(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> = { + label: 'Confirm', + size: 's', + data: { + content: 'Delete backup job? This action cannot be undone.', + yes: 'Delete', + no: 'Cancel', + }, +} + +export const JOBS = new PolymorpheusComponent(BackupsJobsModal) diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/modals/recover.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/modals/recover.component.ts new file mode 100644 index 000000000..576c3beda --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/modals/recover.component.ts @@ -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: ` + + + + + {{ option.title }} + Version {{ option.version }} + Backup made: {{ option.timestamp | date : 'medium' }} + + {{ message.text }} + + + + + + + + `, + 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>(POLYMORPHEUS_CONTEXT) + + readonly packageData$ = inject>(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 { + return this.context.data.backupInfo['package-backups'] + } + + isDisabled(options: RecoverOption[]): boolean { + return options.every(o => !o.checked) + } + + async restore(options: RecoverOption[]): Promise { + 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) diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/modals/report.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/modals/report.component.ts new file mode 100644 index 000000000..b7a980a7a --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/modals/report.component.ts @@ -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: ` + Completed: {{ timestamp | date : 'medium' }} + + + System data + {{ system.result }} + + + + + + {{ pkg.key }} + + {{ pkg.value.error ? 'Failed: ' + pkg.value.error : 'Succeeded' }} + + + + + `, + changeDetection: ChangeDetectionStrategy.OnPush, + standalone: true, + imports: [CommonModule, TuiSvgModule], +}) +export class BackupsReportModal { + private readonly context = + inject>( + 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) diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/modals/target.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/modals/target.component.ts new file mode 100644 index 000000000..18b23060a --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/modals/target.component.ts @@ -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: ` + + + Saved Targets + + + + + {{ displayInfo.name }} + + + {{ displayInfo.description }} + + {{ displayInfo.path }} + + + + + + No saved targets + Go to Targets + + + `, + 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>( + 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> = { + label: 'Select Backup Target', + data: { type: 'create' }, +} + +export const TARGET_RESTORE: Partial> = { + label: 'Select Backup Source', + data: { type: 'restore' }, +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/modals/targets.component.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/modals/targets.component.ts new file mode 100644 index 000000000..9231c4183 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/modals/targets.component.ts @@ -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: ` + + + 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. + + View instructions + + + + Unknown Physical Drives + + Refresh + + + + + Saved Targets + + Add Target + + + + `, + 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) => + 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 { + 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 { + 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) diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/pipes/duration.pipe.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/pipes/duration.pipe.ts new file mode 100644 index 000000000..76c4fd82f --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/pipes/duration.pipe.ts @@ -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 + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/pipes/get-backup-icon.pipe.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/pipes/get-backup-icon.pipe.ts new file mode 100644 index 000000000..4c84a9d73 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/pipes/get-backup-icon.pipe.ts @@ -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' + } + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/pipes/get-display-info.pipe.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/pipes/get-display-info.pipe.ts new file mode 100644 index 000000000..88f29d242 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/pipes/get-display-info.pipe.ts @@ -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' + }`, + } + } + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/pipes/has-error.pipe.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/pipes/has-error.pipe.ts new file mode 100644 index 000000000..898c8fb41 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/pipes/has-error.pipe.ts @@ -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) + ) + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/pipes/to-human-cron.pipe.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/pipes/to-human-cron.pipe.ts new file mode 100644 index 000000000..50653058e --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/pipes/to-human-cron.pipe.ts @@ -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 + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/pipes/to-options.pipe.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/pipes/to-options.pipe.ts new file mode 100644 index 000000000..9f61c238a --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/pipes/to-options.pipe.ts @@ -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>, + packageBackups: Record = {}, + ): Observable { + 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 + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/services/create.service.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/services/create.service.ts new file mode 100644 index 000000000..5a0c86917 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/services/create.service.ts @@ -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(TARGET, TARGET_CREATE) + .pipe( + switchMap(({ id }) => + this.dialogs + .open(BACKUP, OPTIONS) + .pipe(switchMap(ids => from(this.createBackup(id, ids)))), + ), + ) + .subscribe() + } + + private async createBackup( + targetId: string, + pkgIds: string[], + ): Promise { + const loader = this.loader.open('Beginning backup...').subscribe() + + await this.api + .createBackup({ 'target-id': targetId, 'package-ids': pkgIds }) + .finally(() => loader.unsubscribe()) + } +} + +const OPTIONS: Partial> = { + ...BACKUP_OPTIONS, + data: { btnText: 'Create Backup' }, +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/services/restore.service.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/services/restore.service.ts new file mode 100644 index 000000000..b1f6b3faf --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/services/restore.service.ts @@ -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(TARGET, TARGET_RESTORE) + .pipe( + switchMap(target => + this.dialogs.open(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 { + 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> = { + 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, + }, +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/types/backup-config.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/types/backup-config.ts new file mode 100644 index 000000000..e6b485163 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/types/backup-config.ts @@ -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 + } + } diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/types/backup-type.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/types/backup-type.ts new file mode 100644 index 000000000..0befec6a2 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/types/backup-type.ts @@ -0,0 +1 @@ +export type BackupType = 'create' | 'restore' diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/types/display-info.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/types/display-info.ts new file mode 100644 index 000000000..767b17116 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/types/display-info.ts @@ -0,0 +1,6 @@ +export interface DisplayInfo { + name: string + path: string + description: string + icon: string +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/types/recover-data.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/types/recover-data.ts new file mode 100644 index 000000000..7823451ac --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/types/recover-data.ts @@ -0,0 +1,7 @@ +import { BackupInfo } from 'src/app/services/api/api.types' + +export interface RecoverData { + targetId: string + backupInfo: BackupInfo + password: string +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/types/recover-option.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/types/recover-option.ts new file mode 100644 index 000000000..e73e9e4c9 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/types/recover-option.ts @@ -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 +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/backups/utils/job-builder.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/utils/job-builder.ts new file mode 100644 index 000000000..b84e4d369 --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/backups/utils/job-builder.ts @@ -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) { + 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'], + } + } +} diff --git a/frontend/projects/ui/src/app/apps/portal/routes/system/system.module.ts b/frontend/projects/ui/src/app/apps/portal/routes/system/system.module.ts index 6732156a7..d7fb704aa 100644 --- a/frontend/projects/ui/src/app/apps/portal/routes/system/system.module.ts +++ b/frontend/projects/ui/src/app/apps/portal/routes/system/system.module.ts @@ -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'), }, ] diff --git a/frontend/projects/ui/src/app/apps/portal/components/navigation/navigation.service.ts b/frontend/projects/ui/src/app/apps/portal/services/navigation.service.ts similarity index 83% rename from frontend/projects/ui/src/app/apps/portal/components/navigation/navigation.service.ts rename to frontend/projects/ui/src/app/apps/portal/services/navigation.service.ts index 232634697..8ff3f429c 100644 --- a/frontend/projects/ui/src/app/apps/portal/components/navigation/navigation.service.ts +++ b/frontend/projects/ui/src/app/apps/portal/services/navigation.service.ts @@ -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', diff --git a/frontend/projects/ui/src/app/apps/portal/types/navigation-item.ts b/frontend/projects/ui/src/app/apps/portal/types/navigation-item.ts new file mode 100644 index 000000000..ea912247c --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/types/navigation-item.ts @@ -0,0 +1,5 @@ +export interface NavigationItem { + readonly routerLink: string + readonly icon: string + readonly title: string +} diff --git a/frontend/projects/ui/src/app/apps/portal/utils/system-tab-resolver.ts b/frontend/projects/ui/src/app/apps/portal/utils/system-tab-resolver.ts new file mode 100644 index 000000000..02d355d3c --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/utils/system-tab-resolver.ts @@ -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'] +} diff --git a/frontend/projects/ui/src/app/apps/portal/utils/to-desktop-item.ts b/frontend/projects/ui/src/app/apps/portal/utils/to-desktop-item.ts new file mode 100644 index 000000000..cbeed8dfe --- /dev/null +++ b/frontend/projects/ui/src/app/apps/portal/utils/to-desktop-item.ts @@ -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 = {}, +): 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, + } +} diff --git a/frontend/projects/ui/src/app/apps/ui/modals/prompt/prompt.component.html b/frontend/projects/ui/src/app/apps/ui/modals/prompt/prompt.component.html index 788210973..b407d3dfa 100644 --- a/frontend/projects/ui/src/app/apps/ui/modals/prompt/prompt.component.html +++ b/frontend/projects/ui/src/app/apps/ui/modals/prompt/prompt.component.html @@ -16,7 +16,7 @@ [placeholder]="options.placeholder || ''" /> -
No saved targets