From e53c90f8f0a8836aea73ae6fb9bffb615ce78150 Mon Sep 17 00:00:00 2001 From: Matt Hill Date: Tue, 9 May 2023 07:49:20 -0600 Subject: [PATCH] Feat/automated backups (#2142) * initial restructuring * very cool * new structure in place * delete unnecessary T * down the rabbit hole * getting better * dont like it * nice * very nice * sessions select all * nice * backup runs * fix targets and more * small improvements * mostly working * address PR comments * fix error * delete issue with merge * fix checkboxes and add API for deleting backup runs * better styling for checkboxes * small button in ssh kpage too * complete multiple UI launcher * fix actions * present error toast too * fix target forms --- frontend/package-lock.json | 43 +++ frontend/package.json | 3 + .../modals/cifs-modal/cifs-modal.page.scss | 4 - .../projects/setup-wizard/src/styles.scss | 6 - .../unit-conversion/unit-conversion.pipe.ts | 16 +- frontend/projects/shared/styles/shared.scss | 11 +- .../projects/ui/src/app/app-routing.module.ts | 9 + frontend/projects/ui/src/app/app.module.ts | 2 + .../ui/src/app/app/menu/menu.component.ts | 5 + .../app/app/preloader/preloader.component.ts | 3 + .../ui/src/app/app/snek/snek.directive.ts | 1 - .../backup-drives-header.component.html | 16 - .../backup-drives-status.component.html | 20 -- .../backup-drives.component.html | 172 --------- .../backup-drives.component.module.ts | 34 -- .../backup-drives.component.scss | 18 - .../backup-drives/backup-drives.component.ts | 335 ------------------ .../backup-drives/backup.service.ts | 62 ---- .../insecure-warning.component.html | 15 + .../insecure-warning.component.scss | 8 + .../insecure-warning.component.ts | 18 + .../insecure-warning.module.ts | 11 + .../launch-menu/launch-menu.component.html | 28 ++ .../launch-menu/launch-menu.component.scss | 3 + .../launch-menu/launch-menu.component.ts | 37 ++ .../launch-menu/launch-menu.module.ts | 12 + .../modals/generic-form/generic-form.page.ts | 11 +- .../generic-input/generic-input.component.ts | 4 +- .../app-actions/app-actions.module.ts | 2 - .../app-actions/app-actions.page.ts | 11 +- .../app-list-icon.component.html | 1 - .../app-list-pkg/app-list-pkg.component.html | 29 +- .../app-list-pkg/app-list-pkg.component.scss | 3 - .../app-list-pkg/app-list-pkg.component.ts | 9 +- .../apps-routes/app-list/app-list.module.ts | 2 + .../apps-routes/app-show/app-show.module.ts | 4 + .../apps-routes/app-show/app-show.page.html | 29 +- .../apps-routes/app-show/app-show.page.ts | 10 +- .../app-show-status.component.html | 8 +- .../app-show-status.component.ts | 25 +- .../backups-routes/backups-routing.module.ts | 37 ++ .../backing-up/backing-up.component.html | 5 +- .../backing-up/backing-up.component.ts | 3 +- .../directives/backup-create.directive.ts | 77 ++++ .../directives/backup-restore.directive.ts} | 81 +++-- .../backup-select/backup-select.module.ts | 0 .../backup-select/backup-select.page.html | 4 +- .../backup-select/backup-select.page.scss | 0 .../backup-select/backup-select.page.ts | 21 +- .../recover-select/recover-select.module.ts} | 9 +- .../recover-select/recover-select.page.html} | 0 .../recover-select/recover-select.page.scss} | 0 .../recover-select/recover-select.page.ts} | 13 +- .../modals/recover-select}/to-options.pipe.ts | 0 .../target-select/target-select.module.ts | 18 + .../target-select/target-select.page.html | 55 +++ .../target-select/target-select.page.scss} | 0 .../target-select/target-select.page.ts | 72 ++++ .../target-status.component.html | 30 ++ .../backup-history/backup-history.module.ts | 28 ++ .../backup-history/backup-history.page.html | 93 +++++ .../backup-history/backup-history.page.scss | 3 + .../backup-history/backup-history.page.ts | 111 ++++++ .../pages/backup-jobs/backup-jobs.module.ts | 38 ++ .../pages/backup-jobs/backup-jobs.page.html | 92 +++++ .../pages/backup-jobs/backup-jobs.page.scss} | 0 .../pages/backup-jobs/backup-jobs.page.ts | 121 +++++++ .../backup-jobs/edit-job/edit-job.page.html | 33 ++ .../backup-jobs/edit-job/edit-job.page.scss | 3 + .../backup-jobs/edit-job/edit-job.page.ts | 54 +++ .../job-options/job-options.component.html | 34 ++ .../job-options/job-options.component.scss | 9 + .../job-options/job-options.component.ts | 91 +++++ .../backup-jobs/new-job/new-job.page.html | 40 +++ .../backup-jobs/new-job/new-job.page.scss | 3 + .../pages/backup-jobs/new-job/new-job.page.ts | 54 +++ .../backups-routes/pages/backup-jobs/pipes.ts | 34 ++ .../backup-targets/backup-targets.module.ts | 26 ++ .../backup-targets/backup-targets.page.html | 166 +++++++++ .../backup-targets/backup-targets.page.scss | 0 .../backup-targets/backup-targets.page.ts | 250 +++++++++++++ .../pages/backups/backups.module.ts | 44 +++ .../pages/backups/backups.page.html | 112 ++++++ .../pages/backups/backups.page.scss | 0 .../pages/backups/backups.page.ts | 42 +++ .../pipes/get-display-info.pipe.ts | 42 +++ .../pipes/has-valid-backup.pipe.ts | 15 + .../pipes/target-pipes.module.ts | 11 + .../backups-routes/types/target-types.ts | 221 ++++++++++++ .../developer-menu/developer-menu.module.ts | 2 - .../pages/notifications/notifications.page.ts | 1 - .../restore/restore.component.html | 5 - .../restore/restore.component.module.ts | 28 -- .../server-backup/server-backup.module.ts | 30 -- .../server-backup/server-backup.page.html | 14 - .../server-backup/server-backup.page.ts | 173 --------- .../server-routes/server-routing.module.ts | 14 - .../server-show/server-show.module.ts | 2 + .../server-show/server-show.page.html | 36 +- .../server-show/server-show.page.ts | 31 +- .../server-routes/sessions/sessions.module.ts | 4 +- .../server-routes/sessions/sessions.page.html | 202 ++++++----- .../server-routes/sessions/sessions.page.scss | 5 +- .../server-routes/sessions/sessions.page.ts | 126 ++++--- .../server-routes/ssh-keys/ssh-keys.page.html | 112 +++--- .../server-routes/ssh-keys/ssh-keys.page.ts | 20 +- .../ui/src/app/services/api/api.fixures.ts | 180 +++++++--- .../ui/src/app/services/api/api.types.ts | 136 +++++-- .../app/services/api/embassy-api.service.ts | 38 +- .../services/api/embassy-live-api.service.ts | 50 ++- .../services/api/embassy-mock-api.service.ts | 90 +++-- .../ui/src/app/services/api/mock-patch.ts | 5 +- .../ui/src/app/services/eos.service.ts | 2 +- .../src/app/services/patch-db/data-model.ts | 11 +- .../ui/src/app/types/mapped-backup-target.ts | 5 - frontend/projects/ui/src/styles.scss | 45 ++- 116 files changed, 3072 insertions(+), 1530 deletions(-) delete mode 100644 frontend/projects/ui/src/app/components/backup-drives/backup-drives-header.component.html delete mode 100644 frontend/projects/ui/src/app/components/backup-drives/backup-drives-status.component.html delete mode 100644 frontend/projects/ui/src/app/components/backup-drives/backup-drives.component.html delete mode 100644 frontend/projects/ui/src/app/components/backup-drives/backup-drives.component.module.ts delete mode 100644 frontend/projects/ui/src/app/components/backup-drives/backup-drives.component.scss delete mode 100644 frontend/projects/ui/src/app/components/backup-drives/backup-drives.component.ts delete mode 100644 frontend/projects/ui/src/app/components/backup-drives/backup.service.ts create mode 100644 frontend/projects/ui/src/app/components/insecure-warning/insecure-warning.component.html create mode 100644 frontend/projects/ui/src/app/components/insecure-warning/insecure-warning.component.scss create mode 100644 frontend/projects/ui/src/app/components/insecure-warning/insecure-warning.component.ts create mode 100644 frontend/projects/ui/src/app/components/insecure-warning/insecure-warning.module.ts create mode 100644 frontend/projects/ui/src/app/components/launch-menu/launch-menu.component.html create mode 100644 frontend/projects/ui/src/app/components/launch-menu/launch-menu.component.scss create mode 100644 frontend/projects/ui/src/app/components/launch-menu/launch-menu.component.ts create mode 100644 frontend/projects/ui/src/app/components/launch-menu/launch-menu.module.ts create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/backups-routing.module.ts rename frontend/projects/ui/src/app/pages/{server-routes/server-backup => backups-routes/components}/backing-up/backing-up.component.html (93%) rename frontend/projects/ui/src/app/pages/{server-routes/server-backup => backups-routes/components}/backing-up/backing-up.component.ts (96%) create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/directives/backup-create.directive.ts rename frontend/projects/ui/src/app/pages/{server-routes/restore/restore.component.ts => backups-routes/directives/backup-restore.directive.ts} (55%) rename frontend/projects/ui/src/app/{ => pages/backups-routes}/modals/backup-select/backup-select.module.ts (100%) rename frontend/projects/ui/src/app/{ => pages/backups-routes}/modals/backup-select/backup-select.page.html (96%) rename frontend/projects/ui/src/app/{ => pages/backups-routes}/modals/backup-select/backup-select.page.scss (100%) rename frontend/projects/ui/src/app/{ => pages/backups-routes}/modals/backup-select/backup-select.page.ts (81%) rename frontend/projects/ui/src/app/{modals/app-recover-select/app-recover-select.module.ts => pages/backups-routes/modals/recover-select/recover-select.module.ts} (59%) rename frontend/projects/ui/src/app/{modals/app-recover-select/app-recover-select.page.html => pages/backups-routes/modals/recover-select/recover-select.page.html} (100%) rename frontend/projects/ui/src/app/{modals/app-recover-select/app-recover-select.page.scss => pages/backups-routes/modals/recover-select/recover-select.page.scss} (100%) rename frontend/projects/ui/src/app/{modals/app-recover-select/app-recover-select.page.ts => pages/backups-routes/modals/recover-select/recover-select.page.ts} (86%) rename frontend/projects/ui/src/app/{modals/app-recover-select => pages/backups-routes/modals/recover-select}/to-options.pipe.ts (100%) create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/modals/target-select/target-select.module.ts create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/modals/target-select/target-select.page.html rename frontend/projects/ui/src/app/pages/{server-routes/restore/restore.component.scss => backups-routes/modals/target-select/target-select.page.scss} (100%) create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/modals/target-select/target-select.page.ts create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/modals/target-select/target-status.component.html create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pages/backup-history/backup-history.module.ts create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pages/backup-history/backup-history.page.html create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pages/backup-history/backup-history.page.scss create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pages/backup-history/backup-history.page.ts create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/backup-jobs.module.ts create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/backup-jobs.page.html rename frontend/projects/ui/src/app/pages/{server-routes/server-backup/server-backup.page.scss => backups-routes/pages/backup-jobs/backup-jobs.page.scss} (100%) create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/backup-jobs.page.ts create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/edit-job/edit-job.page.html create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/edit-job/edit-job.page.scss create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/edit-job/edit-job.page.ts create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/job-options/job-options.component.html create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/job-options/job-options.component.scss create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/job-options/job-options.component.ts create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/new-job/new-job.page.html create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/new-job/new-job.page.scss create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/new-job/new-job.page.ts create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/pipes.ts create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pages/backup-targets/backup-targets.module.ts create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pages/backup-targets/backup-targets.page.html create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pages/backup-targets/backup-targets.page.scss create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pages/backup-targets/backup-targets.page.ts create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pages/backups/backups.module.ts create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pages/backups/backups.page.html create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pages/backups/backups.page.scss create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pages/backups/backups.page.ts create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pipes/get-display-info.pipe.ts create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pipes/has-valid-backup.pipe.ts create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/pipes/target-pipes.module.ts create mode 100644 frontend/projects/ui/src/app/pages/backups-routes/types/target-types.ts delete mode 100644 frontend/projects/ui/src/app/pages/server-routes/restore/restore.component.html delete mode 100644 frontend/projects/ui/src/app/pages/server-routes/restore/restore.component.module.ts delete mode 100644 frontend/projects/ui/src/app/pages/server-routes/server-backup/server-backup.module.ts delete mode 100644 frontend/projects/ui/src/app/pages/server-routes/server-backup/server-backup.page.html delete mode 100644 frontend/projects/ui/src/app/pages/server-routes/server-backup/server-backup.page.ts delete mode 100644 frontend/projects/ui/src/app/types/mapped-backup-target.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4817c8c88..f4bba2011 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -38,6 +38,8 @@ "cbor": "npm:@jprochazk/cbor@^0.4.9", "cbor-web": "^8.1.0", "core-js": "^3.21.1", + "cron": "^2.2.0", + "cronstrue": "^2.21.0", "dompurify": "^2.3.6", "fast-json-patch": "^3.1.1", "fuse.js": "^6.4.6", @@ -64,6 +66,7 @@ "@angular/compiler-cli": "^14.1.0", "@angular/language-service": "^14.1.0", "@ionic/cli": "^6.19.0", + "@types/cron": "^2.0.0", "@types/dompurify": "^2.3.3", "@types/estree": "^0.0.51", "@types/js-yaml": "^4.0.5", @@ -4077,6 +4080,16 @@ "@types/node": "*" } }, + "node_modules/@types/cron": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/cron/-/cron-2.0.0.tgz", + "integrity": "sha512-xZM08fqvwIXgghtPVkSPKNgC+JoMQ2OHazEvyTKnNf7aWu1aB6/4lBbQFrb03Td2cUGG7ITzMv3mFYnMu6xRaQ==", + "dev": true, + "dependencies": { + "@types/luxon": "*", + "@types/node": "*" + } + }, "node_modules/@types/dompurify": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-2.4.0.tgz", @@ -4165,6 +4178,12 @@ "integrity": "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==", "dev": true }, + "node_modules/@types/luxon": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.2.0.tgz", + "integrity": "sha512-lGmaGFoaXHuOLXFvuju2bfvZRqxAqkHPx9Y9IQdQABrinJJshJwfNCKV+u7rR3kJbiqfTF/NhOkcxxAFrObyaA==", + "dev": true + }, "node_modules/@types/marked": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/@types/marked/-/marked-4.0.8.tgz", @@ -6042,6 +6061,22 @@ "node": ">=8" } }, + "node_modules/cron": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cron/-/cron-2.2.0.tgz", + "integrity": "sha512-GPiI3OgMv83XRtEUc2gUdaLvJhO3XbLN288layOBkDTupg0RK5IECNGpkykIMHg+muVR2bxt29b0xvCAcBrjYQ==", + "dependencies": { + "luxon": "^3.2.1" + } + }, + "node_modules/cronstrue": { + "version": "2.21.0", + "resolved": "https://registry.npmjs.org/cronstrue/-/cronstrue-2.21.0.tgz", + "integrity": "sha512-YxabE1ZSHA1zJZMPCTSEbc0u4cRRenjqqTgCwJT7OvkspPSvfYFITuPFtsT+VkBuavJtFv2kJXT+mKSnlUJxfg==", + "bin": { + "cronstrue": "bin/cli.js" + } + }, "node_modules/cross-spawn": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", @@ -9683,6 +9718,14 @@ "yallist": "^3.0.2" } }, + "node_modules/luxon": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.2.1.tgz", + "integrity": "sha512-QrwPArQCNLAKGO/C+ZIilgIuDnEnKx5QYODdDtbFaxzsbZcc/a7WFq7MhsVYgRlwawLtvOUESTlfJ+hc/USqPg==", + "engines": { + "node": ">=12" + } + }, "node_modules/macos-release": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/macos-release/-/macos-release-2.5.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 14bad0717..79fbac137 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -63,6 +63,8 @@ "cbor": "npm:@jprochazk/cbor@^0.4.9", "cbor-web": "^8.1.0", "core-js": "^3.21.1", + "cron": "^2.2.0", + "cronstrue": "^2.21.0", "dompurify": "^2.3.6", "fast-json-patch": "^3.1.1", "fuse.js": "^6.4.6", @@ -89,6 +91,7 @@ "@angular/compiler-cli": "^14.1.0", "@angular/language-service": "^14.1.0", "@ionic/cli": "^6.19.0", + "@types/cron": "^2.0.0", "@types/dompurify": "^2.3.3", "@types/estree": "^0.0.51", "@types/js-yaml": "^4.0.5", diff --git a/frontend/projects/setup-wizard/src/app/modals/cifs-modal/cifs-modal.page.scss b/frontend/projects/setup-wizard/src/app/modals/cifs-modal/cifs-modal.page.scss index 3d76c6538..db8acb8f7 100644 --- a/frontend/projects/setup-wizard/src/app/modals/cifs-modal/cifs-modal.page.scss +++ b/frontend/projects/setup-wizard/src/app/modals/cifs-modal/cifs-modal.page.scss @@ -13,8 +13,4 @@ ion-item { .item-has-focus { --background: var(--ion-color-dark-tint) !important; -} - -ion-modal { - --backdrop-opacity: 0.7; } \ No newline at end of file diff --git a/frontend/projects/setup-wizard/src/styles.scss b/frontend/projects/setup-wizard/src/styles.scss index e7313cced..df201b214 100644 --- a/frontend/projects/setup-wizard/src/styles.scss +++ b/frontend/projects/setup-wizard/src/styles.scss @@ -249,12 +249,6 @@ ion-toast { border-radius: 4px; } - -ion-modal.stack-modal { - --box-shadow: 0 12px 24px rgba(0, 0, 0, 0.2); - --backdrop-opacity: var(--ion-backdrop-opacity, 0.32); -} - .sc-ion-label-md-s p { line-height: 23px; } diff --git a/frontend/projects/shared/src/pipes/unit-conversion/unit-conversion.pipe.ts b/frontend/projects/shared/src/pipes/unit-conversion/unit-conversion.pipe.ts index 266e7fe9a..289ce835f 100644 --- a/frontend/projects/shared/src/pipes/unit-conversion/unit-conversion.pipe.ts +++ b/frontend/projects/shared/src/pipes/unit-conversion/unit-conversion.pipe.ts @@ -6,12 +6,7 @@ import { Pipe, PipeTransform } from '@angular/core' }) export class ConvertBytesPipe implements PipeTransform { transform(bytes: number): string { - if (bytes === 0) return '0 Bytes' - - const k = 1024 - const i = Math.floor(Math.log(bytes) / Math.log(k)) - - return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] + return convertBytes(bytes) } } @@ -27,6 +22,15 @@ export class DurationToSecondsPipe implements PipeTransform { } } +export function convertBytes(bytes: number) { + if (bytes === 0) return '0 Bytes' + + const k = 1000 + const i = Math.floor(Math.log(bytes) / Math.log(k)) + + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i] +} + const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB', 'ZB', 'YB'] const unitsToSeconds: Record = { diff --git a/frontend/projects/shared/styles/shared.scss b/frontend/projects/shared/styles/shared.scss index eefee9fd4..725d7d985 100644 --- a/frontend/projects/shared/styles/shared.scss +++ b/frontend/projects/shared/styles/shared.scss @@ -24,11 +24,8 @@ ion-alert { } ion-modal { - --max-height: 600px; + --backdrop-opacity: 0.7; &::part(content) { - width: 90% !important; - left: 5%; - border-radius: 6px; border: 2px solid rgba(255, 255, 255, 0.03); box-shadow: 0 32px 64px rgba(0, 0, 0, 0.2); @@ -157,3 +154,9 @@ ion-modal { color: var(--ion-color-success); } } + +a { + cursor: pointer; + color: aqua; + text-decoration: none; +} diff --git a/frontend/projects/ui/src/app/app-routing.module.ts b/frontend/projects/ui/src/app/app-routing.module.ts index b1b79c05d..bf6d7d9ce 100644 --- a/frontend/projects/ui/src/app/app-routing.module.ts +++ b/frontend/projects/ui/src/app/app-routing.module.ts @@ -72,6 +72,15 @@ const routes: Routes = [ m => m.DeveloperRoutingModule, ), }, + { + path: 'backups', + canActivate: [AuthGuard], + canActivateChild: [AuthGuard], + loadChildren: () => + import('./pages/backups-routes/backups-routing.module').then( + m => m.BackupsRoutingModule, + ), + }, ] @NgModule({ diff --git a/frontend/projects/ui/src/app/app.module.ts b/frontend/projects/ui/src/app/app.module.ts index 2e4bd2d2f..2df574618 100644 --- a/frontend/projects/ui/src/app/app.module.ts +++ b/frontend/projects/ui/src/app/app.module.ts @@ -22,6 +22,7 @@ import { AppComponent } from './app.component' import { AppRoutingModule } from './app-routing.module' import { OSWelcomePageModule } from './modals/os-welcome/os-welcome.module' import { GenericInputComponentModule } from './modals/generic-input/generic-input.component.module' +import { GenericFormPageModule } from './modals/generic-form/generic-form.module' import { MarketplaceModule } from './marketplace.module' import { PreloaderModule } from './app/preloader/preloader.module' import { FooterModule } from './app/footer/footer.module' @@ -53,6 +54,7 @@ import { FormPageModule } from './modals/form/form.module' OSWelcomePageModule, MarkdownModule, GenericInputComponentModule, + GenericFormPageModule, MonacoEditorModule, SharedPipesModule, MarketplaceModule, diff --git a/frontend/projects/ui/src/app/app/menu/menu.component.ts b/frontend/projects/ui/src/app/app/menu/menu.component.ts index 5721f895a..256cd4ef9 100644 --- a/frontend/projects/ui/src/app/app/menu/menu.component.ts +++ b/frontend/projects/ui/src/app/app/menu/menu.component.ts @@ -46,6 +46,11 @@ export class MenuComponent { url: '/updates', icon: 'globe-outline', }, + { + title: 'Backups', + url: '/backups', + icon: 'save-outline', + }, { title: 'Notifications', url: '/notifications', diff --git a/frontend/projects/ui/src/app/app/preloader/preloader.component.ts b/frontend/projects/ui/src/app/app/preloader/preloader.component.ts index 27d1750ec..5cb33fe0e 100644 --- a/frontend/projects/ui/src/app/app/preloader/preloader.component.ts +++ b/frontend/projects/ui/src/app/app/preloader/preloader.component.ts @@ -6,6 +6,7 @@ const ICONS = [ 'alert-outline', 'alert-circle-outline', 'aperture-outline', + 'archive-outline', 'arrow-back', 'arrow-forward', 'arrow-up', @@ -44,6 +45,7 @@ const ICONS = [ 'folder-open-outline', 'globe-outline', 'grid-outline', + 'hammer-outline', 'help-circle-outline', 'hammer-outline', 'home-outline', @@ -76,6 +78,7 @@ const ICONS = [ 'repeat-outline', 'rocket-outline', 'save-outline', + 'server-outline', 'settings-outline', 'shield-checkmark-outline', 'stop-outline', diff --git a/frontend/projects/ui/src/app/app/snek/snek.directive.ts b/frontend/projects/ui/src/app/app/snek/snek.directive.ts index 255926792..39f08f39b 100644 --- a/frontend/projects/ui/src/app/app/snek/snek.directive.ts +++ b/frontend/projects/ui/src/app/app/snek/snek.directive.ts @@ -32,7 +32,6 @@ export class SnekDirective { const loader = await this.loadingCtrl.create({ message: 'Saving high score...', - backdropDismiss: true, }) await loader.present() diff --git a/frontend/projects/ui/src/app/components/backup-drives/backup-drives-header.component.html b/frontend/projects/ui/src/app/components/backup-drives/backup-drives-header.component.html deleted file mode 100644 index 1bfd99d4d..000000000 --- a/frontend/projects/ui/src/app/components/backup-drives/backup-drives-header.component.html +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - {{ - type === 'create' ? 'Create Backup' : 'Restore From Backup' - }} - - - Refresh - - - - - diff --git a/frontend/projects/ui/src/app/components/backup-drives/backup-drives-status.component.html b/frontend/projects/ui/src/app/components/backup-drives/backup-drives-status.component.html deleted file mode 100644 index e0437cd1d..000000000 --- a/frontend/projects/ui/src/app/components/backup-drives/backup-drives-status.component.html +++ /dev/null @@ -1,20 +0,0 @@ -
-

- - {{ - hasValidBackup - ? 'Available, contains existing backup' - : 'Available for fresh backup' - }} -

- -

- - StartOS backup detected -

-

- - No StartOS backup -

-
-
diff --git a/frontend/projects/ui/src/app/components/backup-drives/backup-drives.component.html b/frontend/projects/ui/src/app/components/backup-drives/backup-drives.component.html deleted file mode 100644 index f10c3f9c5..000000000 --- a/frontend/projects/ui/src/app/components/backup-drives/backup-drives.component.html +++ /dev/null @@ -1,172 +0,0 @@ - - - - - - - - - - - - - {{ loadingError }} - - - - - - - - Network Folders - - -

- {{ - type === 'create' - ? 'Backup server to' - : 'Restore your services from' - }} - a folder on another computer that is connected to the same network - as your Start9 server. View the - - Instructions - - -

-
-
- - - - - Open New - - - - - - - -

{{ cifs.path.split('/').pop() }}

- - - -

- - Unable to connect -

-

Hostname: {{ cifs.hostname }}

-

Path: {{ cifs.path }}

-
- - - -
-
- -
- - - Physical Drives - - - -

- {{ - type === 'create' - ? 'Backup server to' - : 'Restore your services from' - }} - a physical drive that is plugged directly into your Start9 Server. - View the - - Instructions - - - . - - Warning. Do not use this option if you are using a Raspberry Pi - with an external SSD. The Raspberry Pi does not support more - than one external drive without additional power and can cause - data corruption. - -

-
-
- - -
-
-

- No drives detected. - - Refresh - - -

-
- - - - - - -

{{ drive.label || drive.logicalname }}

- -

- {{ drive.vendor || 'Unknown Vendor' }} - - {{ drive.model || 'Unknown Model' }} -

-

Capacity: {{ drive.capacity | convertBytes }}

-
-
-
-
-
-
-
-
diff --git a/frontend/projects/ui/src/app/components/backup-drives/backup-drives.component.module.ts b/frontend/projects/ui/src/app/components/backup-drives/backup-drives.component.module.ts deleted file mode 100644 index bf7f844e3..000000000 --- a/frontend/projects/ui/src/app/components/backup-drives/backup-drives.component.module.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { NgModule } from '@angular/core' -import { CommonModule } from '@angular/common' -import { IonicModule } from '@ionic/angular' -import { - BackupDrivesComponent, - BackupDrivesHeaderComponent, - BackupDrivesStatusComponent, -} from './backup-drives.component' -import { - UnitConversionPipesModule, - TextSpinnerComponentModule, -} from '@start9labs/shared' -import { GenericFormPageModule } from 'src/app/modals/generic-form/generic-form.module' - -@NgModule({ - declarations: [ - BackupDrivesComponent, - BackupDrivesHeaderComponent, - BackupDrivesStatusComponent, - ], - imports: [ - CommonModule, - IonicModule, - UnitConversionPipesModule, - TextSpinnerComponentModule, - GenericFormPageModule, - ], - exports: [ - BackupDrivesComponent, - BackupDrivesHeaderComponent, - BackupDrivesStatusComponent, - ], -}) -export class BackupDrivesComponentModule {} diff --git a/frontend/projects/ui/src/app/components/backup-drives/backup-drives.component.scss b/frontend/projects/ui/src/app/components/backup-drives/backup-drives.component.scss deleted file mode 100644 index 36da157ea..000000000 --- a/frontend/projects/ui/src/app/components/backup-drives/backup-drives.component.scss +++ /dev/null @@ -1,18 +0,0 @@ -.click-area { - padding: 50px; - - - &:hover { - background-color: var(--ion-color-medium-tint); - } - - ion-icon { - font-size: 27px; - } -} - -@media (max-width: 1000px) { - .click-area { - padding: 18px 0px 10px; - } -} \ No newline at end of file diff --git a/frontend/projects/ui/src/app/components/backup-drives/backup-drives.component.ts b/frontend/projects/ui/src/app/components/backup-drives/backup-drives.component.ts deleted file mode 100644 index 6d9c1fd1c..000000000 --- a/frontend/projects/ui/src/app/components/backup-drives/backup-drives.component.ts +++ /dev/null @@ -1,335 +0,0 @@ -import { Component, EventEmitter, Input, Output } from '@angular/core' -import { BackupService } from './backup.service' -import { - CifsBackupTarget, - DiskBackupTarget, - RR, -} from 'src/app/services/api/api.types' -import { - ActionSheetController, - AlertController, - LoadingController, - ModalController, -} from '@ionic/angular' -import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page' -import { InputSpec } from 'start-sdk/lib/config/configTypes' -import { ApiService } from 'src/app/services/api/embassy-api.service' -import { ErrorToastService } from '@start9labs/shared' -import { MappedBackupTarget } from 'src/app/types/mapped-backup-target' - -type BackupType = 'create' | 'restore' - -@Component({ - selector: 'backup-drives', - templateUrl: './backup-drives.component.html', - styleUrls: ['./backup-drives.component.scss'], -}) -export class BackupDrivesComponent { - @Input() type!: BackupType - @Output() onSelect: EventEmitter< - MappedBackupTarget - > = new EventEmitter() - loadingText = '' - - constructor( - private readonly loadingCtrl: LoadingController, - private readonly actionCtrl: ActionSheetController, - private readonly alertCtrl: AlertController, - private readonly modalCtrl: ModalController, - private readonly embassyApi: ApiService, - private readonly errToast: ErrorToastService, - private readonly backupService: BackupService, - ) {} - - get loading() { - return this.backupService.loading - } - - get loadingError() { - return this.backupService.loadingError - } - - get drives() { - return this.backupService.drives - } - - get cifs() { - return this.backupService.cifs - } - - ngOnInit() { - this.loadingText = - this.type === 'create' - ? 'Fetching Backup Targets' - : 'Fetching Backup Sources' - this.backupService.getBackupTargets() - } - - select( - target: MappedBackupTarget, - ): void { - if (target.entry.type === 'cifs' && !target.entry.mountable) { - const message = - 'Unable to connect to Network Folder. Ensure (1) target computer is connected to the same LAN as your Start9 Server, (2) target folder is being shared, and (3) hostname, path, and credentials are accurate.' - this.presentAlertError(message) - return - } - - if (this.type === 'restore' && !target.hasValidBackup) { - const message = `${ - target.entry.type === 'cifs' ? 'Network Folder' : 'Drive partition' - } does not contain a valid Start9 Server backup.` - this.presentAlertError(message) - return - } - - this.onSelect.emit(target) - } - - async presentModalAddCifs(): Promise { - const modal = await this.modalCtrl.create({ - component: GenericFormPage, - componentProps: { - title: 'New Network Folder', - spec: CifsSpec, - buttons: [ - { - text: 'Connect', - handler: (value: RR.AddBackupTargetReq) => { - return this.addCifs(value) - }, - isSubmit: true, - }, - ], - }, - }) - await modal.present() - } - - async presentActionCifs( - event: Event, - target: MappedBackupTarget, - index: number, - ): Promise { - event.stopPropagation() - - const entry = target.entry as CifsBackupTarget - - const action = await this.actionCtrl.create({ - header: entry.hostname, - subHeader: 'Shared Folder', - mode: 'ios', - buttons: [ - { - text: 'Forget', - icon: 'trash', - role: 'destructive', - handler: () => { - this.deleteCifs(target.id, index) - }, - }, - { - text: 'Edit', - icon: 'pencil', - handler: () => { - this.presentModalEditCifs(target.id, entry, index) - }, - }, - ], - }) - - await action.present() - } - - private async presentAlertError(message: string): Promise { - const alert = await this.alertCtrl.create({ - header: 'Error', - message, - buttons: ['OK'], - }) - await alert.present() - } - - private async addCifs(value: RR.AddBackupTargetReq): Promise { - const loader = await this.loadingCtrl.create({ - message: 'Testing connectivity to shared folder...', - }) - await loader.present() - - try { - const res = await this.embassyApi.addBackupTarget(value) - const [id, entry] = Object.entries(res)[0] - this.backupService.cifs.unshift({ - id, - hasValidBackup: this.backupService.hasValidBackup(entry), - entry, - }) - return true - } catch (e: any) { - this.errToast.present(e) - return false - } finally { - loader.dismiss() - } - } - - private async presentModalEditCifs( - id: string, - entry: CifsBackupTarget, - index: number, - ): Promise { - const { hostname, path, username } = entry - - const modal = await this.modalCtrl.create({ - component: GenericFormPage, - componentProps: { - title: 'Update Shared Folder', - spec: CifsSpec, - buttons: [ - { - text: 'Save', - handler: (value: RR.AddBackupTargetReq) => { - return this.editCifs({ id, ...value }, index) - }, - isSubmit: true, - }, - ], - initialValue: { - hostname, - path, - username, - }, - }, - }) - await modal.present() - } - - private async editCifs( - value: RR.UpdateBackupTargetReq, - index: number, - ): Promise { - const loader = await this.loadingCtrl.create({ - message: 'Testing connectivity to shared folder...', - }) - await loader.present() - - try { - const res = await this.embassyApi.updateBackupTarget(value) - this.backupService.cifs[index].entry = Object.values(res)[0] - } catch (e: any) { - this.errToast.present(e) - } finally { - loader.dismiss() - } - } - - private async deleteCifs(id: string, index: number): Promise { - const loader = await this.loadingCtrl.create({ - message: 'Removing...', - }) - await loader.present() - - try { - await this.embassyApi.removeBackupTarget({ id }) - this.backupService.cifs.splice(index, 1) - } catch (e: any) { - this.errToast.present(e) - } finally { - loader.dismiss() - } - } - - refresh() { - this.backupService.getBackupTargets() - } -} - -@Component({ - selector: 'backup-drives-header', - templateUrl: './backup-drives-header.component.html', - styleUrls: ['./backup-drives.component.scss'], -}) -export class BackupDrivesHeaderComponent { - @Input() type!: BackupType - @Output() onClose: EventEmitter = new EventEmitter() - - constructor(private readonly backupService: BackupService) {} - - get loading() { - return this.backupService.loading - } - - refresh() { - this.backupService.getBackupTargets() - } -} - -@Component({ - selector: 'backup-drives-status', - templateUrl: './backup-drives-status.component.html', - styleUrls: ['./backup-drives.component.scss'], -}) -export class BackupDrivesStatusComponent { - @Input() type!: BackupType - @Input() hasValidBackup!: boolean -} - -const CifsSpec: InputSpec = { - hostname: { - type: 'text', - name: 'Hostname', - description: - 'The hostname of your target device on the Local Area Network.', - inputmode: 'text', - placeholder: `e.g. 'My Computer' OR 'my-computer.local'`, - minLength: null, - maxLength: null, - patterns: [], - required: true, - masked: false, - default: null, - warning: null, - }, - path: { - type: 'text', - name: 'Path', - description: `On Windows, this is the fully qualified path to the shared folder, (e.g. /Desktop/my-folder).\n\n On Linux and Mac, this is the literal name of the shared folder (e.g. my-shared-folder).`, - inputmode: 'text', - placeholder: 'e.g. my-shared-folder or /Desktop/my-folder', - patterns: [], - minLength: null, - maxLength: null, - required: true, - masked: false, - default: null, - warning: null, - }, - username: { - type: 'text', - name: 'Username', - description: `On Linux, this is the samba username you created when sharing the folder.\n\n On Mac and Windows, this is the username of the user who is sharing the folder.`, - inputmode: 'text', - minLength: null, - maxLength: null, - placeholder: null, - patterns: [], - required: true, - masked: false, - default: null, - warning: null, - }, - password: { - type: 'text', - name: 'Password', - description: `On Linux, this is the samba password you created when sharing the folder.\n\n On Mac and Windows, this is the password of the user who is sharing the folder.`, - inputmode: 'text', - placeholder: null, - minLength: null, - maxLength: null, - patterns: [], - required: false, - masked: true, - default: null, - warning: null, - }, -} diff --git a/frontend/projects/ui/src/app/components/backup-drives/backup.service.ts b/frontend/projects/ui/src/app/components/backup-drives/backup.service.ts deleted file mode 100644 index 381110de4..000000000 --- a/frontend/projects/ui/src/app/components/backup-drives/backup.service.ts +++ /dev/null @@ -1,62 +0,0 @@ -import { Injectable } from '@angular/core' -import { IonicSafeString } from '@ionic/core' -import { ApiService } from 'src/app/services/api/embassy-api.service' -import { - BackupTarget, - CifsBackupTarget, - DiskBackupTarget, -} from 'src/app/services/api/api.types' -import { MappedBackupTarget } from 'src/app/types/mapped-backup-target' -import { getErrorMessage, Emver } from '@start9labs/shared' - -@Injectable({ - providedIn: 'root', -}) -export class BackupService { - cifs: MappedBackupTarget[] = [] - drives: MappedBackupTarget[] = [] - loading = true - loadingError: string | IonicSafeString = '' - - constructor( - private readonly embassyApi: ApiService, - private readonly emver: Emver, - ) {} - - async getBackupTargets(): Promise { - this.loading = true - - try { - const targets = await this.embassyApi.getBackupTargets({}) - // cifs - this.cifs = Object.entries(targets) - .filter(([_, target]) => target.type === 'cifs') - .map(([id, cifs]) => { - return { - id, - hasValidBackup: this.hasValidBackup(cifs), - entry: cifs as CifsBackupTarget, - } - }) - // drives - this.drives = Object.entries(targets) - .filter(([_, target]) => target.type === 'disk') - .map(([id, drive]) => { - return { - id, - hasValidBackup: this.hasValidBackup(drive), - entry: drive as DiskBackupTarget, - } - }) - } catch (e: any) { - this.loadingError = getErrorMessage(e) - } finally { - this.loading = false - } - } - - hasValidBackup(target: BackupTarget): boolean { - const backup = target['embassy-os'] - return !!backup && this.emver.compare(backup.version, '0.3.0') !== -1 - } -} diff --git a/frontend/projects/ui/src/app/components/insecure-warning/insecure-warning.component.html b/frontend/projects/ui/src/app/components/insecure-warning/insecure-warning.component.html new file mode 100644 index 000000000..4301b35b5 --- /dev/null +++ b/frontend/projects/ui/src/app/components/insecure-warning/insecure-warning.component.html @@ -0,0 +1,15 @@ + + + +

You are using unencrypted http

+

+ Click the button on the right to switch to https. Your browser may warn + you that the page is insecure. You can safely bypass this warning. It will + go away after you download and trust your Embassy's certificate +

+
+ + Open Https + + +
diff --git a/frontend/projects/ui/src/app/components/insecure-warning/insecure-warning.component.scss b/frontend/projects/ui/src/app/components/insecure-warning/insecure-warning.component.scss new file mode 100644 index 000000000..dc544f671 --- /dev/null +++ b/frontend/projects/ui/src/app/components/insecure-warning/insecure-warning.component.scss @@ -0,0 +1,8 @@ +.warn-label { + h2 { + font-weight: 700; + } + p { + font-weight: 600; + } +} \ No newline at end of file diff --git a/frontend/projects/ui/src/app/components/insecure-warning/insecure-warning.component.ts b/frontend/projects/ui/src/app/components/insecure-warning/insecure-warning.component.ts new file mode 100644 index 000000000..59d5cf9bd --- /dev/null +++ b/frontend/projects/ui/src/app/components/insecure-warning/insecure-warning.component.ts @@ -0,0 +1,18 @@ +import { DOCUMENT } from '@angular/common' +import { ChangeDetectionStrategy, Component, Inject } from '@angular/core' + +@Component({ + selector: 'insecure-warning', + templateUrl: './insecure-warning.component.html', + styleUrls: ['./insecure-warning.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class InsecureWarningComponent { + constructor(@Inject(DOCUMENT) private readonly document: Document) {} + + launchHttps() { + this.document.defaultView?.open( + this.document.location.href.replace('http', 'https'), + ) + } +} diff --git a/frontend/projects/ui/src/app/components/insecure-warning/insecure-warning.module.ts b/frontend/projects/ui/src/app/components/insecure-warning/insecure-warning.module.ts new file mode 100644 index 000000000..92429893f --- /dev/null +++ b/frontend/projects/ui/src/app/components/insecure-warning/insecure-warning.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core' +import { CommonModule } from '@angular/common' +import { IonicModule } from '@ionic/angular' +import { InsecureWarningComponent } from './insecure-warning.component' + +@NgModule({ + declarations: [InsecureWarningComponent], + imports: [CommonModule, IonicModule], + exports: [InsecureWarningComponent], +}) +export class InsecureWarningComponentModule {} diff --git a/frontend/projects/ui/src/app/components/launch-menu/launch-menu.component.html b/frontend/projects/ui/src/app/components/launch-menu/launch-menu.component.html new file mode 100644 index 000000000..474a9e7e4 --- /dev/null +++ b/frontend/projects/ui/src/app/components/launch-menu/launch-menu.component.html @@ -0,0 +1,28 @@ + + + + + + {{ address.name }} + + +

{{ address | addressType }}

+

{{ address }}

+
+ +
+
+
+
+
+
diff --git a/frontend/projects/ui/src/app/components/launch-menu/launch-menu.component.scss b/frontend/projects/ui/src/app/components/launch-menu/launch-menu.component.scss new file mode 100644 index 000000000..70adf02c0 --- /dev/null +++ b/frontend/projects/ui/src/app/components/launch-menu/launch-menu.component.scss @@ -0,0 +1,3 @@ +ion-popover { + --min-width: 360px; +} \ No newline at end of file diff --git a/frontend/projects/ui/src/app/components/launch-menu/launch-menu.component.ts b/frontend/projects/ui/src/app/components/launch-menu/launch-menu.component.ts new file mode 100644 index 000000000..1c22bf891 --- /dev/null +++ b/frontend/projects/ui/src/app/components/launch-menu/launch-menu.component.ts @@ -0,0 +1,37 @@ +import { DOCUMENT } from '@angular/common' +import { + ChangeDetectionStrategy, + Component, + Inject, + Input, + ViewChild, +} from '@angular/core' +import { InstalledPackageInfo } from 'src/app/services/patch-db/data-model' + +@Component({ + selector: 'launch-menu', + templateUrl: 'launch-menu.component.html', + styleUrls: ['launch-menu.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class LaunchMenuComponent { + @ViewChild('popover') popover!: HTMLIonPopoverElement + + @Input() + addressInfo!: InstalledPackageInfo['address-info'] + + set isOpen(open: boolean) { + this.popover.isOpen = open + } + + set event(event: Event) { + this.popover.event = event + } + + constructor(@Inject(DOCUMENT) private readonly document: Document) {} + + launchUI(address: string) { + this.document.defaultView?.open(address, '_blank', 'noreferrer') + this.popover.isOpen = false + } +} diff --git a/frontend/projects/ui/src/app/components/launch-menu/launch-menu.module.ts b/frontend/projects/ui/src/app/components/launch-menu/launch-menu.module.ts new file mode 100644 index 000000000..56e0a9ac5 --- /dev/null +++ b/frontend/projects/ui/src/app/components/launch-menu/launch-menu.module.ts @@ -0,0 +1,12 @@ +import { NgModule } from '@angular/core' +import { CommonModule } from '@angular/common' +import { IonicModule } from '@ionic/angular' +import { LaunchMenuComponent } from './launch-menu.component' +import { UiPipeModule } from 'src/app/pipes/ui/ui.module' + +@NgModule({ + declarations: [LaunchMenuComponent], + imports: [CommonModule, IonicModule, UiPipeModule], + exports: [LaunchMenuComponent], +}) +export class LaunchMenuComponentModule {} diff --git a/frontend/projects/ui/src/app/modals/generic-form/generic-form.page.ts b/frontend/projects/ui/src/app/modals/generic-form/generic-form.page.ts index d9544d08c..7a4c4042c 100644 --- a/frontend/projects/ui/src/app/modals/generic-form/generic-form.page.ts +++ b/frontend/projects/ui/src/app/modals/generic-form/generic-form.page.ts @@ -6,6 +6,7 @@ import { FormService, } from 'src/app/services/form.service' import { InputSpec } from 'start-sdk/lib/config/configTypes' +import { ErrorToastService } from '@start9labs/shared' export interface ActionButton { text: string @@ -30,6 +31,7 @@ export class GenericFormPage { constructor( private readonly modalCtrl: ModalController, private readonly formService: FormService, + private readonly errToast: ErrorToastService, ) {} ngOnInit() { @@ -51,9 +53,12 @@ export class GenericFormPage { return } - // @TODO make this more like generic input component dismissal - const success = await handler(this.formGroup.value) - if (success === true) this.modalCtrl.dismiss() + try { + const response = await handler(this.formGroup.value) + this.modalCtrl.dismiss({ response }, 'success') + } catch (e: any) { + this.errToast.present(e) + } } } diff --git a/frontend/projects/ui/src/app/modals/generic-input/generic-input.component.ts b/frontend/projects/ui/src/app/modals/generic-input/generic-input.component.ts index 22d4d5b25..1bcf27d6a 100644 --- a/frontend/projects/ui/src/app/modals/generic-input/generic-input.component.ts +++ b/frontend/projects/ui/src/app/modals/generic-input/generic-input.component.ts @@ -71,8 +71,8 @@ export class GenericInputComponent { if (!value && this.options.required) return try { - await this.options.submitFn(value) - this.modalCtrl.dismiss(undefined, 'success') + const response = await this.options.submitFn(value) + this.modalCtrl.dismiss({ response, value }, 'success') } catch (e: any) { this.error = getErrorMessage(e) } diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-actions/app-actions.module.ts b/frontend/projects/ui/src/app/pages/apps-routes/app-actions/app-actions.module.ts index 10927d60a..71d7ffc30 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-actions/app-actions.module.ts +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-actions/app-actions.module.ts @@ -9,7 +9,6 @@ import { } from './app-actions.page' import { QRComponentModule } from 'src/app/components/qr/qr.component.module' import { SharedPipesModule } from '@start9labs/shared' -import { GenericFormPageModule } from 'src/app/modals/generic-form/generic-form.module' import { ActionSuccessPageModule } from 'src/app/modals/action-success/action-success.module' const routes: Routes = [ @@ -26,7 +25,6 @@ const routes: Routes = [ RouterModule.forChild(routes), QRComponentModule, SharedPipesModule, - GenericFormPageModule, ActionSuccessPageModule, ], declarations: [AppActionsPage, AppActionsItemComponent, GroupActionsPipe], diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-actions/app-actions.page.ts b/frontend/projects/ui/src/app/pages/apps-routes/app-actions/app-actions.page.ts index 085e1ed99..0095217d5 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-actions/app-actions.page.ts +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-actions/app-actions.page.ts @@ -74,7 +74,8 @@ export class AppActionsPage { buttons: [ { text: 'Execute', - handler: (value: any) => this.executeAction(action.id, value), + handler: async (value: any) => + this.executeAction(action.id, value), isSubmit: true, }, ], @@ -97,9 +98,7 @@ export class AppActionsPage { }, { text: 'Execute', - handler: () => { - this.executeAction(action.id) - }, + handler: async () => this.executeAction(action.id), cssClass: 'enter-click', }, ], @@ -185,10 +184,10 @@ export class AppActionsPage { }) setTimeout(() => successModal.present(), 500) - return true // needed to dismiss original modal/alert + return true } catch (e: any) { this.errToast.present(e) - return false // don't dismiss original modal/alert + return false } finally { loader.dismiss() } diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-icon/app-list-icon.component.html b/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-icon/app-list-icon.component.html index 8f1af1470..90bda33b9 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-icon/app-list-icon.component.html +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-icon/app-list-icon.component.html @@ -10,7 +10,6 @@ diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-pkg/app-list-pkg.component.html b/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-pkg/app-list-pkg.component.html index 412d2016a..6cd49a9f8 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-pkg/app-list-pkg.component.html +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-pkg/app-list-pkg.component.html @@ -30,30 +30,9 @@ > - - - - - - {{ uiAddress.name }} - - -

{{ address | addressType }}

-

{{ address }}

-
-
-
-
-
-
-
+ diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-pkg/app-list-pkg.component.scss b/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-pkg/app-list-pkg.component.scss index 64a03813a..e69de29bb 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-pkg/app-list-pkg.component.scss +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-pkg/app-list-pkg.component.scss @@ -1,3 +0,0 @@ -ion-popover { - --min-width: 300px; -} \ No newline at end of file diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-pkg/app-list-pkg.component.ts b/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-pkg/app-list-pkg.component.ts index 3a5bfc4c6..87523897b 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-pkg/app-list-pkg.component.ts +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list-pkg/app-list-pkg.component.ts @@ -4,6 +4,7 @@ import { Input, ViewChild, } from '@angular/core' +import { LaunchMenuComponent } from 'src/app/components/launch-menu/launch-menu.component' import { PackageMainStatus } from 'src/app/services/patch-db/data-model' import { PkgInfo } from 'src/app/util/get-package-info' @@ -14,13 +15,11 @@ import { PkgInfo } from 'src/app/util/get-package-info' changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppListPkgComponent { - @ViewChild('popover') popover!: HTMLIonPopoverElement + @ViewChild('launchMenu') launchMenu!: LaunchMenuComponent @Input() pkg!: PkgInfo - isPopoverOpen = false - get status(): PackageMainStatus { return ( this.pkg.entry.installed?.status.main.status || PackageMainStatus.Stopped @@ -30,7 +29,7 @@ export class AppListPkgComponent { openPopover(e: Event): void { e.stopPropagation() e.preventDefault() - this.popover.event = e - this.isPopoverOpen = true + this.launchMenu.event = e + this.launchMenu.isOpen = true } } diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list.module.ts b/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list.module.ts index e809cb61e..27ff439fe 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list.module.ts +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-list/app-list.module.ts @@ -16,6 +16,7 @@ import { AppListIconComponent } from './app-list-icon/app-list-icon.component' import { AppListPkgComponent } from './app-list-pkg/app-list-pkg.component' import { PackageInfoPipe } from './package-info.pipe' import { WidgetListComponentModule } from 'src/app/components/widget-list/widget-list.component.module' +import { LaunchMenuComponentModule } from 'src/app/components/launch-menu/launch-menu.module' const routes: Routes = [ { @@ -37,6 +38,7 @@ const routes: Routes = [ WidgetListComponentModule, ResponsiveColModule, TickerModule, + LaunchMenuComponentModule, ], declarations: [ AppListPage, diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-show/app-show.module.ts b/frontend/projects/ui/src/app/pages/apps-routes/app-show/app-show.module.ts index 6768eeb80..b9d79d6f0 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-show/app-show.module.ts +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-show/app-show.module.ts @@ -23,6 +23,8 @@ import { ToButtonsPipe } from './pipes/to-buttons.pipe' import { ToDependenciesPipe } from './pipes/to-dependencies.pipe' import { ToStatusPipe } from './pipes/to-status.pipe' import { ProgressDataPipe } from './pipes/progress-data.pipe' +import { InsecureWarningComponentModule } from 'src/app/components/insecure-warning/insecure-warning.module' +import { LaunchMenuComponentModule } from 'src/app/components/launch-menu/launch-menu.module' const routes: Routes = [ { @@ -57,6 +59,8 @@ const routes: Routes = [ UiPipeModule, ResponsiveColModule, SharedPipesModule, + InsecureWarningComponentModule, + LaunchMenuComponentModule, ], }) export class AppShowPageModule {} diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.html b/frontend/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.html index 4f825ccb6..c0ca7224c 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.html +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.html @@ -48,33 +48,8 @@ - - - -

- - You are using an unencrypted http connection - -

-

- Click the button below to switch to https. Your browser may warn - you that the page is insecure. You can safely bypass this - warning. It will go away after you - - download and trust your server's certificate - - . -

- - Open https - - -
-
-
+ +

This page cannot safely be accessed over an insecure connection

diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.ts b/frontend/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.ts index ba5169c44..ef1cad136 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.ts +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-show/app-show.page.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, Component, Inject } from '@angular/core' +import { ChangeDetectionStrategy, Component } from '@angular/core' import { NavController } from '@ionic/angular' import { PatchDB } from 'patch-db-client' import { @@ -13,9 +13,7 @@ import { import { tap } from 'rxjs/operators' import { ActivatedRoute } from '@angular/router' import { getPkgId } from '@start9labs/shared' -import { DOCUMENT } from '@angular/common' import { ConfigService } from 'src/app/services/config.service' -import { getServerInfo } from 'src/app/util/get-server-info' const STATES = [ PackageState.Installing, @@ -45,7 +43,6 @@ export class AppShowPage { private readonly navCtrl: NavController, private readonly patch: PatchDB, private readonly config: ConfigService, - @Inject(DOCUMENT) private readonly document: Document, ) {} isInstalled({ state }: PackageDataEntry): boolean { @@ -63,9 +60,4 @@ export class AppShowPage { showProgress({ state }: PackageDataEntry): boolean { return STATES.includes(state) } - - async launchHttps() { - const { 'lan-address': lanAddress } = await getServerInfo(this.patch) - window.open(lanAddress) - } } diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.html b/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.html index 2c9da38c1..5cace82aa 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.html +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.html @@ -5,7 +5,7 @@ size="x-large" weight="600" [installProgress]="pkg['install-progress']" - [rendering]="PR[status.primary]" + [rendering]="rendering" > @@ -53,12 +53,14 @@ *ngIf="addressInfo | hasUi" class="action-button" color="primary" - [disabled]="status.primary === 'running'" - (click)="launchUi(addressInfo)" + [disabled]="status.primary !== 'running'" + (click)="openPopover($event)" > Open UI + + diff --git a/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.ts b/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.ts index 8cbb69cbe..3bf109bd0 100644 --- a/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.ts +++ b/frontend/projects/ui/src/app/pages/apps-routes/app-show/components/app-show-status/app-show-status.component.ts @@ -1,14 +1,18 @@ -import { ChangeDetectionStrategy, Component, Input } from '@angular/core' -import { UiLauncherService } from 'src/app/services/ui-launcher.service' +import { + ChangeDetectionStrategy, + Component, + Input, + ViewChild, +} from '@angular/core' import { PackageStatus, PrimaryRendering, PrimaryStatus, + StatusRendering, } from 'src/app/services/pkg-status-rendering.service' import { AddressInfo, DataModel, - InstalledPackageInfo, PackageDataEntry, PackageState, } from 'src/app/services/patch-db/data-model' @@ -24,6 +28,7 @@ import { DependencyInfo } from '../../pipes/to-dependencies.pipe' import { hasCurrentDeps } from 'src/app/util/has-deps' import { ConnectionService } from 'src/app/services/connection.service' import { PatchDB } from 'patch-db-client' +import { LaunchMenuComponent } from 'src/app/components/launch-menu/launch-menu.component' @Component({ selector: 'app-show-status', @@ -32,6 +37,8 @@ import { PatchDB } from 'patch-db-client' changeDetection: ChangeDetectionStrategy.OnPush, }) export class AppShowStatusComponent { + @ViewChild('launchMenu') launchMenu!: LaunchMenuComponent + @Input() pkg!: PackageDataEntry @@ -41,8 +48,6 @@ export class AppShowStatusComponent { @Input() dependencies: DependencyInfo[] = [] - PR = PrimaryRendering - readonly connected$ = this.connectionService.connected$ constructor( @@ -50,7 +55,6 @@ export class AppShowStatusComponent { private readonly errToast: ErrorToastService, private readonly loadingCtrl: LoadingController, private readonly embassyApi: ApiService, - private readonly launcherService: UiLauncherService, private readonly formDialog: FormDialogService, private readonly connectionService: ConnectionService, private readonly patch: PatchDB, @@ -80,8 +84,13 @@ export class AppShowStatusComponent { return this.status.primary === PrimaryStatus.Stopped } - launchUi(addressInfo: InstalledPackageInfo['address-info']): void { - this.launcherService.launch(addressInfo) + get rendering(): StatusRendering { + return PrimaryRendering[this.status.primary] + } + + openPopover(e: Event): void { + this.launchMenu.event = e + this.launchMenu.isOpen = true } presentModalConfig(): void { diff --git a/frontend/projects/ui/src/app/pages/backups-routes/backups-routing.module.ts b/frontend/projects/ui/src/app/pages/backups-routes/backups-routing.module.ts new file mode 100644 index 000000000..94b506cc1 --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/backups-routing.module.ts @@ -0,0 +1,37 @@ +import { NgModule } from '@angular/core' +import { Routes, RouterModule } from '@angular/router' + +const routes: Routes = [ + { + path: '', + loadChildren: () => + import('./pages/backups/backups.module').then(m => m.BackupsPageModule), + }, + { + path: 'jobs', + loadChildren: () => + import('./pages/backup-jobs/backup-jobs.module').then( + m => m.BackupJobsPageModule, + ), + }, + { + path: 'targets', + loadChildren: () => + import('./pages/backup-targets/backup-targets.module').then( + m => m.BackupTargetsPageModule, + ), + }, + { + path: 'history', + loadChildren: () => + import('./pages/backup-history/backup-history.module').then( + m => m.BackupHistoryPageModule, + ), + }, +] + +@NgModule({ + imports: [RouterModule.forChild(routes)], + exports: [RouterModule], +}) +export class BackupsRoutingModule {} diff --git a/frontend/projects/ui/src/app/pages/server-routes/server-backup/backing-up/backing-up.component.html b/frontend/projects/ui/src/app/pages/backups-routes/components/backing-up/backing-up.component.html similarity index 93% rename from frontend/projects/ui/src/app/pages/server-routes/server-backup/backing-up/backing-up.component.html rename to frontend/projects/ui/src/app/pages/backups-routes/components/backing-up/backing-up.component.html index b3ffd149b..153de04c0 100644 --- a/frontend/projects/ui/src/app/pages/server-routes/server-backup/backing-up/backing-up.component.html +++ b/frontend/projects/ui/src/app/pages/backups-routes/components/backing-up/backing-up.component.html @@ -35,10 +35,7 @@ > diff --git a/frontend/projects/ui/src/app/pages/server-routes/server-backup/backing-up/backing-up.component.ts b/frontend/projects/ui/src/app/pages/backups-routes/components/backing-up/backing-up.component.ts similarity index 96% rename from frontend/projects/ui/src/app/pages/server-routes/server-backup/backing-up/backing-up.component.ts rename to frontend/projects/ui/src/app/pages/backups-routes/components/backing-up/backing-up.component.ts index 85ffbb2e1..9e9016481 100644 --- a/frontend/projects/ui/src/app/pages/server-routes/server-backup/backing-up/backing-up.component.ts +++ b/frontend/projects/ui/src/app/pages/backups-routes/components/backing-up/backing-up.component.ts @@ -22,11 +22,10 @@ export class BackingUpComponent { readonly backupProgress$ = this.patch.watch$( 'server-info', 'status-info', + 'current-backup', 'backup-progress', ) - PackageMainStatus = PackageMainStatus - constructor(private readonly patch: PatchDB) {} } diff --git a/frontend/projects/ui/src/app/pages/backups-routes/directives/backup-create.directive.ts b/frontend/projects/ui/src/app/pages/backups-routes/directives/backup-create.directive.ts new file mode 100644 index 000000000..1343707d6 --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/directives/backup-create.directive.ts @@ -0,0 +1,77 @@ +import { Directive, HostListener } from '@angular/core' +import { LoadingController, ModalController } from '@ionic/angular' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { BackupSelectPage } from 'src/app/pages/backups-routes/modals/backup-select/backup-select.page' +import { TargetSelectPage } from '../modals/target-select/target-select.page' +import { + CifsBackupTarget, + DiskBackupTarget, +} from 'src/app/services/api/api.types' + +@Directive({ + selector: '[backupCreate]', +}) +export class BackupCreateDirective { + serviceIds: string[] = [] + + constructor( + private readonly loadingCtrl: LoadingController, + private readonly modalCtrl: ModalController, + private readonly embassyApi: ApiService, + ) {} + + @HostListener('click') onClick() { + this.presentModalTarget() + } + + async presentModalTarget() { + const modal = await this.modalCtrl.create({ + presentingElement: await this.modalCtrl.getTop(), + component: TargetSelectPage, + componentProps: { type: 'create' }, + }) + + modal.onDidDismiss().then(res => { + if (res.data) { + this.presentModalSelect(res.data.id) + } + }) + + await modal.present() + } + + private async presentModalSelect(targetId: string) { + const modal = await this.modalCtrl.create({ + presentingElement: await this.modalCtrl.getTop(), + component: BackupSelectPage, + componentProps: { + btnText: 'Create Backup', + }, + }) + + modal.onWillDismiss().then(res => { + if (res.data) { + this.createBackup(targetId, res.data) + } + }) + + await modal.present() + } + + private async createBackup( + targetId: string, + pkgIds: string[], + ): Promise { + const loader = await this.loadingCtrl.create({ + message: 'Beginning backup...', + }) + await loader.present() + + await this.embassyApi + .createBackup({ + 'target-id': targetId, + 'package-ids': pkgIds, + }) + .finally(() => loader.dismiss()) + } +} diff --git a/frontend/projects/ui/src/app/pages/server-routes/restore/restore.component.ts b/frontend/projects/ui/src/app/pages/backups-routes/directives/backup-restore.directive.ts similarity index 55% rename from frontend/projects/ui/src/app/pages/server-routes/restore/restore.component.ts rename to frontend/projects/ui/src/app/pages/backups-routes/directives/backup-restore.directive.ts index 24adff639..da9178fb3 100644 --- a/frontend/projects/ui/src/app/pages/server-routes/restore/restore.component.ts +++ b/frontend/projects/ui/src/app/pages/backups-routes/directives/backup-restore.directive.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core' +import { Directive, HostListener } from '@angular/core' import { LoadingController, ModalController, @@ -9,21 +9,15 @@ import { GenericInputComponent, GenericInputOptions, } from 'src/app/modals/generic-input/generic-input.component' -import { MappedBackupTarget } from 'src/app/types/mapped-backup-target' -import { - BackupInfo, - CifsBackupTarget, - DiskBackupTarget, -} from 'src/app/services/api/api.types' -import { AppRecoverSelectPage } from 'src/app/modals/app-recover-select/app-recover-select.page' +import { BackupInfo, BackupTarget } from 'src/app/services/api/api.types' +import { RecoverSelectPage } from 'src/app/pages/backups-routes/modals/recover-select/recover-select.page' import * as argon2 from '@start9labs/argon2' +import { TargetSelectPage } from '../modals/target-select/target-select.page' -@Component({ - selector: 'restore', - templateUrl: './restore.component.html', - styleUrls: ['./restore.component.scss'], +@Directive({ + selector: '[backupRestore]', }) -export class RestorePage { +export class BackupRestoreDirective { constructor( private readonly modalCtrl: ModalController, private readonly navCtrl: NavController, @@ -31,9 +25,27 @@ export class RestorePage { private readonly loadingCtrl: LoadingController, ) {} - async presentModalPassword( - target: MappedBackupTarget, - ): Promise { + @HostListener('click') onClick() { + this.presentModalTarget() + } + + async presentModalTarget() { + const modal = await this.modalCtrl.create({ + presentingElement: await this.modalCtrl.getTop(), + component: TargetSelectPage, + componentProps: { type: 'restore' }, + }) + + modal.onDidDismiss().then(res => { + if (res.data) { + this.presentModalPassword(res.data) + } + }) + + await modal.present() + } + + async presentModalPassword(target: BackupTarget): Promise { const options: GenericInputOptions = { title: 'Password Required', message: @@ -43,9 +55,9 @@ export class RestorePage { useMask: true, buttonText: 'Next', submitFn: async (password: string) => { - const passwordHash = target.entry['embassy-os']?.['password-hash'] || '' + const passwordHash = target['embassy-os']?.['password-hash'] || '' argon2.verify(passwordHash, password) - await this.restoreFromBackup(target, password) + return this.getBackupInfo(target.id, password) }, } @@ -56,45 +68,46 @@ export class RestorePage { component: GenericInputComponent, }) + modal.onDidDismiss().then(res => { + if (res.data) { + const { value, response } = res.data + this.presentModalSelect(target.id, response, value) + } + }) + await modal.present() } - private async restoreFromBackup( - target: MappedBackupTarget, + private async getBackupInfo( + targetId: string, password: string, - oldPassword?: string, - ): Promise { + ): Promise { const loader = await this.loadingCtrl.create({ message: 'Decrypting drive...', }) await loader.present() - try { - const backupInfo = await this.embassyApi.getBackupInfo({ - 'target-id': target.id, + return this.embassyApi + .getBackupInfo({ + 'target-id': targetId, password, }) - this.presentModalSelect(target.id, backupInfo, password, oldPassword) - } finally { - loader.dismiss() - } + .finally(() => loader.dismiss()) } private async presentModalSelect( - id: string, + targetId: string, backupInfo: BackupInfo, password: string, - oldPassword?: string, ): Promise { const modal = await this.modalCtrl.create({ componentProps: { - id, + targetId, backupInfo, password, - oldPassword, }, presentingElement: await this.modalCtrl.getTop(), - component: AppRecoverSelectPage, + component: RecoverSelectPage, }) modal.onWillDismiss().then(res => { diff --git a/frontend/projects/ui/src/app/modals/backup-select/backup-select.module.ts b/frontend/projects/ui/src/app/pages/backups-routes/modals/backup-select/backup-select.module.ts similarity index 100% rename from frontend/projects/ui/src/app/modals/backup-select/backup-select.module.ts rename to frontend/projects/ui/src/app/pages/backups-routes/modals/backup-select/backup-select.module.ts diff --git a/frontend/projects/ui/src/app/modals/backup-select/backup-select.page.html b/frontend/projects/ui/src/app/pages/backups-routes/modals/backup-select/backup-select.page.html similarity index 96% rename from frontend/projects/ui/src/app/modals/backup-select/backup-select.page.html rename to frontend/projects/ui/src/app/pages/backups-routes/modals/backup-select/backup-select.page.html index 37abf98c0..457152a45 100644 --- a/frontend/projects/ui/src/app/modals/backup-select/backup-select.page.html +++ b/frontend/projects/ui/src/app/pages/backups-routes/modals/backup-select/backup-select.page.html @@ -47,10 +47,10 @@ [disabled]="!hasSelection" fill="solid" color="primary" - (click)="dismiss(true)" + (click)="done()" class="enter-click btn-128" > - Back Up Selected + {{ btnText }} diff --git a/frontend/projects/ui/src/app/modals/backup-select/backup-select.page.scss b/frontend/projects/ui/src/app/pages/backups-routes/modals/backup-select/backup-select.page.scss similarity index 100% rename from frontend/projects/ui/src/app/modals/backup-select/backup-select.page.scss rename to frontend/projects/ui/src/app/pages/backups-routes/modals/backup-select/backup-select.page.scss diff --git a/frontend/projects/ui/src/app/modals/backup-select/backup-select.page.ts b/frontend/projects/ui/src/app/pages/backups-routes/modals/backup-select/backup-select.page.ts similarity index 81% rename from frontend/projects/ui/src/app/modals/backup-select/backup-select.page.ts rename to frontend/projects/ui/src/app/pages/backups-routes/modals/backup-select/backup-select.page.ts index bff2bfd1b..117597b1a 100644 --- a/frontend/projects/ui/src/app/modals/backup-select/backup-select.page.ts +++ b/frontend/projects/ui/src/app/pages/backups-routes/modals/backup-select/backup-select.page.ts @@ -1,4 +1,4 @@ -import { Component } from '@angular/core' +import { Component, Input } from '@angular/core' import { ModalController } from '@ionic/angular' import { map } from 'rxjs/operators' import { DataModel, PackageState } from 'src/app/services/patch-db/data-model' @@ -11,6 +11,9 @@ import { firstValueFrom } from 'rxjs' styleUrls: ['./backup-select.page.scss'], }) export class BackupSelectPage { + @Input() btnText!: string + @Input() selectedIds: string[] = [] + hasSelection = false selectAll = false pkgs: { @@ -38,7 +41,7 @@ export class BackupSelectPage { title, icon: pkg.icon, disabled: pkg.state !== PackageState.Installed, - checked: pkg.state === PackageState.Installed, + checked: this.selectedIds.includes(id), } }) .sort((a, b) => @@ -49,13 +52,13 @@ export class BackupSelectPage { ) } - dismiss(success = false) { - if (success) { - const ids = this.pkgs.filter(p => p.checked).map(p => p.id) - this.modalCtrl.dismiss(ids) - } else { - this.modalCtrl.dismiss() - } + dismiss() { + this.modalCtrl.dismiss() + } + + async done() { + const pkgIds = this.pkgs.filter(p => p.checked).map(p => p.id) + this.modalCtrl.dismiss(pkgIds) } handleChange() { diff --git a/frontend/projects/ui/src/app/modals/app-recover-select/app-recover-select.module.ts b/frontend/projects/ui/src/app/pages/backups-routes/modals/recover-select/recover-select.module.ts similarity index 59% rename from frontend/projects/ui/src/app/modals/app-recover-select/app-recover-select.module.ts rename to frontend/projects/ui/src/app/pages/backups-routes/modals/recover-select/recover-select.module.ts index 90c9c1097..3cf866171 100644 --- a/frontend/projects/ui/src/app/modals/app-recover-select/app-recover-select.module.ts +++ b/frontend/projects/ui/src/app/pages/backups-routes/modals/recover-select/recover-select.module.ts @@ -2,13 +2,12 @@ import { NgModule } from '@angular/core' import { CommonModule } from '@angular/common' import { IonicModule } from '@ionic/angular' import { FormsModule } from '@angular/forms' - -import { AppRecoverSelectPage } from './app-recover-select.page' +import { RecoverSelectPage } from './recover-select.page' import { ToOptionsPipe } from './to-options.pipe' @NgModule({ - declarations: [AppRecoverSelectPage, ToOptionsPipe], + declarations: [RecoverSelectPage, ToOptionsPipe], imports: [CommonModule, IonicModule, FormsModule], - exports: [AppRecoverSelectPage], + exports: [RecoverSelectPage], }) -export class AppRecoverSelectPageModule {} +export class RecoverSelectPageModule {} diff --git a/frontend/projects/ui/src/app/modals/app-recover-select/app-recover-select.page.html b/frontend/projects/ui/src/app/pages/backups-routes/modals/recover-select/recover-select.page.html similarity index 100% rename from frontend/projects/ui/src/app/modals/app-recover-select/app-recover-select.page.html rename to frontend/projects/ui/src/app/pages/backups-routes/modals/recover-select/recover-select.page.html diff --git a/frontend/projects/ui/src/app/modals/app-recover-select/app-recover-select.page.scss b/frontend/projects/ui/src/app/pages/backups-routes/modals/recover-select/recover-select.page.scss similarity index 100% rename from frontend/projects/ui/src/app/modals/app-recover-select/app-recover-select.page.scss rename to frontend/projects/ui/src/app/pages/backups-routes/modals/recover-select/recover-select.page.scss diff --git a/frontend/projects/ui/src/app/modals/app-recover-select/app-recover-select.page.ts b/frontend/projects/ui/src/app/pages/backups-routes/modals/recover-select/recover-select.page.ts similarity index 86% rename from frontend/projects/ui/src/app/modals/app-recover-select/app-recover-select.page.ts rename to frontend/projects/ui/src/app/pages/backups-routes/modals/recover-select/recover-select.page.ts index 65f61e643..85989cc45 100644 --- a/frontend/projects/ui/src/app/modals/app-recover-select/app-recover-select.page.ts +++ b/frontend/projects/ui/src/app/pages/backups-routes/modals/recover-select/recover-select.page.ts @@ -13,12 +13,12 @@ import { DataModel } from 'src/app/services/patch-db/data-model' import { take } from 'rxjs' @Component({ - selector: 'app-recover-select', - templateUrl: './app-recover-select.page.html', - styleUrls: ['./app-recover-select.page.scss'], + selector: 'recover-select', + templateUrl: './recover-select.page.html', + styleUrls: ['./recover-select.page.scss'], }) -export class AppRecoverSelectPage { - @Input() id!: string +export class RecoverSelectPage { + @Input() targetId!: string @Input() backupInfo!: BackupInfo @Input() password!: string @Input() oldPassword?: string @@ -53,8 +53,7 @@ export class AppRecoverSelectPage { try { await this.embassyApi.restorePackages({ ids, - 'target-id': this.id, - 'old-password': this.oldPassword || null, + 'target-id': this.targetId, password: this.password, }) this.modalCtrl.dismiss(undefined, 'success') diff --git a/frontend/projects/ui/src/app/modals/app-recover-select/to-options.pipe.ts b/frontend/projects/ui/src/app/pages/backups-routes/modals/recover-select/to-options.pipe.ts similarity index 100% rename from frontend/projects/ui/src/app/modals/app-recover-select/to-options.pipe.ts rename to frontend/projects/ui/src/app/pages/backups-routes/modals/recover-select/to-options.pipe.ts diff --git a/frontend/projects/ui/src/app/pages/backups-routes/modals/target-select/target-select.module.ts b/frontend/projects/ui/src/app/pages/backups-routes/modals/target-select/target-select.module.ts new file mode 100644 index 000000000..7cfa69407 --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/modals/target-select/target-select.module.ts @@ -0,0 +1,18 @@ +import { NgModule } from '@angular/core' +import { CommonModule } from '@angular/common' +import { IonicModule } from '@ionic/angular' +import { TargetSelectPage, TargetStatusComponent } from './target-select.page' +import { TargetPipesModule } from '../../pipes/target-pipes.module' +import { TextSpinnerComponentModule } from '@start9labs/shared' + +@NgModule({ + declarations: [TargetSelectPage, TargetStatusComponent], + imports: [ + CommonModule, + IonicModule, + TargetPipesModule, + TextSpinnerComponentModule, + ], + exports: [TargetSelectPage], +}) +export class TargetSelectPageModule {} diff --git a/frontend/projects/ui/src/app/pages/backups-routes/modals/target-select/target-select.page.html b/frontend/projects/ui/src/app/pages/backups-routes/modals/target-select/target-select.page.html new file mode 100644 index 000000000..b4cf91e06 --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/modals/target-select/target-select.page.html @@ -0,0 +1,55 @@ + + + Select Backup {{ type === 'create' ? 'Target' : 'Source' }} + + + + + + + + + + + + + + + + Saved Targets + + + + +

{{ displayInfo.name }}

+ +

{{ displayInfo.description }}

+

{{ displayInfo.path }}

+
+
+
+ +
+

No saved targets

+ Go to Targets +
+
+
+
diff --git a/frontend/projects/ui/src/app/pages/server-routes/restore/restore.component.scss b/frontend/projects/ui/src/app/pages/backups-routes/modals/target-select/target-select.page.scss similarity index 100% rename from frontend/projects/ui/src/app/pages/server-routes/restore/restore.component.scss rename to frontend/projects/ui/src/app/pages/backups-routes/modals/target-select/target-select.page.scss diff --git a/frontend/projects/ui/src/app/pages/backups-routes/modals/target-select/target-select.page.ts b/frontend/projects/ui/src/app/pages/backups-routes/modals/target-select/target-select.page.ts new file mode 100644 index 000000000..176c40d4e --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/modals/target-select/target-select.page.ts @@ -0,0 +1,72 @@ +import { ChangeDetectionStrategy, Component, Input } from '@angular/core' +import { ModalController, NavController } from '@ionic/angular' +import { BehaviorSubject } from 'rxjs' +import { BackupTarget } from 'src/app/services/api/api.types' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { ErrorToastService } from '@start9labs/shared' +import { BackupType } from '../../pages/backup-targets/backup-targets.page' + +@Component({ + selector: 'target-select', + templateUrl: './target-select.page.html', + styleUrls: ['./target-select.page.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class TargetSelectPage { + @Input() type!: BackupType + @Input() isOneOff = true + + targets: BackupTarget[] = [] + + loading$ = new BehaviorSubject(true) + + constructor( + private readonly modalCtrl: ModalController, + private readonly navCtrl: NavController, + private readonly api: ApiService, + private readonly errToast: ErrorToastService, + ) {} + + async ngOnInit() { + await this.getTargets() + } + + dismiss() { + this.modalCtrl.dismiss() + } + + select(target: BackupTarget): void { + this.modalCtrl.dismiss(target) + } + + goToTargets() { + this.modalCtrl + .dismiss() + .then(() => this.navCtrl.navigateForward(`/backups/targets`)) + } + + async refresh() { + await this.getTargets() + } + + private async getTargets(): Promise { + this.loading$.next(true) + try { + this.targets = (await this.api.getBackupTargets({})).saved + } catch (e: any) { + this.errToast.present(e) + } finally { + this.loading$.next(false) + } + } +} + +@Component({ + selector: 'target-status', + templateUrl: './target-status.component.html', + styleUrls: ['./target-select.page.scss'], +}) +export class TargetStatusComponent { + @Input() type!: BackupType + @Input() target!: BackupTarget +} diff --git a/frontend/projects/ui/src/app/pages/backups-routes/modals/target-select/target-status.component.html b/frontend/projects/ui/src/app/pages/backups-routes/modals/target-select/target-status.component.html new file mode 100644 index 000000000..fb6942e86 --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/modals/target-select/target-status.component.html @@ -0,0 +1,30 @@ +
+

+ + Unable to connect +

+ + +

+ + {{ + (target | hasValidBackup) + ? 'Available, contains existing backup' + : 'Available for fresh backup' + }} +

+ + +

+ + Embassy backup detected +

+ +

+ + No Embassy backup +

+
+
+
+
diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-history/backup-history.module.ts b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-history/backup-history.module.ts new file mode 100644 index 000000000..cbc1ba2dd --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-history/backup-history.module.ts @@ -0,0 +1,28 @@ +import { NgModule } from '@angular/core' +import { RouterModule, Routes } from '@angular/router' +import { CommonModule } from '@angular/common' +import { IonicModule } from '@ionic/angular' +import { + BackupHistoryPage, + DurationPipe, + HasErrorPipe, +} from './backup-history.page' +import { TargetPipesModule } from '../../pipes/target-pipes.module' + +const routes: Routes = [ + { + path: '', + component: BackupHistoryPage, + }, +] + +@NgModule({ + declarations: [BackupHistoryPage, DurationPipe, HasErrorPipe], + imports: [ + CommonModule, + IonicModule, + RouterModule.forChild(routes), + TargetPipesModule, + ], +}) +export class BackupHistoryPageModule {} diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-history/backup-history.page.html b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-history/backup-history.page.html new file mode 100644 index 000000000..2e4801e5e --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-history/backup-history.page.html @@ -0,0 +1,93 @@ + + + + + + Backup History + + + + + + 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 }} + +
+
+
+
+
diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-history/backup-history.page.scss b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-history/backup-history.page.scss new file mode 100644 index 000000000..05b3f2393 --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-history/backup-history.page.scss @@ -0,0 +1,3 @@ +.highlighted { + background-color: var(--ion-color-medium-shade); +} \ No newline at end of file diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-history/backup-history.page.ts b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-history/backup-history.page.ts new file mode 100644 index 000000000..2329b952c --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-history/backup-history.page.ts @@ -0,0 +1,111 @@ +import { Component } from '@angular/core' +import { Pipe, PipeTransform } from '@angular/core' +import { BackupReport, BackupRun } from 'src/app/services/api/api.types' +import { LoadingController, ModalController } from '@ionic/angular' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { ErrorToastService } from '@start9labs/shared' +import { BehaviorSubject } from 'rxjs' +import { BackupReportPage } from 'src/app/modals/backup-report/backup-report.page' + +@Component({ + selector: 'backup-history', + templateUrl: './backup-history.page.html', + styleUrls: ['./backup-history.page.scss'], +}) +export class BackupHistoryPage { + selected: Record = {} + runs: BackupRun[] = [] + loading$ = new BehaviorSubject(true) + + constructor( + private readonly modalCtrl: ModalController, + private readonly loadingCtrl: LoadingController, + private readonly errToast: ErrorToastService, + private readonly api: ApiService, + ) {} + + async ngOnInit() { + try { + this.runs = await this.api.getBackupRuns({}) + } catch (e: any) { + this.errToast.present(e) + } finally { + this.loading$.next(false) + } + } + + get empty() { + return this.count === 0 + } + + get count() { + return Object.keys(this.selected).length + } + + async presentModalReport(run: BackupRun) { + const modal = await this.modalCtrl.create({ + component: BackupReportPage, + componentProps: { + report: run.report, + timestamp: run['completed-at'], + }, + }) + await modal.present() + } + + async toggleChecked(id: string) { + if (this.selected[id]) { + delete this.selected[id] + } else { + this.selected[id] = true + } + } + + async toggleAll(runs: BackupRun[]) { + if (this.empty) { + runs.forEach(r => (this.selected[r.id] = true)) + } else { + this.selected = {} + } + } + + async deleteSelected(): Promise { + const ids = Object.keys(this.selected) + + const loader = await this.loadingCtrl.create({ + message: 'Deleting...', + }) + await loader.present() + + try { + await this.api.deleteBackupRuns({ ids }) + this.selected = {} + this.runs = this.runs.filter(r => !ids.includes(r.id)) + } catch (e: any) { + this.errToast.present(e) + } finally { + loader.dismiss() + } + } +} + +@Pipe({ + name: 'duration', +}) +export class DurationPipe implements PipeTransform { + transform(start: string, finish: string): number { + const diffMs = new Date(finish).valueOf() - new Date(start).valueOf() + return diffMs / 100 + } +} + +@Pipe({ + name: 'hasError', +}) +export class HasErrorPipe implements PipeTransform { + transform(report: BackupReport): boolean { + const osErr = !!report.server.error + const pkgErr = !!Object.values(report.packages).find(pkg => pkg.error) + return osErr || pkgErr + } +} diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/backup-jobs.module.ts b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/backup-jobs.module.ts new file mode 100644 index 000000000..3ef154196 --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/backup-jobs.module.ts @@ -0,0 +1,38 @@ +import { NgModule } from '@angular/core' +import { RouterModule, Routes } from '@angular/router' +import { CommonModule } from '@angular/common' +import { IonicModule } from '@ionic/angular' +import { BackupJobsPage } from './backup-jobs.page' +import { NewJobPage } from './new-job/new-job.page' +import { EditJobPage } from './edit-job/edit-job.page' +import { JobOptionsComponent } from './job-options/job-options.component' +import { ToHumanCronPipe } from './pipes' +import { FormsModule } from '@angular/forms' +import { TargetSelectPageModule } from '../../modals/target-select/target-select.module' +import { TargetPipesModule } from '../../pipes/target-pipes.module' + +const routes: Routes = [ + { + path: '', + component: BackupJobsPage, + }, +] + +@NgModule({ + imports: [ + CommonModule, + IonicModule, + RouterModule.forChild(routes), + FormsModule, + TargetSelectPageModule, + TargetPipesModule, + ], + declarations: [ + BackupJobsPage, + ToHumanCronPipe, + NewJobPage, + EditJobPage, + JobOptionsComponent, + ], +}) +export class BackupJobsPageModule {} diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/backup-jobs.page.html b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/backup-jobs.page.html new file mode 100644 index 000000000..516b0ad7d --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/backup-jobs.page.html @@ -0,0 +1,92 @@ + + + + + + Backup Jobs + + + + + + + +

+ 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 + + + New Job + + + +
+ + + Name + Target + Packages + Schedule + + + + + + + + + + + + + + {{ job.name }} + + +   {{ job.target.name }} + + {{ job['package-ids'].length }} Packages + {{ (job.cron | toHumanCron).message }} + + + + + + + + + + + + + +
+
+
diff --git a/frontend/projects/ui/src/app/pages/server-routes/server-backup/server-backup.page.scss b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/backup-jobs.page.scss similarity index 100% rename from frontend/projects/ui/src/app/pages/server-routes/server-backup/server-backup.page.scss rename to frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/backup-jobs.page.scss diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/backup-jobs.page.ts b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/backup-jobs.page.ts new file mode 100644 index 000000000..a84a3c1b7 --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/backup-jobs.page.ts @@ -0,0 +1,121 @@ +import { Component } from '@angular/core' +import { + AlertController, + LoadingController, + ModalController, +} from '@ionic/angular' +import { BehaviorSubject } from 'rxjs' +import { BackupJob } from 'src/app/services/api/api.types' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { ErrorToastService } from '@start9labs/shared' +import { EditJobPage } from './edit-job/edit-job.page' +import { NewJobPage } from './new-job/new-job.page' + +@Component({ + selector: 'backup-jobs', + templateUrl: './backup-jobs.page.html', + styleUrls: ['./backup-jobs.page.scss'], +}) +export class BackupJobsPage { + readonly docsUrl = + 'https://docs.start9.com/latest/user-manual/backups/backup-jobs' + + jobs: BackupJob[] = [] + + loading$ = new BehaviorSubject(true) + + constructor( + private readonly modalCtrl: ModalController, + private readonly alertCtrl: AlertController, + private readonly loadingCtrl: LoadingController, + private readonly errToast: ErrorToastService, + private readonly api: ApiService, + ) {} + + async ngOnInit() { + try { + this.jobs = await this.api.getBackupJobs({}) + } catch (e: any) { + this.errToast.present(e) + } finally { + this.loading$.next(false) + } + } + + async presentModalCreate() { + const modal = await this.modalCtrl.create({ + presentingElement: await this.modalCtrl.getTop(), + component: NewJobPage, + componentProps: { + count: this.jobs.length + 1, + }, + }) + + modal.onWillDismiss().then(res => { + if (res.data) { + this.jobs.push(res.data) + } + }) + + await modal.present() + } + + async presentModalUpdate(job: BackupJob) { + const modal = await this.modalCtrl.create({ + presentingElement: await this.modalCtrl.getTop(), + component: EditJobPage, + componentProps: { + existingJob: job, + }, + }) + + modal.onWillDismiss().then((res: { data?: BackupJob }) => { + if (res.data) { + const { name, target, cron } = res.data + job.name = name + job.target = target + job.cron = cron + job['package-ids'] = res.data['package-ids'] + } + }) + + await modal.present() + } + + async presentAlertDelete(id: string, index: number) { + const alert = await this.alertCtrl.create({ + header: 'Confirm', + message: 'Delete backup job? This action cannot be undone.', + buttons: [ + { + text: 'Cancel', + role: 'cancel', + }, + { + text: 'Delete', + handler: () => { + this.delete(id, index) + }, + cssClass: 'enter-click', + }, + ], + }) + await alert.present() + } + + private async delete(id: string, i: number): Promise { + const loader = await this.loadingCtrl.create({ + message: 'Deleting...', + }) + await loader.present() + + try { + await this.api.removeBackupTarget({ id }) + this.jobs.splice(i, 1) + } catch (e: any) { + this.errToast.present(e) + } finally { + loader.dismiss() + } + } +} diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/edit-job/edit-job.page.html b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/edit-job/edit-job.page.html new file mode 100644 index 000000000..f3cdbb119 --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/edit-job/edit-job.page.html @@ -0,0 +1,33 @@ + + + Edit Job + + + + + + + + + + + + + + + + + + + Save + + + + diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/edit-job/edit-job.page.scss b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/edit-job/edit-job.page.scss new file mode 100644 index 000000000..5255d7814 --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/edit-job/edit-job.page.scss @@ -0,0 +1,3 @@ +h2 { + font-weight: bold; +} \ No newline at end of file diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/edit-job/edit-job.page.ts b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/edit-job/edit-job.page.ts new file mode 100644 index 000000000..f9a484412 --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/edit-job/edit-job.page.ts @@ -0,0 +1,54 @@ +import { Component, Input } from '@angular/core' +import { BackupJob } from 'src/app/services/api/api.types' +import { LoadingController, ModalController } from '@ionic/angular' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { ErrorToastService } from '@start9labs/shared' +import { BackupJobBuilder } from '../job-options/job-options.component' + +@Component({ + selector: 'edit-job', + templateUrl: './edit-job.page.html', + styleUrls: ['./edit-job.page.scss'], +}) +export class EditJobPage { + @Input() existingJob!: BackupJob + + job = {} as BackupJobBuilder + + saving = false + + constructor( + private readonly modalCtrl: ModalController, + private readonly loadingCtrl: LoadingController, + private readonly api: ApiService, + private readonly errToast: ErrorToastService, + ) {} + + ngOnInit() { + this.job = new BackupJobBuilder(this.existingJob) + } + + async dismiss() { + this.modalCtrl.dismiss() + } + + async save() { + this.saving = true + const loader = await this.loadingCtrl.create({ + message: 'Saving Job', + }) + await loader.present() + + try { + const job = await this.api.updateBackupJob( + this.job.buildUpdate(this.existingJob.id), + ) + this.modalCtrl.dismiss(job) + } catch (e: any) { + this.errToast.present(e) + } finally { + loader.dismiss() + this.saving = false + } + } +} diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/job-options/job-options.component.html b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/job-options/job-options.component.html new file mode 100644 index 000000000..4e453c046 --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/job-options/job-options.component.html @@ -0,0 +1,34 @@ +
+

Job Name

+ + + +
+ + + +

Target

+
+ {{ + job.target.type || 'Select target' + }} +
+ + + +

Packages

+
+ {{ + job['package-ids'].length + ' selected' + }} +
+ +
+

Schedule

+ + + +

+ {{ human.message }} +

+
diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/job-options/job-options.component.scss b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/job-options/job-options.component.scss new file mode 100644 index 000000000..dbb2f1b60 --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/job-options/job-options.component.scss @@ -0,0 +1,9 @@ +h2 { + font-weight: bold; +} + +.input-label { + margin-bottom: 6px; + font-size: medium; + font-weight: bold; +} \ No newline at end of file diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/job-options/job-options.component.ts b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/job-options/job-options.component.ts new file mode 100644 index 000000000..fef1920fb --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/job-options/job-options.component.ts @@ -0,0 +1,91 @@ +import { Component, Input } from '@angular/core' +import { ModalController } from '@ionic/angular' +import { BackupJob, BackupTarget, RR } from 'src/app/services/api/api.types' +import { BackupSelectPage } from '../../../modals/backup-select/backup-select.page' +import { TargetSelectPage } from '../../../modals/target-select/target-select.page' + +@Component({ + selector: 'job-options', + templateUrl: './job-options.component.html', + styleUrls: ['./job-options.component.scss'], +}) +export class JobOptionsComponent { + @Input() job!: BackupJobBuilder + + constructor(private readonly modalCtrl: ModalController) {} + + async presentModalTarget() { + const modal = await this.modalCtrl.create({ + presentingElement: await this.modalCtrl.getTop(), + component: TargetSelectPage, + componentProps: { type: 'create' }, + }) + + modal.onWillDismiss().then(res => { + if (res.data) { + this.job.target = res.data + } + }) + + await modal.present() + } + + async presentModalPackages() { + const modal = await this.modalCtrl.create({ + presentingElement: await this.modalCtrl.getTop(), + component: BackupSelectPage, + componentProps: { + btnText: 'Done', + selectedIds: this.job['package-ids'], + }, + }) + + modal.onWillDismiss().then(res => { + if (res.data) { + this.job['package-ids'] = res.data + } + }) + + await modal.present() + } +} + +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/pages/backups-routes/pages/backup-jobs/new-job/new-job.page.html b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/new-job/new-job.page.html new file mode 100644 index 000000000..f740e44d8 --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/new-job/new-job.page.html @@ -0,0 +1,40 @@ + + + Create New Job + + + + + + + + + + + + + + +

Also Execute Now

+
+ +
+
+
+ + + + + + Save Job + + + + diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/new-job/new-job.page.scss b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/new-job/new-job.page.scss new file mode 100644 index 000000000..5255d7814 --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/new-job/new-job.page.scss @@ -0,0 +1,3 @@ +h2 { + font-weight: bold; +} \ No newline at end of file diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/new-job/new-job.page.ts b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/new-job/new-job.page.ts new file mode 100644 index 000000000..e87a3af85 --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/new-job/new-job.page.ts @@ -0,0 +1,54 @@ +import { Component, Input } from '@angular/core' +import { LoadingController, ModalController } from '@ionic/angular' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { ErrorToastService } from '@start9labs/shared' +import { BackupJobBuilder } from '../job-options/job-options.component' + +@Component({ + selector: 'new-job', + templateUrl: './new-job.page.html', + styleUrls: ['./new-job.page.scss'], +}) +export class NewJobPage { + @Input() count!: number + + readonly docsUrl = + 'https://docs.start9.com/latest/user-manual/backups/backup-jobs' + + job = {} as BackupJobBuilder + + saving = false + + constructor( + private readonly modalCtrl: ModalController, + private readonly loadingCtrl: LoadingController, + private readonly api: ApiService, + private readonly errToast: ErrorToastService, + ) {} + + ngOnInit() { + this.job = new BackupJobBuilder({ name: `Backup Job ${this.count}` }) + } + + async dismiss() { + this.modalCtrl.dismiss() + } + + async save() { + const loader = await this.loadingCtrl.create({ + message: 'Saving Job', + }) + await loader.present() + this.saving = true + + try { + const job = await this.api.createBackupJob(this.job.buildCreate()) + this.modalCtrl.dismiss(job) + } catch (e: any) { + this.errToast.present(e) + } finally { + loader.dismiss() + this.saving = false + } + } +} diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/pipes.ts b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/pipes.ts new file mode 100644 index 000000000..dc7afc3fb --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-jobs/pipes.ts @@ -0,0 +1,34 @@ +import { Pipe, PipeTransform } from '@angular/core' +import cronstrue from 'cronstrue' + +@Pipe({ + name: 'toHumanCron', +}) +export class ToHumanCronPipe implements PipeTransform { + transform(cron: string): { message: string; color: string } { + const toReturn = { + message: '', + color: 'success', + } + + 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 = 'danger' + } + + return toReturn + } +} diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-targets/backup-targets.module.ts b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-targets/backup-targets.module.ts new file mode 100644 index 000000000..eef76b9eb --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-targets/backup-targets.module.ts @@ -0,0 +1,26 @@ +import { NgModule } from '@angular/core' +import { RouterModule, Routes } from '@angular/router' +import { CommonModule } from '@angular/common' +import { IonicModule } from '@ionic/angular' +import { BackupTargetsPage } from './backup-targets.page' +import { SkeletonListComponentModule } from 'src/app/components/skeleton-list/skeleton-list.component.module' +import { UnitConversionPipesModule } from '@start9labs/shared' + +const routes: Routes = [ + { + path: '', + component: BackupTargetsPage, + }, +] + +@NgModule({ + declarations: [BackupTargetsPage], + imports: [ + CommonModule, + IonicModule, + RouterModule.forChild(routes), + SkeletonListComponentModule, + UnitConversionPipesModule, + ], +}) +export class BackupTargetsPageModule {} diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-targets/backup-targets.page.html b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-targets/backup-targets.page.html new file mode 100644 index 000000000..c8819ed62 --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-targets/backup-targets.page.html @@ -0,0 +1,166 @@ + + + + + + Backup Targets + + + + + + + +

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

+ To add a new physical backup target, connect the drive and click + refresh. +

+
+
+
+ + + + Saved Targets + + + Add Target + + + +
+ + + Name + Type + Available + Path + + + + + + + + + + + + + + {{ target.name }} + + +   {{ target.type | titlecase }} + + + + + {{ target.path }} + + + + + + + + + + + + +

No saved backup targets.

+
+
+
+
+
diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-targets/backup-targets.page.scss b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-targets/backup-targets.page.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-targets/backup-targets.page.ts b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-targets/backup-targets.page.ts new file mode 100644 index 000000000..3b0301ede --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pages/backup-targets/backup-targets.page.ts @@ -0,0 +1,250 @@ +import { Component } from '@angular/core' +import { + BackupTarget, + BackupTargetType, + DiskBackupTarget, + RR, + UnknownDisk, +} from 'src/app/services/api/api.types' +import { + AlertController, + LoadingController, + ModalController, +} from '@ionic/angular' +import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { ErrorToastService } from '@start9labs/shared' +import { + CifsSpec, + DropboxSpec, + GoogleDriveSpec, + DiskBackupTargetSpec, + RemoteBackupTargetSpec, +} from '../../types/target-types' +import { BehaviorSubject } from 'rxjs' + +export type BackupType = 'create' | 'restore' + +@Component({ + selector: 'backup-targets', + templateUrl: './backup-targets.page.html', + styleUrls: ['./backup-targets.page.scss'], +}) +export class BackupTargetsPage { + readonly docsUrl = + 'https://docs.start9.com/latest/user-manual/backups/backup-targets' + targets: RR.GetBackupTargetsRes = { + 'unknown-disks': [], + saved: [], + } + + loading$ = new BehaviorSubject(true) + + constructor( + private readonly modalCtrl: ModalController, + private readonly alertCtrl: AlertController, + private readonly loadingCtrl: LoadingController, + private readonly errToast: ErrorToastService, + private readonly api: ApiService, + ) {} + + ngOnInit() { + this.getTargets() + } + + async presentModalAddPhysical( + disk: UnknownDisk, + index: number, + ): Promise { + const modal = await this.modalCtrl.create({ + component: GenericFormPage, + componentProps: { + title: 'New Physical Target', + spec: DiskBackupTargetSpec, + initialValue: { + name: disk.label || disk.logicalname, + }, + buttons: [ + { + text: 'Save', + handler: (value: Omit) => + this.add('disk', { + logicalname: disk.logicalname, + ...value, + }).then(disk => { + this.targets['unknown-disks'].splice(index, 1) + this.targets.saved.push(disk) + }), + isSubmit: true, + }, + ], + }, + }) + + await modal.present() + } + + async presentModalAddRemote(): Promise { + const modal = await this.modalCtrl.create({ + component: GenericFormPage, + componentProps: { + title: 'New Remote Target', + spec: RemoteBackupTargetSpec, + buttons: [ + { + text: 'Save', + handler: ( + value: + | (RR.AddCifsBackupTargetReq & { type: BackupTargetType }) + | (RR.AddCloudBackupTargetReq & { type: BackupTargetType }), + ) => this.add(value.type, value), + isSubmit: true, + }, + ], + }, + }) + + await modal.present() + } + + async presentModalUpdate(target: BackupTarget): Promise { + let spec: typeof RemoteBackupTargetSpec = {} + + switch (target.type) { + case 'cifs': + spec = CifsSpec + break + case 'cloud': + spec = target.provider === 'dropbox' ? DropboxSpec : GoogleDriveSpec + break + case 'disk': + spec = DiskBackupTargetSpec + break + } + + const modal = await this.modalCtrl.create({ + component: GenericFormPage, + componentProps: { + title: 'Update Remote Target', + spec, + initialValue: target, + buttons: [ + { + text: 'Save', + handler: ( + value: + | RR.UpdateCifsBackupTargetReq + | RR.UpdateCloudBackupTargetReq + | RR.UpdateDiskBackupTargetReq, + ) => this.update(target.type, value), + isSubmit: true, + }, + ], + }, + }) + await modal.present() + } + + async presentAlertDelete(id: string, index: number) { + const alert = await this.alertCtrl.create({ + header: 'Confirm', + message: 'Forget backup target? This actions cannot be undone.', + buttons: [ + { + text: 'Cancel', + role: 'cancel', + }, + { + text: 'Delete', + handler: () => { + this.delete(id, index) + }, + cssClass: 'enter-click', + }, + ], + }) + await alert.present() + } + + async delete(id: string, index: number): Promise { + const loader = await this.loadingCtrl.create({ + message: 'Removing...', + }) + await loader.present() + + try { + await this.api.removeBackupTarget({ id }) + this.targets.saved.splice(index, 1) + } catch (e: any) { + this.errToast.present(e) + } finally { + loader.dismiss() + } + } + + async refresh() { + this.loading$.next(true) + await this.getTargets() + } + + getIcon(type: BackupTargetType) { + switch (type) { + case 'disk': + return 'save-outline' + case 'cifs': + return 'folder-open-outline' + case 'cloud': + return 'cloud-outline' + } + } + + private async getTargets(): Promise { + try { + this.targets = await this.api.getBackupTargets({}) + } catch (e: any) { + this.errToast.present(e) + } finally { + this.loading$.next(false) + } + } + + private async add( + type: BackupTargetType, + value: + | RR.AddCifsBackupTargetReq + | RR.AddCloudBackupTargetReq + | RR.AddDiskBackupTargetReq, + ): Promise { + const loader = await this.loadingCtrl.create({ + message: 'Saving target...', + }) + await loader.present() + + try { + const res = await this.api.addBackupTarget(type, value) + return res + } finally { + loader.dismiss() + } + } + + private async update( + type: BackupTargetType, + value: + | RR.UpdateCifsBackupTargetReq + | RR.UpdateCloudBackupTargetReq + | RR.UpdateDiskBackupTargetReq, + ): Promise { + const loader = await this.loadingCtrl.create({ + message: 'Saving target...', + }) + await loader.present() + + try { + const res = await this.api.updateBackupTarget(type, value) + return res + } finally { + loader.dismiss() + } + } +} diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pages/backups/backups.module.ts b/frontend/projects/ui/src/app/pages/backups-routes/pages/backups/backups.module.ts new file mode 100644 index 000000000..8726083c4 --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pages/backups/backups.module.ts @@ -0,0 +1,44 @@ +import { NgModule } from '@angular/core' +import { RouterModule, Routes } from '@angular/router' +import { CommonModule } from '@angular/common' +import { IonicModule } from '@ionic/angular' +import { BackupsPage } from './backups.page' +import { BackupCreateDirective } from '../../directives/backup-create.directive' +import { BackupRestoreDirective } from '../../directives/backup-restore.directive' +import { + BackingUpComponent, + PkgMainStatusPipe, +} from '../../components/backing-up/backing-up.component' +import { BackupSelectPageModule } from '../../modals/backup-select/backup-select.module' +import { RecoverSelectPageModule } from '../../modals/recover-select/recover-select.module' +import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module' +import { InsecureWarningComponentModule } from 'src/app/components/insecure-warning/insecure-warning.module' +import { TargetPipesModule } from '../../pipes/target-pipes.module' + +const routes: Routes = [ + { + path: '', + component: BackupsPage, + }, +] + +@NgModule({ + imports: [ + CommonModule, + IonicModule, + RouterModule.forChild(routes), + BackupSelectPageModule, + RecoverSelectPageModule, + BadgeMenuComponentModule, + InsecureWarningComponentModule, + TargetPipesModule, + ], + declarations: [ + BackupsPage, + BackupCreateDirective, + BackupRestoreDirective, + BackingUpComponent, + PkgMainStatusPipe, + ], +}) +export class BackupsPageModule {} diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pages/backups/backups.page.html b/frontend/projects/ui/src/app/pages/backups-routes/pages/backups/backups.page.html new file mode 100644 index 000000000..4dca2133c --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pages/backups/backups.page.html @@ -0,0 +1,112 @@ + + + Backups + + + + + + + + + + + Options + + + + +

Create a Backup

+

Create a one-time backup

+
+
+ + + +

Restore From Backup

+

Restore services from backup

+
+
+ + + +

Jobs

+

Manage backup jobs

+
+
+ + + +

Targets

+

Manage backup targets

+
+
+ + + +

History

+

View your entire backup history

+
+
+ + Upcoming Jobs + +
+ + + Scheduled + Job + Target + Packages + + + + + + + + Running + + + {{ upcoming.next | date : 'MMM d, y, h:mm a' }} + + + {{ upcoming.name }} + + +   {{ upcoming.target.name }} + + + {{ upcoming['package-ids'].length }} Packages + + + +

+ You have no active or upcoming backup jobs. +

+
+
+ + + + + + + + +
+
+
+
diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pages/backups/backups.page.scss b/frontend/projects/ui/src/app/pages/backups-routes/pages/backups/backups.page.scss new file mode 100644 index 000000000..e69de29bb diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pages/backups/backups.page.ts b/frontend/projects/ui/src/app/pages/backups-routes/pages/backups/backups.page.ts new file mode 100644 index 000000000..4b8f2978b --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pages/backups/backups.page.ts @@ -0,0 +1,42 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core' +import { PatchDB } from 'patch-db-client' +import { from, map } from 'rxjs' +import { ApiService } from 'src/app/services/api/embassy-api.service' +import { ConfigService } from 'src/app/services/config.service' +import { DataModel } from 'src/app/services/patch-db/data-model' +import { CronJob } from 'cron' + +@Component({ + selector: 'backups', + templateUrl: './backups.page.html', + styleUrls: ['./backups.page.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class BackupsPage { + readonly secure = this.config.isSecure() + readonly current$ = this.patch + .watch$('server-info', 'status-info', 'current-backup', 'job') + .pipe(map(job => job || {})) + readonly upcoming$ = from(this.api.getBackupJobs({})).pipe( + map(jobs => + jobs + .map(job => { + const nextDate = new CronJob(job.cron, () => {}).nextDate() + const next = nextDate.toISO() + const diff = nextDate.diffNow().milliseconds + return { + ...job, + next, + diff, + } + }) + .sort((a, b) => a.diff - b.diff), + ), + ) + + constructor( + private readonly patch: PatchDB, + private readonly config: ConfigService, + private readonly api: ApiService, + ) {} +} diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pipes/get-display-info.pipe.ts b/frontend/projects/ui/src/app/pages/backups-routes/pipes/get-display-info.pipe.ts new file mode 100644 index 000000000..fe5a927bf --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pipes/get-display-info.pipe.ts @@ -0,0 +1,42 @@ +import { Pipe, PipeTransform } from '@angular/core' +import { BackupTarget } from 'src/app/services/api/api.types' + +@Pipe({ + name: 'getDisplayInfo', +}) +export class GetDisplayInfoPipe implements PipeTransform { + transform(target: BackupTarget): DisplayInfo { + const toReturn: DisplayInfo = { + name: target.name, + path: `Path: ${target.path}`, + description: '', + icon: '', + } + + switch (target.type) { + case 'cifs': + toReturn.description = `Network Folder: ${target.hostname}` + toReturn.icon = 'folder-open-outline' + break + case 'disk': + toReturn.description = `Physical Drive: ${ + target.vendor || 'Unknown Vendor' + }, ${target.model || 'Unknown Model'}` + toReturn.icon = 'save-outline' + break + case 'cloud': + toReturn.description = `Provider: ${target.provider}` + toReturn.icon = 'cloud-outline' + break + } + + return toReturn + } +} + +interface DisplayInfo { + name: string + path: string + description: string + icon: string +} diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pipes/has-valid-backup.pipe.ts b/frontend/projects/ui/src/app/pages/backups-routes/pipes/has-valid-backup.pipe.ts new file mode 100644 index 000000000..ff1b6237d --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pipes/has-valid-backup.pipe.ts @@ -0,0 +1,15 @@ +import { Pipe, PipeTransform } from '@angular/core' +import { BackupTarget } from 'src/app/services/api/api.types' +import { Emver } from '@start9labs/shared' + +@Pipe({ + name: 'hasValidBackup', +}) +export class HasValidBackupPipe implements PipeTransform { + constructor(private readonly emver: Emver) {} + + transform(target: BackupTarget): boolean { + const backup = target['embassy-os'] + return !!backup && this.emver.compare(backup.version, '0.3.0') !== -1 + } +} diff --git a/frontend/projects/ui/src/app/pages/backups-routes/pipes/target-pipes.module.ts b/frontend/projects/ui/src/app/pages/backups-routes/pipes/target-pipes.module.ts new file mode 100644 index 000000000..111ad834e --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/pipes/target-pipes.module.ts @@ -0,0 +1,11 @@ +import { NgModule } from '@angular/core' +import { CommonModule } from '@angular/common' +import { HasValidBackupPipe } from './has-valid-backup.pipe' +import { GetDisplayInfoPipe } from './get-display-info.pipe' + +@NgModule({ + declarations: [HasValidBackupPipe, GetDisplayInfoPipe], + imports: [CommonModule], + exports: [HasValidBackupPipe, GetDisplayInfoPipe], +}) +export class TargetPipesModule {} diff --git a/frontend/projects/ui/src/app/pages/backups-routes/types/target-types.ts b/frontend/projects/ui/src/app/pages/backups-routes/types/target-types.ts new file mode 100644 index 000000000..704d0a080 --- /dev/null +++ b/frontend/projects/ui/src/app/pages/backups-routes/types/target-types.ts @@ -0,0 +1,221 @@ +import { InputSpec } from 'start-sdk/lib/config/configTypes' + +export const DropboxSpec: InputSpec = { + name: { + type: 'text', + inputmode: 'text', + minLength: null, + maxLength: null, + patterns: [], + name: 'Name', + description: 'A friendly name for this Dropbox target', + placeholder: 'My Dropbox', + required: true, + masked: false, + warning: null, + default: null, + }, + token: { + type: 'text', + inputmode: 'text', + minLength: null, + maxLength: null, + patterns: [], + name: 'Access Token', + description: 'The secret access token for your custom Dropbox app', + warning: null, + placeholder: null, + required: true, + masked: true, + default: null, + }, + path: { + type: 'text', + inputmode: 'text', + minLength: null, + maxLength: null, + patterns: [], + name: 'Path', + description: 'The fully qualified path to the backup directory', + warning: null, + placeholder: 'e.g. /Desktop/my-folder', + required: true, + masked: false, + default: null, + }, +} + +export const GoogleDriveSpec: InputSpec = { + name: { + type: 'text', + inputmode: 'text', + minLength: null, + maxLength: null, + patterns: [], + name: 'Name', + description: 'A friendly name for this Google Drive target', + warning: null, + placeholder: 'My Google Drive', + required: true, + masked: false, + default: null, + }, + key: { + type: 'file', + name: 'Private Key File', + description: + 'Your Google Drive service account private key file (.json file)', + warning: null, + required: true, + extensions: ['json'], + }, + path: { + type: 'text', + inputmode: 'text', + minLength: null, + maxLength: null, + patterns: [], + name: 'Path', + description: 'The fully qualified path to the backup directory', + placeholder: 'e.g. /Desktop/my-folder', + required: true, + masked: false, + warning: null, + default: null, + }, +} + +export const CifsSpec: InputSpec = { + name: { + type: 'text', + inputmode: 'text', + minLength: null, + maxLength: null, + patterns: [], + name: 'Name', + description: 'A friendly name for this Network Folder', + warning: null, + placeholder: 'My Network Folder', + required: true, + masked: false, + default: null, + }, + hostname: { + type: 'text', + inputmode: 'text', + minLength: null, + maxLength: null, + patterns: [ + { + regex: '^[a-zA-Z0-9._-]+( [a-zA-Z0-9]+)*$', + description: `Must be a valid hostname. e.g. 'My Computer' OR 'my-computer.local'`, + }, + ], + name: 'Hostname', + description: + 'The hostname of your target device on the Local Area Network.', + warning: null, + required: true, + masked: false, + placeholder: `e.g. 'My Computer' OR 'my-computer.local'`, + default: null, + }, + path: { + type: 'text', + inputmode: 'text', + minLength: null, + maxLength: null, + patterns: [], + name: 'Path', + description: `On Windows, this is the fully qualified path to the shared folder, (e.g. /Desktop/my-folder).\n\n On Linux and Mac, this is the literal name of the shared folder (e.g. my-shared-folder).`, + placeholder: 'e.g. my-shared-folder or /Desktop/my-folder', + required: true, + masked: false, + warning: null, + default: null, + }, + username: { + type: 'text', + inputmode: 'text', + minLength: null, + maxLength: null, + patterns: [], + name: 'Username', + description: `On Linux, this is the samba username you created when sharing the folder.\n\n On Mac and Windows, this is the username of the user who is sharing the folder.`, + required: true, + masked: false, + warning: null, + placeholder: 'My Network Folder', + default: null, + }, + password: { + type: 'text', + inputmode: 'text', + minLength: null, + maxLength: null, + patterns: [], + name: 'Password', + description: `On Linux, this is the samba password you created when sharing the folder.\n\n On Mac and Windows, this is the password of the user who is sharing the folder.`, + required: false, + masked: true, + warning: null, + placeholder: 'My Network Folder', + default: null, + }, +} + +export const RemoteBackupTargetSpec: InputSpec = { + type: { + type: 'union', + name: 'Target Type', + description: null, + warning: null, + required: true, + variants: { + dropbox: { + name: 'Dropbox', + spec: DropboxSpec, + }, + 'google-drive': { + name: 'Google Drive', + spec: GoogleDriveSpec, + }, + cifs: { + name: 'Network Folder', + spec: CifsSpec, + }, + }, + default: 'dropbox', + }, +} + +export const DiskBackupTargetSpec: InputSpec = { + name: { + type: 'text', + inputmode: 'text', + minLength: null, + maxLength: null, + patterns: [], + name: 'Name', + description: 'A friendly name for this physical target', + placeholder: 'My Physical Target', + required: true, + masked: false, + warning: null, + default: null, + }, + path: { + type: 'text', + inputmode: 'text', + minLength: null, + maxLength: null, + patterns: [], + name: 'Path', + description: 'The fully qualified path to the backup directory', + placeholder: 'e.g. /Backups/my-folder', + required: true, + masked: false, + warning: null, + default: null, + }, +} diff --git a/frontend/projects/ui/src/app/pages/developer-routes/developer-menu/developer-menu.module.ts b/frontend/projects/ui/src/app/pages/developer-routes/developer-menu/developer-menu.module.ts index d33ecf47f..286ab8178 100644 --- a/frontend/projects/ui/src/app/pages/developer-routes/developer-menu/developer-menu.module.ts +++ b/frontend/projects/ui/src/app/pages/developer-routes/developer-menu/developer-menu.module.ts @@ -5,7 +5,6 @@ import { RouterModule, Routes } from '@angular/router' import { DeveloperMenuPage } from './developer-menu.page' import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module' import { BackupReportPageModule } from 'src/app/modals/backup-report/backup-report.module' -import { GenericFormPageModule } from 'src/app/modals/generic-form/generic-form.module' import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor' import { FormsModule } from '@angular/forms' import { SharedPipesModule } from '@start9labs/shared' @@ -24,7 +23,6 @@ const routes: Routes = [ RouterModule.forChild(routes), BadgeMenuComponentModule, BackupReportPageModule, - GenericFormPageModule, FormsModule, MonacoEditorModule, SharedPipesModule, diff --git a/frontend/projects/ui/src/app/pages/notifications/notifications.page.ts b/frontend/projects/ui/src/app/pages/notifications/notifications.page.ts index 71bf50370..35c86048a 100644 --- a/frontend/projects/ui/src/app/pages/notifications/notifications.page.ts +++ b/frontend/projects/ui/src/app/pages/notifications/notifications.page.ts @@ -91,7 +91,6 @@ export class NotificationsPage { async presentAlertDeleteAll() { const alert = await this.alertCtrl.create({ - backdropDismiss: false, header: 'Delete All?', message: 'Are you sure you want to delete all notifications?', buttons: [ diff --git a/frontend/projects/ui/src/app/pages/server-routes/restore/restore.component.html b/frontend/projects/ui/src/app/pages/server-routes/restore/restore.component.html deleted file mode 100644 index 7440c9a77..000000000 --- a/frontend/projects/ui/src/app/pages/server-routes/restore/restore.component.html +++ /dev/null @@ -1,5 +0,0 @@ - diff --git a/frontend/projects/ui/src/app/pages/server-routes/restore/restore.component.module.ts b/frontend/projects/ui/src/app/pages/server-routes/restore/restore.component.module.ts deleted file mode 100644 index 8cb4f6916..000000000 --- a/frontend/projects/ui/src/app/pages/server-routes/restore/restore.component.module.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { NgModule } from '@angular/core' -import { RouterModule, Routes } from '@angular/router' -import { CommonModule } from '@angular/common' -import { IonicModule } from '@ionic/angular' -import { RestorePage } from './restore.component' -import { SharedPipesModule } from '@start9labs/shared' -import { BackupDrivesComponentModule } from 'src/app/components/backup-drives/backup-drives.component.module' -import { AppRecoverSelectPageModule } from 'src/app/modals/app-recover-select/app-recover-select.module' - -const routes: Routes = [ - { - path: '', - component: RestorePage, - }, -] - -@NgModule({ - imports: [ - CommonModule, - IonicModule, - RouterModule.forChild(routes), - SharedPipesModule, - BackupDrivesComponentModule, - AppRecoverSelectPageModule, - ], - declarations: [RestorePage], -}) -export class RestorePageModule {} diff --git a/frontend/projects/ui/src/app/pages/server-routes/server-backup/server-backup.module.ts b/frontend/projects/ui/src/app/pages/server-routes/server-backup/server-backup.module.ts deleted file mode 100644 index 6a1782985..000000000 --- a/frontend/projects/ui/src/app/pages/server-routes/server-backup/server-backup.module.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { NgModule } from '@angular/core' -import { CommonModule } from '@angular/common' -import { IonicModule } from '@ionic/angular' -import { ServerBackupPage } from './server-backup.page' -import { BackingUpComponent } from './backing-up/backing-up.component' -import { RouterModule, Routes } from '@angular/router' -import { BackupDrivesComponentModule } from 'src/app/components/backup-drives/backup-drives.component.module' -import { SharedPipesModule } from '@start9labs/shared' -import { BackupSelectPageModule } from 'src/app/modals/backup-select/backup-select.module' -import { PkgMainStatusPipe } from './backing-up/backing-up.component' - -const routes: Routes = [ - { - path: '', - component: ServerBackupPage, - }, -] - -@NgModule({ - imports: [ - CommonModule, - IonicModule, - RouterModule.forChild(routes), - SharedPipesModule, - BackupDrivesComponentModule, - BackupSelectPageModule, - ], - declarations: [ServerBackupPage, BackingUpComponent, PkgMainStatusPipe], -}) -export class ServerBackupPageModule {} diff --git a/frontend/projects/ui/src/app/pages/server-routes/server-backup/server-backup.page.html b/frontend/projects/ui/src/app/pages/server-routes/server-backup/server-backup.page.html deleted file mode 100644 index 16653f3da..000000000 --- a/frontend/projects/ui/src/app/pages/server-routes/server-backup/server-backup.page.html +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - diff --git a/frontend/projects/ui/src/app/pages/server-routes/server-backup/server-backup.page.ts b/frontend/projects/ui/src/app/pages/server-routes/server-backup/server-backup.page.ts deleted file mode 100644 index 2d4d3901e..000000000 --- a/frontend/projects/ui/src/app/pages/server-routes/server-backup/server-backup.page.ts +++ /dev/null @@ -1,173 +0,0 @@ -import { Component } from '@angular/core' -import { - LoadingController, - ModalController, - NavController, -} from '@ionic/angular' -import { ApiService } from 'src/app/services/api/embassy-api.service' -import { - GenericInputComponent, - GenericInputOptions, -} from 'src/app/modals/generic-input/generic-input.component' -import { PatchDB } from 'patch-db-client' -import { skip, takeUntil } from 'rxjs/operators' -import { MappedBackupTarget } from 'src/app/types/mapped-backup-target' -import * as argon2 from '@start9labs/argon2' -import { TuiDestroyService } from '@taiga-ui/cdk' -import { - CifsBackupTarget, - DiskBackupTarget, -} from 'src/app/services/api/api.types' -import { BackupSelectPage } from 'src/app/modals/backup-select/backup-select.page' -import { EOSService } from 'src/app/services/eos.service' -import { getServerInfo } from 'src/app/util/get-server-info' -import { DataModel } from 'src/app/services/patch-db/data-model' - -@Component({ - selector: 'server-backup', - templateUrl: './server-backup.page.html', - styleUrls: ['./server-backup.page.scss'], - providers: [TuiDestroyService], -}) -export class ServerBackupPage { - serviceIds: string[] = [] - - readonly backingUp$ = this.eosService.backingUp$ - - constructor( - private readonly loadingCtrl: LoadingController, - private readonly modalCtrl: ModalController, - private readonly embassyApi: ApiService, - private readonly navCtrl: NavController, - private readonly destroy$: TuiDestroyService, - private readonly eosService: EOSService, - private readonly patch: PatchDB, - ) {} - - ngOnInit() { - this.backingUp$ - .pipe(skip(1), takeUntil(this.destroy$)) - .subscribe(isBackingUp => { - if (!isBackingUp) { - this.navCtrl.navigateRoot('/system') - } - }) - } - - async presentModalSelect( - target: MappedBackupTarget, - ) { - const modal = await this.modalCtrl.create({ - presentingElement: await this.modalCtrl.getTop(), - component: BackupSelectPage, - }) - - modal.onWillDismiss().then(res => { - if (res.data) { - this.serviceIds = res.data - this.presentModalPassword(target) - } - }) - - await modal.present() - } - - private async presentModalPassword( - target: MappedBackupTarget, - ): Promise { - const options: GenericInputOptions = { - title: 'Master Password Needed', - message: 'Enter your master password to encrypt this backup.', - label: 'Master Password', - placeholder: 'Enter master password', - useMask: true, - buttonText: 'Create Backup', - submitFn: async (password: string) => { - // confirm password matches current master password - const { 'password-hash': passwordHash } = await getServerInfo( - this.patch, - ) - argon2.verify(passwordHash, password) - - // first time backup - if (!target.hasValidBackup) { - await this.createBackup(target, password) - // existing backup - } else { - try { - const passwordHash = - target.entry['embassy-os']?.['password-hash'] || '' - - argon2.verify(passwordHash, password) - } catch { - setTimeout( - () => this.presentModalOldPassword(target, password), - 500, - ) - return - } - await this.createBackup(target, password) - } - }, - } - - const m = await this.modalCtrl.create({ - component: GenericInputComponent, - componentProps: { options }, - cssClass: 'alertlike-modal', - }) - - await m.present() - } - - private async presentModalOldPassword( - target: MappedBackupTarget, - password: string, - ): Promise { - const options: GenericInputOptions = { - title: 'Original Password Needed', - message: - 'This backup was created with a different password. Enter the ORIGINAL password that was used to encrypt this backup.', - label: 'Original Password', - placeholder: 'Enter original password', - useMask: true, - buttonText: 'Create Backup', - submitFn: async (oldPassword: string) => { - const passwordHash = target.entry['embassy-os']?.['password-hash'] || '' - - argon2.verify(passwordHash, oldPassword) - await this.createBackup(target, password, oldPassword) - }, - } - - const m = await this.modalCtrl.create({ - component: GenericInputComponent, - componentProps: { options }, - cssClass: 'alertlike-modal', - }) - - await m.present() - } - - private async createBackup( - target: MappedBackupTarget, - password: string, - oldPassword?: string, - ): Promise { - const loader = await this.loadingCtrl.create({ - message: 'Beginning backup...', - }) - await loader.present() - - try { - await this.embassyApi.createBackup({ - 'target-id': target.id, - 'package-ids': this.serviceIds, - 'old-password': oldPassword || null, - password, - }) - } finally { - loader.dismiss() - } - } -} diff --git a/frontend/projects/ui/src/app/pages/server-routes/server-routing.module.ts b/frontend/projects/ui/src/app/pages/server-routes/server-routing.module.ts index bd773ddf7..d6b42bfec 100644 --- a/frontend/projects/ui/src/app/pages/server-routes/server-routing.module.ts +++ b/frontend/projects/ui/src/app/pages/server-routes/server-routing.module.ts @@ -9,13 +9,6 @@ const routes: Routes = [ m => m.ServerShowPageModule, ), }, - { - path: 'backup', - loadChildren: () => - import('./server-backup/server-backup.module').then( - m => m.ServerBackupPageModule, - ), - }, { path: 'lan', loadChildren: () => import('./lan/lan.module').then(m => m.LANPageModule), @@ -46,13 +39,6 @@ const routes: Routes = [ m => m.ServerMetricsPageModule, ), }, - { - path: 'restore', - loadChildren: () => - import('./restore/restore.component.module').then( - m => m.RestorePageModule, - ), - }, { path: 'sessions', loadChildren: () => diff --git a/frontend/projects/ui/src/app/pages/server-routes/server-show/server-show.module.ts b/frontend/projects/ui/src/app/pages/server-routes/server-show/server-show.module.ts index 137a13430..03e938b7c 100644 --- a/frontend/projects/ui/src/app/pages/server-routes/server-show/server-show.module.ts +++ b/frontend/projects/ui/src/app/pages/server-routes/server-show/server-show.module.ts @@ -6,6 +6,7 @@ import { ServerShowPage } from './server-show.page' import { FormsModule } from '@angular/forms' import { TextSpinnerComponentModule } from '@start9labs/shared' import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module' +import { InsecureWarningComponentModule } from 'src/app/components/insecure-warning/insecure-warning.module' import { OSUpdatePageModule } from 'src/app/modals/os-update/os-update.page.module' import { BackupColorPipeModule } from 'src/app/pipes/backup-color/backup-color.module' import { ThemeSwitcherModule } from '../theme-switcher/theme-switcher.module' @@ -28,6 +29,7 @@ const routes: Routes = [ OSUpdatePageModule, BackupColorPipeModule, ThemeSwitcherModule, + InsecureWarningComponentModule, ], declarations: [ServerShowPage], }) diff --git a/frontend/projects/ui/src/app/pages/server-routes/server-show/server-show.page.html b/frontend/projects/ui/src/app/pages/server-routes/server-show/server-show.page.html index e236895df..cc0d96c74 100644 --- a/frontend/projects/ui/src/app/pages/server-routes/server-show/server-show.page.html +++ b/frontend/projects/ui/src/app/pages/server-routes/server-show/server-show.page.html @@ -15,22 +15,7 @@ - - - -

You are using unencrypted http

-

- Click the button on the right to switch to https. Your browser may - warn you that the page is insecure. You can safely bypass this - warning. It will go away after you download and trust your server's - certificate -

-
- - Open Https - - -
+
@@ -52,25 +37,6 @@

{{ button.title }}

{{ button.description }}

- -

- - - Last Backup: {{ server['last-backup'] ? (server['last-backup'] | - date: 'medium') : 'never' }} - - - - Backing up - - -

- this.navCtrl.navigateForward(['backup'], { relativeTo: this.route }), - detail: true, - disabled$: of(!this.secure), - }, - { - title: 'Restore From Backup', - description: 'Restore one or more services from backup', - icon: 'color-wand-outline', - action: () => - this.navCtrl.navigateForward(['restore'], { relativeTo: this.route }), - detail: true, - disabled$: combineLatest([ - this.eosService.updatingOrBackingUp$, - of(this.secure), - ]).pipe(map(([updating, secure]) => updating || !secure)), - }, - ], Manage: [ { title: 'Software Update', diff --git a/frontend/projects/ui/src/app/pages/server-routes/sessions/sessions.module.ts b/frontend/projects/ui/src/app/pages/server-routes/sessions/sessions.module.ts index f4f43a1bd..be0905775 100644 --- a/frontend/projects/ui/src/app/pages/server-routes/sessions/sessions.module.ts +++ b/frontend/projects/ui/src/app/pages/server-routes/sessions/sessions.module.ts @@ -2,7 +2,7 @@ import { NgModule } from '@angular/core' import { CommonModule } from '@angular/common' import { IonicModule } from '@ionic/angular' import { RouterModule, Routes } from '@angular/router' -import { SessionsPage } from './sessions.page' +import { PlatformInfoPipe, SessionsPage } from './sessions.page' import { SharedPipesModule } from '@start9labs/shared' const routes: Routes = [ @@ -19,6 +19,6 @@ const routes: Routes = [ RouterModule.forChild(routes), SharedPipesModule, ], - declarations: [SessionsPage], + declarations: [SessionsPage, PlatformInfoPipe], }) export class SessionsPageModule {} diff --git a/frontend/projects/ui/src/app/pages/server-routes/sessions/sessions.page.html b/frontend/projects/ui/src/app/pages/server-routes/sessions/sessions.page.html index 4e291b5fc..631d9aaee 100644 --- a/frontend/projects/ui/src/app/pages/server-routes/sessions/sessions.page.html +++ b/frontend/projects/ui/src/app/pages/server-routes/sessions/sessions.page.html @@ -8,97 +8,117 @@ - - -

- {{ entry }} - - - - - - - - - - - - - + + + Current Session +
+ + + User Agent + Platform + Last Active + + + + + + + + + + + + + {{ currentSession['user-agent'] }} + + + +   {{ info.name }} + + + + {{ currentSession['last-active']| date: 'medium' }} + + + + +
+ + + + Other Sessions + + Terminate Selected + + + +
+ + + +
+ +
+ User Agent +
+ Platform + Last Active +
+ + + + + + + + + + + + +
+ +
+ {{ session['user-agent'] }} +
+ + + +   {{ info.name }} + + + + {{ session['last-active']| date: 'medium' }} + +
+

+ You are not logged in anywhere else +

+
+
- - - - - Current Session - - - -

{{ getPlatformName(currentSession.metadata.platforms) }}

-

- Last Active: {{ currentSession['last-active'] | date : 'medium' }} -

-

{{ currentSession['user-agent'] }}

-
-
- - - Other Sessions - - Terminate all - - -
- - - -

{{ getPlatformName(session.metadata.platforms) }}

-

Last Active: {{ session['last-active'] | date : 'medium' }}

-

{{ session['user-agent'] }}

-
- - Logout - - -
-
- - -

You are not logged in anywhere else

-
-
-
-
diff --git a/frontend/projects/ui/src/app/pages/server-routes/sessions/sessions.page.scss b/frontend/projects/ui/src/app/pages/server-routes/sessions/sessions.page.scss index 68d631bbf..05b3f2393 100644 --- a/frontend/projects/ui/src/app/pages/server-routes/sessions/sessions.page.scss +++ b/frontend/projects/ui/src/app/pages/server-routes/sessions/sessions.page.scss @@ -1,4 +1,3 @@ -.hide { - max-height: 0px !important; - transition: max-height .5s ease-out; +.highlighted { + background-color: var(--ion-color-medium-shade); } \ No newline at end of file diff --git a/frontend/projects/ui/src/app/pages/server-routes/sessions/sessions.page.ts b/frontend/projects/ui/src/app/pages/server-routes/sessions/sessions.page.ts index b06deab5a..6a4a565a5 100644 --- a/frontend/projects/ui/src/app/pages/server-routes/sessions/sessions.page.ts +++ b/frontend/projects/ui/src/app/pages/server-routes/sessions/sessions.page.ts @@ -1,8 +1,10 @@ import { Component } from '@angular/core' -import { AlertController, LoadingController } from '@ionic/angular' +import { Pipe, PipeTransform } from '@angular/core' +import { LoadingController } from '@ionic/angular' import { ErrorToastService } from '@start9labs/shared' import { ApiService } from 'src/app/services/api/embassy-api.service' import { PlatformType, Session } from 'src/app/services/api/api.types' +import { BehaviorSubject } from 'rxjs' @Component({ selector: 'sessions', @@ -10,20 +12,28 @@ import { PlatformType, Session } from 'src/app/services/api/api.types' styleUrls: ['sessions.page.scss'], }) export class SessionsPage { - loading = true currentSession?: Session otherSessions: SessionWithId[] = [] + selected: Record = {} + loading$ = new BehaviorSubject(true) constructor( private readonly loadingCtrl: LoadingController, private readonly errToast: ErrorToastService, - private readonly alertCtrl: AlertController, - private readonly embassyApi: ApiService, + private readonly api: ApiService, ) {} + get empty() { + return this.count === 0 + } + + get count() { + return Object.keys(this.selected).length + } + async ngOnInit() { try { - const sessionInfo = await this.embassyApi.getSessions({}) + const sessionInfo = await this.api.getSessions({}) this.currentSession = sessionInfo.sessions[sessionInfo.current] delete sessionInfo.sessions[sessionInfo.current] this.otherSessions = Object.entries(sessionInfo.sessions) @@ -42,39 +52,37 @@ export class SessionsPage { } catch (e: any) { this.errToast.present(e) } finally { - this.loading = false + this.loading$.next(false) } } - async presentAlertKillAll() { - const alert = await this.alertCtrl.create({ - header: 'Confirm', - message: `Terminate all other web sessions?

Note: you will not be logged out of your current session on this device.`, - buttons: [ - { - text: 'Cancel', - role: 'cancel', - }, - { - text: 'Terminate all', - handler: () => { - this.kill(this.otherSessions.map(s => s.id)) - }, - cssClass: 'enter-click', - }, - ], - }) - await alert.present() + async toggleChecked(id: string) { + if (this.selected[id]) { + delete this.selected[id] + } else { + this.selected[id] = true + } } - async kill(ids: string[]): Promise { + async toggleAll() { + if (this.empty) { + this.otherSessions.forEach(s => (this.selected[s.id] = true)) + } else { + this.selected = {} + } + } + + async kill(): Promise { + const ids = Object.keys(this.selected) + const loader = await this.loadingCtrl.create({ message: `Terminating session${ids.length > 1 ? 's' : ''}...`, }) await loader.present() try { - await this.embassyApi.killSessions({ ids }) + await this.api.killSessions({ ids }) + this.selected = {} this.otherSessions = this.otherSessions.filter(s => !ids.includes(s.id)) } catch (e: any) { this.errToast.present(e) @@ -82,40 +90,40 @@ export class SessionsPage { loader.dismiss() } } - - getPlatformIcon(platforms: PlatformType[]): string { - if (platforms.includes('cli')) { - return 'terminal-outline' - } else if (platforms.includes('desktop')) { - return 'desktop-outline' - } else { - return 'phone-portrait-outline' - } - } - - getPlatformName(platforms: PlatformType[]): string { - if (platforms.includes('cli')) { - return 'CLI' - } else if (platforms.includes('desktop')) { - return 'Desktop/Laptop' - } else if (platforms.includes('android')) { - return 'Android Device' - } else if (platforms.includes('iphone')) { - return 'iPhone' - } else if (platforms.includes('ipad')) { - return 'iPad' - } else if (platforms.includes('ios')) { - return 'iOS Device' - } else { - return 'Unknown Device' - } - } - - asIsOrder(a: any, b: any) { - return 0 - } } interface SessionWithId extends Session { id: string } + +@Pipe({ + name: 'platformInfo', +}) +export class PlatformInfoPipe implements PipeTransform { + transform(platforms: PlatformType[]): { name: string; icon: string } { + const info = { + name: '', + icon: 'phone-portrait-outline', + } + + if (platforms.includes('cli')) { + info.name = 'CLI' + info.icon = 'terminal-outline' + } else if (platforms.includes('desktop')) { + info.name = 'Desktop/Laptop' + info.icon = 'desktop-outline' + } else if (platforms.includes('android')) { + info.name = 'Android Device' + } else if (platforms.includes('iphone')) { + info.name = 'iPhone' + } else if (platforms.includes('ipad')) { + info.name = 'iPad' + } else if (platforms.includes('ios')) { + info.name = 'iOS Device' + } else { + info.name = 'Unknown Device' + } + + return info + } +} diff --git a/frontend/projects/ui/src/app/pages/server-routes/ssh-keys/ssh-keys.page.html b/frontend/projects/ui/src/app/pages/server-routes/ssh-keys/ssh-keys.page.html index 4ac7d5439..ce2c35b62 100644 --- a/frontend/projects/ui/src/app/pages/server-routes/ssh-keys/ssh-keys.page.html +++ b/frontend/projects/ui/src/app/pages/server-routes/ssh-keys/ssh-keys.page.html @@ -22,63 +22,61 @@ - Saved Keys + + Saved Keys + + + Add New Key + + - - - - Add New Key - - - - - - - - - - - - - - - - - - - - - - - - - -

{{ ssh.hostname }}

-

{{ ssh['created-at'] | date: 'medium' }}

-

{{ ssh.alg }} {{ ssh.fingerprint }}

-
- - - Remove - -
-
+
+ + + Hostname + Created At + Algorithm + Fingerprint + + + + + + + + + + + + + + {{ ssh.hostname }} + + {{ ssh['created-at'] | date: 'medium' }} + + {{ ssh.alg }} + {{ ssh.fingerprint }} + + + + + + + + + + +
diff --git a/frontend/projects/ui/src/app/pages/server-routes/ssh-keys/ssh-keys.page.ts b/frontend/projects/ui/src/app/pages/server-routes/ssh-keys/ssh-keys.page.ts index be692277a..6bf457763 100644 --- a/frontend/projects/ui/src/app/pages/server-routes/ssh-keys/ssh-keys.page.ts +++ b/frontend/projects/ui/src/app/pages/server-routes/ssh-keys/ssh-keys.page.ts @@ -11,6 +11,7 @@ import { GenericInputComponent, GenericInputOptions, } from 'src/app/modals/generic-input/generic-input.component' +import { BehaviorSubject } from 'rxjs' @Component({ selector: 'ssh-keys', @@ -18,9 +19,9 @@ import { styleUrls: ['ssh-keys.page.scss'], }) export class SSHKeysPage { - loading = true - sshKeys: SSHKey[] = [] readonly docsUrl = 'https://docs.start9.com/latest/user-manual/ssh' + sshKeys: SSHKey[] = [] + loading$ = new BehaviorSubject(true) constructor( private readonly loadingCtrl: LoadingController, @@ -40,7 +41,7 @@ export class SSHKeysPage { } catch (e: any) { this.errToast.present(e) } finally { - this.loading = false + this.loading$.next(false) } } @@ -75,10 +76,10 @@ export class SSHKeysPage { } } - async presentAlertDelete(i: number) { + async presentAlertDelete(key: SSHKey, i: number) { const alert = await this.alertCtrl.create({ - header: 'Caution', - message: `Are you sure you want to delete this key?`, + header: 'Confirm', + message: 'Delete key? This action cannot be undone.', buttons: [ { text: 'Cancel', @@ -87,7 +88,7 @@ export class SSHKeysPage { { text: 'Delete', handler: () => { - this.delete(i) + this.delete(key, i) }, cssClass: 'enter-click', }, @@ -96,15 +97,14 @@ export class SSHKeysPage { await alert.present() } - async delete(i: number): Promise { + async delete(key: SSHKey, i: number): Promise { const loader = await this.loadingCtrl.create({ message: 'Deleting...', }) await loader.present() try { - const entry = this.sshKeys[i] - await this.embassyApi.deleteSshKey({ fingerprint: entry.fingerprint }) + await this.embassyApi.deleteSshKey({ fingerprint: key.fingerprint }) this.sshKeys.splice(i, 1) } catch (e: any) { this.errToast.present(e) diff --git a/frontend/projects/ui/src/app/services/api/api.fixures.ts b/frontend/projects/ui/src/app/services/api/api.fixures.ts index f57c5dd50..a887061c5 100644 --- a/frontend/projects/ui/src/app/services/api/api.fixures.ts +++ b/frontend/projects/ui/src/app/services/api/api.fixures.ts @@ -17,7 +17,7 @@ import { unionSelectKey } from 'start-sdk/lib/config/configTypes' export module Mock { export const ServerUpdated: ServerStatusInfo = { - 'backup-progress': null, + 'current-backup': null, 'update-progress': null, updated: true, 'shutting-down': false, @@ -505,13 +505,20 @@ export module Mock { export const Sessions: RR.GetSessionsRes = { current: 'b7b1a9cef4284f00af9e9dda6e676177', sessions: { - '9513226517c54ddd8107d6d7b9d8aed7': { + c54ddd8107d6d7b9d8aed7: { 'last-active': '2021-07-14T20:49:17.774Z', 'user-agent': 'AppleWebKit/{WebKit Rev} (KHTML, like Gecko)', metadata: { platforms: ['iphone', 'mobileweb', 'mobile', 'ios'], }, }, + klndsfjhbwsajkdnaksj: { + 'last-active': '2019-07-14T20:49:17.774Z', + 'user-agent': 'AppleWebKit/{WebKit Rev} (KHTML, like Gecko)', + metadata: { + platforms: ['cli'], + }, + }, b7b1a9cef4284f00af9e9dda6e676177: { 'last-active': '2021-06-14T20:49:17.774Z', 'user-agent': @@ -581,58 +588,131 @@ export module Mock { } export const BackupTargets: RR.GetBackupTargetsRes = { - hsbdjhasbasda: { - type: 'cifs', - hostname: 'smb://192.169.10.0', - path: '/Desktop/startos-backups', - username: 'TestUser', - mountable: false, - 'embassy-os': { - version: '0.3.0', - full: true, - 'password-hash': + 'unknown-disks': [ + { + logicalname: 'sbc4', + label: 'My Backup Drive', + capacity: 2000000000000, + used: 100000000000, + model: 'T7', + vendor: 'Samsung', + }, + ], + saved: [ + { + id: 'hsbdjhasbasda', + type: 'cifs', + name: 'Embassy Backups', + hostname: 'smb://192.169.10.0', + path: '/Desktop/embassy-backups', + username: 'TestUser', + mountable: false, + 'embassy-os': { + version: '0.3.0', + full: true, + 'password-hash': + // password is asdfasdf + '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', + 'wrapped-key': '', + }, + }, + { + id: 'ftcvewdnkemfksdm', + type: 'cloud', + name: 'Dropbox 1', + provider: 'dropbox', + path: '/Home/backups', + mountable: true, + 'embassy-os': null, + }, + { + id: 'csgashbdjkasnd', + type: 'cifs', + name: 'Network Folder 2', + hostname: 'smb://192.169.10.0', + path: '/Desktop/embassy-backups-2', + username: 'TestUser', + mountable: true, + 'embassy-os': null, + }, + { + id: 'powjefhjbnwhdva', + type: 'disk', + name: 'Physical Drive 1', + logicalname: 'sdba1', + label: 'Another Drive', + capacity: 2000000000000, + used: 100000000000, + model: null, + vendor: 'SSK', + mountable: true, + path: '/HomeFolder/Documents', + 'embassy-os': { + version: '0.3.0', + full: true, // password is asdfasdf - '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', - 'wrapped-key': '', + 'password-hash': + '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', + 'wrapped-key': '', + }, }, - }, - // 'ftcvewdnkemfksdm': { - // type: 'disk', - // logicalname: 'sdba1', - // label: 'Matt Stuff', - // capacity: 1000000000000, - // used: 0, - // model: 'Evo SATA 2.5', - // vendor: 'Samsung', - // 'embassy-os': null, - // }, - csgashbdjkasnd: { - type: 'cifs', - hostname: 'smb://192.169.10.0', - path: '/Desktop/startos-backups-2', - username: 'TestUser', - mountable: true, - 'embassy-os': null, - }, - powjefhjbnwhdva: { - type: 'disk', - logicalname: 'sdba1', - label: 'Another Drive', - capacity: 2000000000000, - used: 100000000000, - model: null, - vendor: 'SSK', - 'embassy-os': { - version: '0.3.0', - full: true, - // password is asdfasdf - 'password-hash': - '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', - 'wrapped-key': '', - }, - }, + ], } + export const BackupJobs: RR.GetBackupJobsRes = [ + { + id: 'lalalalalala-babababababa', + name: 'My Backup Job', + target: BackupTargets.saved[0], + cron: '0 3 * * *', + 'package-ids': ['bitcoind', 'lnd'], + }, + { + id: 'hahahahaha-mwmwmwmwmwmw', + name: 'Another Backup Job', + target: BackupTargets.saved[1], + cron: '0 * * * *', + 'package-ids': ['lnd'], + }, + ] + + export const BackupRuns: RR.GetBackupRunsRes = [ + { + id: 'kladhbfweubdsk', + 'started-at': new Date().toISOString(), + 'completed-at': new Date(new Date().valueOf() + 10000).toISOString(), + 'package-ids': ['bitcoind', 'lnd'], + job: BackupJobs[0], + report: { + server: { + attempted: true, + error: null, + }, + packages: { + bitcoind: { error: null }, + lnd: { error: null }, + }, + }, + }, + { + id: 'kladhbfwhrfeubdsk', + 'started-at': new Date().toISOString(), + 'completed-at': new Date(new Date().valueOf() + 10000).toISOString(), + 'package-ids': ['bitcoind', 'lnd'], + job: BackupJobs[0], + report: { + server: { + attempted: true, + error: null, + }, + packages: { + bitcoind: { error: null }, + lnd: { error: null }, + }, + }, + }, + ] + export const BackupInfo: RR.GetBackupInfoRes = { version: '0.3.0', timestamp: new Date().toISOString(), diff --git a/frontend/projects/ui/src/app/services/api/api.types.ts b/frontend/projects/ui/src/app/services/api/api.types.ts index 28e781c21..a34484690 100644 --- a/frontend/projects/ui/src/app/services/api/api.types.ts +++ b/frontend/projects/ui/src/app/services/api/api.types.ts @@ -147,33 +147,78 @@ export module RR { // backup export type GetBackupTargetsReq = {} // backup.target.list - export type GetBackupTargetsRes = { [id: string]: BackupTarget } - - export type AddBackupTargetReq = { - // backup.target.cifs.add - hostname: string - path: string - username: string - password: string | null + export type GetBackupTargetsRes = { + 'unknown-disks': UnknownDisk[] + saved: BackupTarget[] } - export type AddBackupTargetRes = { [id: string]: CifsBackupTarget } - export type UpdateBackupTargetReq = AddBackupTargetReq & { id: string } // backup.target.cifs.update + export type AddCifsBackupTargetReq = { + name: string + path: string + hostname: string + username: string + password?: string + } // backup.target.cifs.add + export type AddCloudBackupTargetReq = { + name: string + path: string + provider: CloudProvider + [params: string]: any + } // backup.target.cloud.add + export type AddDiskBackupTargetReq = { + logicalname: string + name: string + path: string + } // backup.target.disk.add + export type AddBackupTargetRes = BackupTarget + + export type UpdateCifsBackupTargetReq = AddCifsBackupTargetReq & { + id: string + } // backup.target.cifs.update + export type UpdateCloudBackupTargetReq = AddCloudBackupTargetReq & { + id: string + } // backup.target.cloud.update + export type UpdateDiskBackupTargetReq = Omit< + AddDiskBackupTargetReq, + 'logicalname' + > & { + id: string + } // backup.target.disk.update export type UpdateBackupTargetRes = AddBackupTargetRes - export type RemoveBackupTargetReq = { id: string } // backup.target.cifs.remove + export type RemoveBackupTargetReq = { id: string } // backup.target.remove export type RemoveBackupTargetRes = null + export type GetBackupJobsReq = {} // backup.job.list + export type GetBackupJobsRes = BackupJob[] + + export type CreateBackupJobReq = { + name: string + 'target-id': string + cron: string + 'package-ids': string[] + now: boolean + } // backup.job.create + export type CreateBackupJobRes = BackupJob + + export type UpdateBackupJobReq = Omit & { + id: string + } // backup.job.update + export type UpdateBackupJobRes = CreateBackupJobRes + + export type DeleteBackupJobReq = { id: string } // backup.job.delete + export type DeleteBackupJobRes = null + + export type GetBackupRunsReq = {} // backup.runs + export type GetBackupRunsRes = BackupRun[] + + export type DeleteBackupRunsReq = { ids: string[] } // backup.runs.delete + export type DeleteBackupRunsRes = null + export type GetBackupInfoReq = { 'target-id': string; password: string } // backup.target.info export type GetBackupInfoRes = BackupInfo - export type CreateBackupReq = { - // backup.create - 'target-id': string - 'package-ids': string[] - 'old-password': string | null - password: string - } + export type CreateBackupReq = { 'target-id': string; 'package-ids': string[] } // backup.create export type CreateBackupRes = null // package @@ -215,7 +260,6 @@ export module RR { // package.backup.restore ids: string[] 'target-id': string - 'old-password': string | null password: string } export type RestorePackagesRes = null @@ -348,41 +392,59 @@ export type PlatformType = | 'desktop' | 'hybrid' -export type BackupTarget = DiskBackupTarget | CifsBackupTarget +export type RemoteBackupTarget = CifsBackupTarget | CloudBackupTarget +export type BackupTarget = RemoteBackupTarget | DiskBackupTarget -export interface DiskBackupTarget { - type: 'disk' +export type BackupTargetType = 'disk' | 'cifs' | 'cloud' + +export interface UnknownDisk { + logicalname: string vendor: string | null model: string | null - logicalname: string | null label: string | null capacity: number used: number | null - 'embassy-os': StartOSDiskInfo | null } -export interface CifsBackupTarget { - type: 'cifs' - hostname: string - path: string - username: string +export interface BaseBackupTarget { + id: string + type: BackupTargetType + name: string mountable: boolean + path: string 'embassy-os': StartOSDiskInfo | null } -export type RecoverySource = DiskRecoverySource | CifsRecoverySource - -export interface DiskRecoverySource { +export interface DiskBackupTarget extends UnknownDisk, BaseBackupTarget { type: 'disk' - logicalname: string // partition logicalname } -export interface CifsRecoverySource { +export interface CifsBackupTarget extends BaseBackupTarget { type: 'cifs' hostname: string - path: string username: string - password: string +} + +export interface CloudBackupTarget extends BaseBackupTarget { + type: 'cloud' + provider: 'dropbox' | 'google-drive' +} + +export interface BackupRun { + id: string + 'started-at': string + 'completed-at': string + 'package-ids': string[] + job: BackupJob + report: BackupReport +} + +export interface BackupJob { + id: string + name: string + target: BackupTarget + cron: string // '* * * * * *' https://cloud.google.com/scheduler/docs/configuring/cron-job-schedules + 'package-ids': string[] } export interface BackupInfo { @@ -473,3 +535,5 @@ declare global { export type Encrypted = { encrypted: string } + +export type CloudProvider = 'dropbox' | 'google-drive' diff --git a/frontend/projects/ui/src/app/services/api/embassy-api.service.ts b/frontend/projects/ui/src/app/services/api/embassy-api.service.ts index 877c442ae..609f8d786 100644 --- a/frontend/projects/ui/src/app/services/api/embassy-api.service.ts +++ b/frontend/projects/ui/src/app/services/api/embassy-api.service.ts @@ -1,6 +1,6 @@ import { BehaviorSubject, Observable } from 'rxjs' import { Update } from 'patch-db-client' -import { Encrypted, RR } from './api.types' +import { RR, Encrypted, BackupTargetType } from './api.types' import { DataModel } from 'src/app/services/patch-db/data-model' import { Log } from '@start9labs/shared' import { WebSocketSubjectConfig } from 'rxjs/webSocket' @@ -175,17 +175,49 @@ export abstract class ApiService { ): Promise abstract addBackupTarget( - params: RR.AddBackupTargetReq, + type: BackupTargetType, + params: + | RR.AddCifsBackupTargetReq + | RR.AddCloudBackupTargetReq + | RR.AddDiskBackupTargetReq, ): Promise abstract updateBackupTarget( - params: RR.UpdateBackupTargetReq, + type: BackupTargetType, + params: + | RR.UpdateCifsBackupTargetReq + | RR.UpdateCloudBackupTargetReq + | RR.UpdateDiskBackupTargetReq, ): Promise abstract removeBackupTarget( params: RR.RemoveBackupTargetReq, ): Promise + abstract getBackupJobs( + params: RR.GetBackupJobsReq, + ): Promise + + abstract createBackupJob( + params: RR.CreateBackupJobReq, + ): Promise + + abstract updateBackupJob( + params: RR.UpdateBackupJobReq, + ): Promise + + abstract deleteBackupJob( + params: RR.DeleteBackupJobReq, + ): Promise + + abstract getBackupRuns( + params: RR.GetBackupRunsReq, + ): Promise + + abstract deleteBackupRuns( + params: RR.DeleteBackupRunsReq, + ): Promise + abstract getBackupInfo( params: RR.GetBackupInfoReq, ): Promise diff --git a/frontend/projects/ui/src/app/services/api/embassy-live-api.service.ts b/frontend/projects/ui/src/app/services/api/embassy-live-api.service.ts index 8d958dc18..fe02111a4 100644 --- a/frontend/projects/ui/src/app/services/api/embassy-live-api.service.ts +++ b/frontend/projects/ui/src/app/services/api/embassy-live-api.service.ts @@ -10,7 +10,7 @@ import { RPCOptions, } from '@start9labs/shared' import { ApiService } from './embassy-api.service' -import { RR } from './api.types' +import { BackupTargetType, RR } from './api.types' import { parsePropertiesPermissive } from 'src/app/util/properties.util' import { ConfigService } from '../config.service' import { webSocket, WebSocketSubjectConfig } from 'rxjs/webSocket' @@ -312,22 +312,60 @@ export class LiveApiService extends ApiService { } async addBackupTarget( - params: RR.AddBackupTargetReq, + type: BackupTargetType, + params: RR.AddCifsBackupTargetReq | RR.AddCloudBackupTargetReq, ): Promise { params.path = params.path.replace('/\\/g', '/') - return this.rpcRequest({ method: 'backup.target.cifs.add', params }) + return this.rpcRequest({ method: `backup.target.${type}.add`, params }) } async updateBackupTarget( - params: RR.UpdateBackupTargetReq, + type: BackupTargetType, + params: RR.UpdateCifsBackupTargetReq | RR.UpdateCloudBackupTargetReq, ): Promise { - return this.rpcRequest({ method: 'backup.target.cifs.update', params }) + return this.rpcRequest({ method: `backup.target.${type}.update`, params }) } async removeBackupTarget( params: RR.RemoveBackupTargetReq, ): Promise { - return this.rpcRequest({ method: 'backup.target.cifs.remove', params }) + return this.rpcRequest({ method: 'backup.target.remove', params }) + } + + async getBackupJobs( + params: RR.GetBackupJobsReq, + ): Promise { + return this.rpcRequest({ method: 'backup.job.list', params }) + } + + async createBackupJob( + params: RR.CreateBackupJobReq, + ): Promise { + return this.rpcRequest({ method: 'backup.job.create', params }) + } + + async updateBackupJob( + params: RR.UpdateBackupJobReq, + ): Promise { + return this.rpcRequest({ method: 'backup.job.update', params }) + } + + async deleteBackupJob( + params: RR.DeleteBackupJobReq, + ): Promise { + return this.rpcRequest({ method: 'backup.job.delete', params }) + } + + async getBackupRuns( + params: RR.GetBackupRunsReq, + ): Promise { + return this.rpcRequest({ method: 'backup.runs.list', params }) + } + + async deleteBackupRuns( + params: RR.DeleteBackupRunsReq, + ): Promise { + return this.rpcRequest({ method: 'backup.runs.delete', params }) } async getBackupInfo( diff --git a/frontend/projects/ui/src/app/services/api/embassy-mock-api.service.ts b/frontend/projects/ui/src/app/services/api/embassy-mock-api.service.ts index 891fcc987..73696bdab 100644 --- a/frontend/projects/ui/src/app/services/api/embassy-mock-api.service.ts +++ b/frontend/projects/ui/src/app/services/api/embassy-mock-api.service.ts @@ -16,7 +16,7 @@ import { PackageMainStatus, PackageState, } from 'src/app/services/patch-db/data-model' -import { CifsBackupTarget, RR } from './api.types' +import { BackupTargetType, CifsBackupTarget, RR } from './api.types' import { parsePropertiesPermissive } from 'src/app/util/properties.util' import { Mock } from './api.fixures' import markdown from 'raw-loader!../../../../../shared/assets/markdown/md-sample.md' @@ -477,35 +477,32 @@ export class MockApiService extends ApiService { } async addBackupTarget( - params: RR.AddBackupTargetReq, + type: BackupTargetType, + params: + | RR.AddCifsBackupTargetReq + | RR.AddCloudBackupTargetReq + | RR.AddDiskBackupTargetReq, ): Promise { await pauseFor(2000) - const { hostname, path, username } = params + const { path, name } = params return { - latfgvwdbhjsndmk: { - type: 'cifs', - hostname, - path: path.replace(/\\/g, '/'), - username, - mountable: true, - 'embassy-os': null, - }, + id: 'latfgvwdbhjsndmk', + name, + type: 'cifs', + hostname: 'mockhotname', + path: path.replace(/\\/g, '/'), + username: 'mockusername', + mountable: true, + 'embassy-os': null, } } async updateBackupTarget( - params: RR.UpdateBackupTargetReq, + type: BackupTargetType, + params: RR.UpdateCifsBackupTargetReq | RR.UpdateCloudBackupTargetReq, ): Promise { await pauseFor(2000) - const { id, hostname, path, username } = params - return { - [id]: { - ...(Mock.BackupTargets[id] as CifsBackupTarget), - hostname, - path, - username, - }, - } + return Mock.BackupTargets.saved.find(b => b.id === params.id)! } async removeBackupTarget( @@ -515,6 +512,57 @@ export class MockApiService extends ApiService { return null } + async getBackupJobs( + params: RR.GetBackupJobsReq, + ): Promise { + await pauseFor(2000) + return Mock.BackupJobs + } + + async createBackupJob( + params: RR.CreateBackupJobReq, + ): Promise { + await pauseFor(2000) + return { + id: 'hjdfbjsahdbn', + name: params.name, + target: Mock.BackupTargets.saved[0], + cron: params.cron, + 'package-ids': params['package-ids'], + } + } + + async updateBackupJob( + params: RR.UpdateBackupJobReq, + ): Promise { + await pauseFor(2000) + return { + ...Mock.BackupJobs[0], + ...params, + } + } + + async deleteBackupJob( + params: RR.DeleteBackupJobReq, + ): Promise { + await pauseFor(2000) + return null + } + + async getBackupRuns( + params: RR.GetBackupRunsReq, + ): Promise { + await pauseFor(2000) + return Mock.BackupRuns + } + + async deleteBackupRuns( + params: RR.DeleteBackupRunsReq, + ): Promise { + await pauseFor(2000) + return null + } + async getBackupInfo( params: RR.GetBackupInfoReq, ): Promise { diff --git a/frontend/projects/ui/src/app/services/api/mock-patch.ts b/frontend/projects/ui/src/app/services/api/mock-patch.ts index 97246ee5d..83018491e 100644 --- a/frontend/projects/ui/src/app/services/api/mock-patch.ts +++ b/frontend/projects/ui/src/app/services/api/mock-patch.ts @@ -59,12 +59,9 @@ export const mockPatchData: DataModel = { 'last-wifi-region': null, 'wifi-enabled': false, 'unread-notification-count': 4, - // password is asdfasdf - 'password-hash': - '$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ', 'eos-version-compat': '>=0.3.0 <=0.3.0.1', 'status-info': { - 'backup-progress': null, + 'current-backup': null, updated: false, 'update-progress': null, 'shutting-down': false, diff --git a/frontend/projects/ui/src/app/services/eos.service.ts b/frontend/projects/ui/src/app/services/eos.service.ts index 396c84223..27cc1739e 100644 --- a/frontend/projects/ui/src/app/services/eos.service.ts +++ b/frontend/projects/ui/src/app/services/eos.service.ts @@ -21,7 +21,7 @@ export class EOSService { ) readonly backingUp$ = this.patch - .watch$('server-info', 'status-info', 'backup-progress') + .watch$('server-info', 'status-info', 'current-backup') .pipe( map(obj => !!obj), distinctUntilChanged(), diff --git a/frontend/projects/ui/src/app/services/patch-db/data-model.ts b/frontend/projects/ui/src/app/services/patch-db/data-model.ts index 598890b0b..e1db7eaad 100644 --- a/frontend/projects/ui/src/app/services/patch-db/data-model.ts +++ b/frontend/projects/ui/src/app/services/patch-db/data-model.ts @@ -2,6 +2,7 @@ import { InputSpec } from 'start-sdk/lib/config/configTypes' import { Url } from '@start9labs/shared' import { Manifest } from '@start9labs/marketplace' import { BasicInfo } from 'src/app/pages/developer-routes/developer-menu/form-info' +import { BackupJob } from '../api/api.types' export interface DataModel { 'server-info': ServerInfo @@ -74,7 +75,6 @@ export interface ServerInfo { 'unread-notification-count': number 'status-info': ServerStatusInfo 'eos-version-compat': string - 'password-hash': string hostname: string pubkey: string 'ca-fingerprint': string @@ -90,9 +90,12 @@ export interface IpInfo { } export interface ServerStatusInfo { - 'backup-progress': null | { - [packageId: string]: { - complete: boolean + 'current-backup': null | { + job: BackupJob + 'backup-progress': { + [packageId: string]: { + complete: boolean + } } } updated: boolean diff --git a/frontend/projects/ui/src/app/types/mapped-backup-target.ts b/frontend/projects/ui/src/app/types/mapped-backup-target.ts deleted file mode 100644 index 13b51d4b5..000000000 --- a/frontend/projects/ui/src/app/types/mapped-backup-target.ts +++ /dev/null @@ -1,5 +0,0 @@ -export interface MappedBackupTarget { - id: string - hasValidBackup: boolean - entry: T -} diff --git a/frontend/projects/ui/src/styles.scss b/frontend/projects/ui/src/styles.scss index 858a2b79d..223a10aa0 100644 --- a/frontend/projects/ui/src/styles.scss +++ b/frontend/projects/ui/src/styles.scss @@ -104,17 +104,6 @@ $subheader-height: 48px; word-break: break-all; } -.input-label { - margin-bottom: 6px; - font-size: medium; - font-weight: bold; - - * { - display: inline-block; - vertical-align: middle; - } -} - .center { display: block; margin: auto; @@ -319,6 +308,35 @@ h2 { animation: flickerAnimation 4s infinite; } +.grid-headings { + font-weight: bold; + background: var(--ion-color-medium-tint); +} + +.grid-row-border { + border-bottom: 1px solid rgb(54, 54, 54); + min-height: 43px; +} + +.grid-fixed { + overflow: auto; + + /* Hide scrollbar for Chrome, Safari and Opera */ + &::-webkit-scrollbar { + width: 0; + height: 0; + } + /* Hide scrollbar for IE, Edge and Firefox */ + /* IE and Edge */ + -ms-overflow-style: none; + /* Firefox */ + scrollbar-width: none; + + ion-grid { + min-width: 840px; + } +} + // TODO: Refactor so header is outside router-outlet ion-content.with-widgets, ion-footer.with-widgets { @@ -367,3 +385,8 @@ tui-hint[data-appearance='onDark'] { color: var(--tui-link-hover) !important; } } + +.checkbox { + cursor: pointer; + margin: 0 12px 6px 0; +}