mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-31 04:23:40 +00:00
Feat/domains
update FE types and unify sideload page with marketplace show begin popover for UI launch select update node version for github workflows fix type errors eager load more components fix mocks for types recalculate updates bad on pkg uninstall chore: break form-object file structure files for config finish file upload API and implement for config chore: break down form-object by type, part 1 remove NEW from config comment entire setTimeout for new generic form options chore: break down form-object by type, part 2 headers for enums and unions implement select and multiselect for config update union types and camel case for specs implement textarea config value inputspec and required instead of nullable remove subtype from list spec update start-sdk bump start-sdk feat: use Taiga UI for config modal (#2250) * feat: use Taiga UI for config modal * chore: finish remaining changes * chore: address comments * bump sdk version --------- Co-authored-by: Matt Hill <matthewonthemoon@gmail.com> update package lock update to sdk 20 and fix types chore: update Taiga UI and migrate some more forms (#2252) update form to latest sdk validate length for textarea too chore: accommodate new changes to the specs (#2254) * chore: accommodate new changes to the specs * chore: fix error * chore: fix error feat: add input color (#2257) * feat: add input color * patterns will always be there --------- Co-authored-by: Matt Hill <matthewonthemoon@gmail.com> chore: properly type pattern error update to latest sdk Add sans-serif font fallback (#2263) * Add sans-serif font fallback * Update frontend readme start scripts feat: add datetime spec support (#2264) Wifi optional (#2249) * begin work * allow enable and disable wifi * nice styling * done except for popover not dismissing * update wifi.ts * address comments 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 Add logs window to setup wizard loading screen (#2076) * add logs window to setup wizard loading screen * fix type error * Update frontend/projects/setup-wizard/src/app/services/api/live-api.service.ts Co-authored-by: Lucy C <12953208+elvece@users.noreply.github.com> --------- Co-authored-by: Lucy C <12953208+elvece@users.noreply.github.com> statically type server metrics and use websocket (#2124) Co-authored-by: Matt Hill <matthewonthemoon@gmail.com> Feat/external-smtp (#1791) * UI for EOS smtp, missing API layer * implement api * fix errors * switch to external smtp creds * fix things up * fix types * update types for new forms * feat: add new form to emails and marketplace (#2268) * import tuilet module * feat: get rid of old form completely (#2270) * move to builder spec and delete developer menu * update sdk * tiny * getting better * working * done * feat: add step to number config * chore: small fixes * update SDK and step for numbers --------- Co-authored-by: Alex Inkin <alexander@inkin.ru> latest sdk, fix build update SDK for better disabled props feat: implement `disabled`, `immutable` and `generate` (#2280) * feat: implement `disabled`, `immutable` and `generate` * chore: remove unnecessary code * chore: add generate to textarea and implement immutable * no generate for textarea --------- Co-authored-by: Matt Hill <matthewonthemoon@gmail.com> update lockfile refactor: extract loading status to shared library (#2282) * refactor: extract loading status to shared library * chore: remove inline style refactor: break routing down to apps level (#2285) closes #2212 and closes #2214 Feat/credentials (#2290) add credentials and remove properties refactor: break ui up further down (#2292) * refactor: break ui up further down * permit loading even when authed --------- Co-authored-by: Matt Hill <matthewonthemoon@gmail.com> update patchdb for package compatability fixes fix file structure WIP finish rebase mvp complete port forwards mvp looking good cleaner system page move experimental features manual port overrides better info headers for jobs pages refactor: move diagnostic-ui app under ui route (#2306) * refactor: move diagnostic-ui app under ui route * chore: hide navigation * chore: remove ionic from diagnostic * fix navbar showing on login --------- Co-authored-by: Matt Hill <matthewonthemoon@gmail.com> chore: partially remove ionic modals and loaders (#2308) * chore: partially remove ionic modals and loaders * change to snake --------- Co-authored-by: Matt Hill <matthewonthemoon@gmail.com> better session data fetching abstract store icon component to shared marketplace project (#2311) * abstract store icon component to shared marketplace project * better than using a pipe * minor cleanup * chore: fix missing node types in libraries * typo --------- Co-authored-by: Matt Hill <matthewonthemoon@gmail.com> Co-authored-by: waterplea <alexander@inkin.ru> refactor: continue to get rid of ionic infrastructure (#2325) refactor: finish removing ionic entities: (#2333) * refactor: finish removing ionic entities: ToastController ErrorToastService ModalController AlertController LoadingController * chore: rollback testing code * chore: fix comments * minor form change * chore: fix comments * update clearnet address parts * move around patchDB * chore: fix comments --------- Co-authored-by: Matt Hill <matthewonthemoon@gmail.com> fixup after rebase
This commit is contained in:
committed by
Aiden McClelland
parent
c03778ec8b
commit
38c2c47789
@@ -7,7 +7,7 @@
|
||||
<ion-content>
|
||||
<ion-split-pane
|
||||
contentId="main-content"
|
||||
[disabled]="!(authService.isVerified$ | async)"
|
||||
[disabled]="!(navigation$ | async)"
|
||||
(ionSplitPaneVisible)="splitPaneVisible($event)"
|
||||
>
|
||||
<ion-menu
|
||||
@@ -75,7 +75,7 @@
|
||||
</ion-app>
|
||||
</tui-root>
|
||||
<ng-container
|
||||
*ngIf="authService.isVerified$ | async"
|
||||
*ngIf="authService.isVerified$ | async; else defaultTheme"
|
||||
[ngSwitch]="theme$ | async"
|
||||
>
|
||||
<ng-container *ngSwitchCase="'Dark'">
|
||||
@@ -84,3 +84,7 @@
|
||||
</ng-container>
|
||||
<light-theme *ngSwitchCase="'Light'"></light-theme>
|
||||
</ng-container>
|
||||
<ng-template #defaultTheme>
|
||||
<tui-theme-night></tui-theme-night>
|
||||
<dark-theme></dark-theme>
|
||||
</ng-template>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, inject, OnDestroy } from '@angular/core'
|
||||
import { merge } from 'rxjs'
|
||||
import { Router } from '@angular/router'
|
||||
import { combineLatest, map, merge } from 'rxjs'
|
||||
import { AuthService } from './services/auth.service'
|
||||
import { SplitPaneTracker } from './services/split-pane.service'
|
||||
import { PatchDataService } from './services/patch-data.service'
|
||||
@@ -15,6 +16,10 @@ import { THEME } from '@start9labs/shared'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { DataModel } from './services/patch-db/data-model'
|
||||
|
||||
function hasNavigation(url: string): boolean {
|
||||
return !url.startsWith('/loading') && !url.startsWith('/diagnostic')
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: 'app.component.html',
|
||||
@@ -25,8 +30,13 @@ export class AppComponent implements OnDestroy {
|
||||
readonly sidebarOpen$ = this.splitPane.sidebarOpen$
|
||||
readonly widgetDrawer$ = this.clientStorageService.widgetDrawer$
|
||||
readonly theme$ = inject(THEME)
|
||||
readonly navigation$ = combineLatest([
|
||||
this.authService.isVerified$,
|
||||
this.router.events.pipe(map(() => hasNavigation(this.router.url))),
|
||||
]).pipe(map(([isVerified, hasNavigation]) => isVerified && hasNavigation))
|
||||
|
||||
constructor(
|
||||
private readonly router: Router,
|
||||
private readonly titleService: Title,
|
||||
private readonly patchData: PatchDataService,
|
||||
private readonly patchMonitor: PatchMonitorService,
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
ResponsiveColModule,
|
||||
SharedPipesModule,
|
||||
LightThemeModule,
|
||||
LoadingModule,
|
||||
} from '@start9labs/shared'
|
||||
|
||||
import { AppComponent } from './app.component'
|
||||
@@ -32,7 +33,6 @@ import { ConnectionBarComponentModule } from './app/connection-bar/connection-ba
|
||||
import { WidgetsPageModule } from 'src/app/apps/ui/pages/widgets/widgets.module'
|
||||
import { ServiceWorkerModule } from '@angular/service-worker'
|
||||
import { environment } from '../environments/environment'
|
||||
import { LoadingModule } from './common/loading/loading.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent],
|
||||
|
||||
@@ -54,9 +54,9 @@
|
||||
<img
|
||||
appSnek
|
||||
class="snek"
|
||||
alt="Play Snek"
|
||||
alt="Play Snake"
|
||||
src="assets/img/icons/snek.png"
|
||||
[appSnekHighScore]="snekScore$ | async"
|
||||
[appSnekHighScore]="(snekScore$ | async) || 0"
|
||||
/>
|
||||
<ion-footer *ngIf="sidebarOpen$ | async" class="bottom">
|
||||
<connection-bar></connection-bar>
|
||||
|
||||
@@ -63,7 +63,6 @@
|
||||
<img src="assets/img/icons/bitcoin.svg" />
|
||||
<img src="assets/img/icon.png" />
|
||||
<img src="assets/img/logo.png" />
|
||||
<img src="assets/img/icon.png" />
|
||||
<img src="assets/img/icon_transparent.png" />
|
||||
<img src="assets/img/community-store.png" />
|
||||
<img src="assets/img/icons/snek.png" />
|
||||
@@ -83,5 +82,4 @@
|
||||
<p style="font-family: Open Sans; font-weight: bold">a</p>
|
||||
<p style="font-family: Open Sans; font-weight: 600">a</p>
|
||||
<p style="font-family: Open Sans; font-weight: 100">a</p>
|
||||
<p style="font-family: Redacted">a</p>
|
||||
</div>
|
||||
|
||||
@@ -82,9 +82,11 @@ const ICONS = [
|
||||
'settings-outline',
|
||||
'shield-checkmark-outline',
|
||||
'stop-outline',
|
||||
'stopwatch-outline',
|
||||
'storefront-outline',
|
||||
'swap-vertical',
|
||||
'terminal-outline',
|
||||
'trail-sign-outline',
|
||||
'trash',
|
||||
'trash-outline',
|
||||
'warning-outline',
|
||||
|
||||
@@ -1,28 +1,8 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Play Snek!</ion-title>
|
||||
<ion-title slot="end">Score: {{ score }}</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<div class="canvas-center" style="width: 100%; height: 100%">
|
||||
<canvas id="game"></canvas>
|
||||
</div>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-title slot="start">High Score: {{ highScore }}</ion-title>
|
||||
<ion-buttons slot="end" class="ion-padding-end">
|
||||
<ion-button
|
||||
fill="solid"
|
||||
color="primary"
|
||||
(click)="dismiss()"
|
||||
class="enter-click btn-128"
|
||||
>
|
||||
Save and Quit
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
<div class="canvas-center">
|
||||
<canvas id="game"></canvas>
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<strong>Score: {{ score }}</strong>
|
||||
<span>High Score: {{ highScore }}</span>
|
||||
<button tuiButton (click)="dismiss()">Save and Quit</button>
|
||||
</footer>
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
.canvas-center {
|
||||
min-height: 50vh;
|
||||
padding-top: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 32px;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { Component, HostListener, Input } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { pauseFor } from '../../../../../shared/src/public-api'
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
HostListener,
|
||||
Inject,
|
||||
OnDestroy,
|
||||
} from '@angular/core'
|
||||
import { pauseFor } from '@start9labs/shared'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
|
||||
@Component({
|
||||
selector: 'snake',
|
||||
templateUrl: './snake.page.html',
|
||||
styleUrls: ['./snake.page.scss'],
|
||||
})
|
||||
export class SnakePage {
|
||||
@Input()
|
||||
highScore = 0
|
||||
export class SnakePage implements AfterViewInit, OnDestroy {
|
||||
highScore = this.dialog.data.highScore
|
||||
|
||||
score = 0
|
||||
|
||||
@@ -30,11 +37,16 @@ export class SnakePage {
|
||||
private bitcoin: { x: number; y: number } = { x: NaN, y: NaN }
|
||||
|
||||
private moveQueue: String[] = []
|
||||
private destroyed = false
|
||||
|
||||
constructor(private readonly modalCtrl: ModalController) {}
|
||||
constructor(
|
||||
@Inject(DOCUMENT) private readonly document: Document,
|
||||
@Inject(POLYMORPHEUS_CONTEXT)
|
||||
private readonly dialog: TuiDialogContext<number, { highScore: number }>,
|
||||
) {}
|
||||
|
||||
async dismiss() {
|
||||
return this.modalCtrl.dismiss({ highScore: this.highScore })
|
||||
dismiss() {
|
||||
this.dialog.completeWith(this.highScore)
|
||||
}
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
@@ -57,7 +69,11 @@ export class SnakePage {
|
||||
this.init()
|
||||
}
|
||||
|
||||
ionViewDidEnter() {
|
||||
ngOnDestroy() {
|
||||
this.destroyed = true
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.init()
|
||||
|
||||
this.image = new Image()
|
||||
@@ -68,10 +84,10 @@ export class SnakePage {
|
||||
}
|
||||
|
||||
init() {
|
||||
this.canvas = document.querySelector('canvas#game')!
|
||||
this.canvas = this.document.querySelector('canvas#game')!
|
||||
this.canvas.style.border = '1px solid #e0e0e0'
|
||||
this.context = this.canvas.getContext('2d')!
|
||||
const container = document.getElementsByClassName('canvas-center')[0]
|
||||
const container = this.document.querySelector('.canvas-center')!
|
||||
this.grid = Math.min(
|
||||
Math.floor(container.clientWidth / this.width),
|
||||
Math.floor(container.clientHeight / this.height),
|
||||
@@ -139,13 +155,15 @@ export class SnakePage {
|
||||
|
||||
// game loop
|
||||
async loop() {
|
||||
if (this.destroyed) return
|
||||
|
||||
await pauseFor(this.speed)
|
||||
|
||||
requestAnimationFrame(async () => await this.loop())
|
||||
|
||||
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height)
|
||||
|
||||
// move snake by it's velocity
|
||||
// move snake by its velocity
|
||||
this.snake.x += this.snake.dx
|
||||
this.snake.y += this.snake.dy
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Directive, HostListener, Input } from '@angular/core'
|
||||
import { LoadingController, ModalController } from '@ionic/angular'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { filter } from 'rxjs'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { SnakePage } from './snake.page'
|
||||
|
||||
@Directive({
|
||||
@@ -9,45 +11,40 @@ import { SnakePage } from './snake.page'
|
||||
})
|
||||
export class SnekDirective {
|
||||
@Input()
|
||||
appSnekHighScore: number | null = null
|
||||
appSnekHighScore = 0
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly embassyApi: ApiService,
|
||||
) {}
|
||||
|
||||
@HostListener('click')
|
||||
async onClick() {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: SnakePage,
|
||||
cssClass: 'snake-modal',
|
||||
backdropDismiss: false,
|
||||
componentProps: { highScore: this.appSnekHighScore || 0 },
|
||||
})
|
||||
|
||||
modal.onDidDismiss().then(async ({ data }) => {
|
||||
if (data?.highScore <= (this.appSnekHighScore || 0)) return
|
||||
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Saving high score...',
|
||||
this.dialogs
|
||||
.open<number>(new PolymorpheusComponent(SnakePage), {
|
||||
label: 'Snake!',
|
||||
closeable: false,
|
||||
dismissible: false,
|
||||
data: {
|
||||
highScore: this.appSnekHighScore,
|
||||
},
|
||||
})
|
||||
.pipe(filter(score => score > this.appSnekHighScore))
|
||||
.subscribe(async score => {
|
||||
const loader = this.loader.open('Saving high score...').subscribe()
|
||||
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.embassyApi.setDbValue<number>(
|
||||
['gaming', 'snake', 'high-score'],
|
||||
data.highScore,
|
||||
)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
this.loadingCtrl.dismiss()
|
||||
}
|
||||
})
|
||||
|
||||
modal.present()
|
||||
try {
|
||||
await this.embassyApi.setDbValue<number>(
|
||||
['gaming', 'snake', 'high-score'],
|
||||
score,
|
||||
)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ import { IonicModule } from '@ionic/angular'
|
||||
|
||||
import { SnekDirective } from './snek.directive'
|
||||
import { SnakePage } from './snake.page'
|
||||
import { TuiButtonModule } from '@taiga-ui/core'
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, IonicModule],
|
||||
imports: [CommonModule, IonicModule, TuiButtonModule],
|
||||
declarations: [SnekDirective, SnakePage],
|
||||
exports: [SnekDirective, SnakePage],
|
||||
})
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { WorkspaceConfig } from '@start9labs/shared'
|
||||
import { DiagnosticService } from './services/diagnostic.service'
|
||||
import { MockDiagnosticService } from './services/mock-diagnostic.service'
|
||||
import { LiveDiagnosticService } from './services/live-diagnostic.service'
|
||||
|
||||
const { useMocks } = require('../../../../../../config.json') as WorkspaceConfig
|
||||
|
||||
const ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadChildren: () =>
|
||||
import('./home/home.module').then(m => m.HomePageModule),
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
loadChildren: () =>
|
||||
import('./logs/logs.module').then(m => m.LogsPageModule),
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(ROUTES)],
|
||||
providers: [
|
||||
{
|
||||
provide: DiagnosticService,
|
||||
useClass: useMocks ? MockDiagnosticService : LiveDiagnosticService,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class DiagnosticModule {}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { TuiButtonModule } from '@taiga-ui/core'
|
||||
import { HomePage } from './home.page'
|
||||
|
||||
const ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: HomePage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, TuiButtonModule, RouterModule.forChild(ROUTES)],
|
||||
declarations: [HomePage],
|
||||
})
|
||||
export class HomePageModule {}
|
||||
@@ -0,0 +1,53 @@
|
||||
<ng-container *ngIf="!restarted; else refresh">
|
||||
<h1 class="title">StartOS - Diagnostic Mode</h1>
|
||||
|
||||
<ng-container *ngIf="error">
|
||||
<h2 class="subtitle">StartOS launch error:</h2>
|
||||
<code class="code warning">
|
||||
<p>{{ error.problem }}</p>
|
||||
<p *ngIf="error.details">{{ error.details }}</p>
|
||||
</code>
|
||||
|
||||
<a tuiButton routerLink="logs">View Logs</a>
|
||||
|
||||
<h2 class="subtitle">Possible solutions:</h2>
|
||||
<code class="code"><p>{{ error.solution }}</p></code>
|
||||
|
||||
<div class="buttons">
|
||||
<button tuiButton (click)="restart()">Restart Server</button>
|
||||
|
||||
<button
|
||||
*ngIf="error.code === 15 || error.code === 25"
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
(click)="forgetDrive()"
|
||||
>
|
||||
{{ error.code === 15 ? 'Setup Current Drive' : 'Enter Recovery Mode'}}
|
||||
</button>
|
||||
|
||||
<button
|
||||
tuiButton
|
||||
appearance="secondary-warning"
|
||||
(click)="presentAlertSystemRebuild()"
|
||||
>
|
||||
System Rebuild
|
||||
</button>
|
||||
|
||||
<button
|
||||
tuiButton
|
||||
appearance="secondary-destructive"
|
||||
(click)="presentAlertRepairDisk()"
|
||||
>
|
||||
Repair Drive
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #refresh>
|
||||
<h1 class="title">Server is restarting</h1>
|
||||
<h2 class="subtitle">
|
||||
Wait for the server to restart, then refresh this page.
|
||||
</h2>
|
||||
<button tuiButton (click)="refreshPage()">Refresh</button>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,35 @@
|
||||
:host {
|
||||
display: block;
|
||||
padding: 32px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
padding-bottom: 24px;
|
||||
font-size: calc(2vw + 14px);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
font-size: calc(1vw + 12px);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.code {
|
||||
display: block;
|
||||
color: var(--tui-success-fill);
|
||||
background: rgb(69, 69, 69);
|
||||
padding: 1px 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--tui-warning-fill);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
194
frontend/projects/ui/src/app/apps/diagnostic/home/home.page.ts
Normal file
194
frontend/projects/ui/src/app/apps/diagnostic/home/home.page.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { Component, Inject } from '@angular/core'
|
||||
import { WINDOW } from '@ng-web-apis/common'
|
||||
import { LoadingService } from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { filter } from 'rxjs'
|
||||
import { DiagnosticService } from '../services/diagnostic.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
templateUrl: 'home.page.html',
|
||||
styleUrls: ['home.page.scss'],
|
||||
})
|
||||
export class HomePage {
|
||||
restarted = false
|
||||
error?: {
|
||||
code: number
|
||||
problem: string
|
||||
solution: string
|
||||
details?: string
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly loader: LoadingService,
|
||||
private readonly api: DiagnosticService,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
@Inject(WINDOW) private readonly window: Window,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
const error = await this.api.getError()
|
||||
// incorrect drive
|
||||
if (error.code === 15) {
|
||||
this.error = {
|
||||
code: 15,
|
||||
problem: 'Unknown storage drive detected',
|
||||
solution:
|
||||
'To use a different storage drive, replace the current one and click RESTART SERVER below. To use the current storage drive, click USE CURRENT DRIVE below, then follow instructions. No data will be erased during this process.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
// no drive
|
||||
} else if (error.code === 20) {
|
||||
this.error = {
|
||||
code: 20,
|
||||
problem: 'Storage drive not found',
|
||||
solution:
|
||||
'Insert your StartOS storage drive and click RESTART SERVER below.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
// drive corrupted
|
||||
} else if (error.code === 25) {
|
||||
this.error = {
|
||||
code: 25,
|
||||
problem:
|
||||
'Storage drive corrupted. This could be the result of data corruption or physical damage.',
|
||||
solution:
|
||||
'It may or may not be possible to re-use this drive by reformatting and recovering from backup. To enter recovery mode, click ENTER RECOVERY MODE below, then follow instructions. No data will be erased during this step.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
// filesystem I/O error - disk needs repair
|
||||
} else if (error.code === 2) {
|
||||
this.error = {
|
||||
code: 2,
|
||||
problem: 'Filesystem I/O error.',
|
||||
solution:
|
||||
'Repairing the disk could help resolve this issue. Please DO NOT unplug the drive or server during this time or the situation will become worse.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
// disk management error - disk needs repair
|
||||
} else if (error.code === 48) {
|
||||
this.error = {
|
||||
code: 48,
|
||||
problem: 'Disk management error.',
|
||||
solution:
|
||||
'Repairing the disk could help resolve this issue. Please DO NOT unplug the drive or server during this time or the situation will become worse.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
} else {
|
||||
this.error = {
|
||||
code: error.code,
|
||||
problem: error.message,
|
||||
solution: 'Please contact support.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
async restart(): Promise<void> {
|
||||
const loader = this.loader.open('').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.restart()
|
||||
this.restarted = true
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
async forgetDrive(): Promise<void> {
|
||||
const loader = this.loader.open('').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.forgetDrive()
|
||||
await this.api.restart()
|
||||
this.restarted = true
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
async presentAlertSystemRebuild() {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
no: 'Cancel',
|
||||
yes: 'Rebuild',
|
||||
content:
|
||||
'<p>This action will tear down all service containers and rebuild them from scratch. No data will be deleted.</p><p>A system rebuild can be useful if your system gets into a bad state, and it should only be performed if you are experiencing general performance or reliability issues.</p><p>It may take up to an hour to complete. During this time, you will lose all connectivity to your Start9 server.</p>',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => {
|
||||
try {
|
||||
this.systemRebuild()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async presentAlertRepairDisk() {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
no: 'Cancel',
|
||||
yes: 'Repair',
|
||||
content:
|
||||
'<p>This action should only be executed if directed by a Start9 support specialist.</p><p>If anything happens to the device during the reboot, such as losing power or unplugging the drive, the filesystem <i>will</i> be in an unrecoverable state. Please proceed with caution.</p>',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => {
|
||||
try {
|
||||
this.repairDisk()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
refreshPage(): void {
|
||||
this.window.location.reload()
|
||||
}
|
||||
|
||||
private async systemRebuild(): Promise<void> {
|
||||
const loader = this.loader.open('').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.systemRebuild()
|
||||
await this.api.restart()
|
||||
this.restarted = true
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async repairDisk(): Promise<void> {
|
||||
const loader = this.loader.open('').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.repairDisk()
|
||||
await this.api.restart()
|
||||
this.restarted = true
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Routes, RouterModule } from '@angular/router'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { LogsPage } from './logs.page'
|
||||
|
||||
const ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: LogsPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, IonicModule, RouterModule.forChild(ROUTES)],
|
||||
declarations: [LogsPage],
|
||||
})
|
||||
export class LogsPageModule {}
|
||||
@@ -0,0 +1,57 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button defaultHref="/"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Logs</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content
|
||||
[scrollEvents]="true"
|
||||
(ionScrollEnd)="scrollEnd()"
|
||||
class="ion-padding"
|
||||
>
|
||||
<ion-infinite-scroll
|
||||
id="scroller"
|
||||
*ngIf="!loading && needInfinite"
|
||||
position="top"
|
||||
threshold="0"
|
||||
(ionInfinite)="doInfinite($event)"
|
||||
>
|
||||
<ion-infinite-scroll-content
|
||||
loadingSpinner="lines"
|
||||
></ion-infinite-scroll-content>
|
||||
</ion-infinite-scroll>
|
||||
|
||||
<div id="container">
|
||||
<div id="template" style="white-space: pre-line"></div>
|
||||
</div>
|
||||
|
||||
<div id="bottom-div"></div>
|
||||
|
||||
<div
|
||||
[ngStyle]="{
|
||||
'position': 'fixed',
|
||||
'bottom': '50px',
|
||||
'right': isOnBottom ? '-52px' : '30px',
|
||||
'border-radius': '100%',
|
||||
'transition': 'right 0.25s ease-out'
|
||||
}"
|
||||
>
|
||||
<ion-button
|
||||
style="
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
--padding-start: 0px;
|
||||
--padding-end: 0px;
|
||||
--border-radius: 100%;
|
||||
"
|
||||
color="dark"
|
||||
(click)="scrollToBottom()"
|
||||
strong
|
||||
>
|
||||
<ion-icon name="chevron-down"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Component, ViewChild } from '@angular/core'
|
||||
import { IonContent } from '@ionic/angular'
|
||||
import { ErrorService, toLocalIsoString } from '@start9labs/shared'
|
||||
import { DiagnosticService } from '../services/diagnostic.service'
|
||||
|
||||
const Convert = require('ansi-to-html')
|
||||
const convert = new Convert({
|
||||
bg: 'transparent',
|
||||
})
|
||||
|
||||
@Component({
|
||||
selector: 'logs',
|
||||
templateUrl: './logs.page.html',
|
||||
})
|
||||
export class LogsPage {
|
||||
@ViewChild(IonContent) private content?: IonContent
|
||||
loading = true
|
||||
needInfinite = true
|
||||
startCursor?: string
|
||||
limit = 200
|
||||
isOnBottom = true
|
||||
|
||||
constructor(
|
||||
private readonly api: DiagnosticService,
|
||||
private readonly errorService: ErrorService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.getLogs()
|
||||
this.loading = false
|
||||
}
|
||||
|
||||
scrollEnd() {
|
||||
const bottomDiv = document.getElementById('bottom-div')
|
||||
this.isOnBottom =
|
||||
!!bottomDiv &&
|
||||
bottomDiv.getBoundingClientRect().top - 420 < window.innerHeight
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
this.content?.scrollToBottom(500)
|
||||
}
|
||||
|
||||
async doInfinite(e: any): Promise<void> {
|
||||
await this.getLogs()
|
||||
e.target.complete()
|
||||
}
|
||||
|
||||
private async getLogs() {
|
||||
try {
|
||||
const { 'start-cursor': startCursor, entries } = await this.api.getLogs({
|
||||
cursor: this.startCursor,
|
||||
before: !!this.startCursor,
|
||||
limit: this.limit,
|
||||
})
|
||||
|
||||
if (!entries.length) return
|
||||
|
||||
this.startCursor = startCursor
|
||||
|
||||
const container = document.getElementById('container')
|
||||
const newLogs = document.getElementById('template')?.cloneNode(true)
|
||||
|
||||
if (!(newLogs instanceof HTMLElement)) return
|
||||
|
||||
newLogs.innerHTML = entries
|
||||
.map(
|
||||
entry =>
|
||||
`<b>${toLocalIsoString(
|
||||
new Date(entry.timestamp),
|
||||
)}</b> ${convert.toHtml(entry.message)}`,
|
||||
)
|
||||
.join('\n')
|
||||
|
||||
const beforeContainerHeight = container?.scrollHeight || 0
|
||||
container?.prepend(newLogs)
|
||||
const afterContainerHeight = container?.scrollHeight || 0
|
||||
|
||||
// scroll down
|
||||
setTimeout(() => {
|
||||
this.content?.scrollToPoint(
|
||||
0,
|
||||
afterContainerHeight - beforeContainerHeight,
|
||||
)
|
||||
}, 50)
|
||||
|
||||
if (entries.length < this.limit) {
|
||||
this.needInfinite = false
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { LogsRes, ServerLogsReq } from '@start9labs/shared'
|
||||
|
||||
export abstract class DiagnosticService {
|
||||
abstract getError(): Promise<GetErrorRes>
|
||||
abstract restart(): Promise<void>
|
||||
abstract forgetDrive(): Promise<void>
|
||||
abstract repairDisk(): Promise<void>
|
||||
abstract systemRebuild(): Promise<void>
|
||||
abstract getLogs(params: ServerLogsReq): Promise<LogsRes>
|
||||
}
|
||||
|
||||
export interface GetErrorRes {
|
||||
code: number
|
||||
message: string
|
||||
data: { details: string }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import {
|
||||
HttpService,
|
||||
isRpcError,
|
||||
RpcError,
|
||||
RPCOptions,
|
||||
} from '@start9labs/shared'
|
||||
import { LogsRes, ServerLogsReq } from '@start9labs/shared'
|
||||
import { DiagnosticService, GetErrorRes } from './diagnostic.service'
|
||||
|
||||
@Injectable()
|
||||
export class LiveDiagnosticService implements DiagnosticService {
|
||||
constructor(private readonly http: HttpService) {}
|
||||
|
||||
async getError(): Promise<GetErrorRes> {
|
||||
return this.rpcRequest<GetErrorRes>({
|
||||
method: 'diagnostic.error',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async restart(): Promise<void> {
|
||||
return this.rpcRequest<void>({
|
||||
method: 'diagnostic.restart',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async forgetDrive(): Promise<void> {
|
||||
return this.rpcRequest<void>({
|
||||
method: 'diagnostic.disk.forget',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async repairDisk(): Promise<void> {
|
||||
return this.rpcRequest<void>({
|
||||
method: 'diagnostic.disk.repair',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async systemRebuild(): Promise<void> {
|
||||
return this.rpcRequest<void>({
|
||||
method: 'diagnostic.rebuild',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async getLogs(params: ServerLogsReq): Promise<LogsRes> {
|
||||
return this.rpcRequest<LogsRes>({
|
||||
method: 'diagnostic.logs',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
private async rpcRequest<T>(opts: RPCOptions): Promise<T> {
|
||||
const res = await this.http.rpcRequest<T>(opts)
|
||||
|
||||
const rpcRes = res.body
|
||||
|
||||
if (isRpcError(rpcRes)) {
|
||||
throw new RpcError(rpcRes.error)
|
||||
}
|
||||
|
||||
return rpcRes.result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { pauseFor } from '@start9labs/shared'
|
||||
import { LogsRes, ServerLogsReq, Log } from '@start9labs/shared'
|
||||
import { DiagnosticService, GetErrorRes } from './diagnostic.service'
|
||||
|
||||
@Injectable()
|
||||
export class MockDiagnosticService implements DiagnosticService {
|
||||
async getError(): Promise<GetErrorRes> {
|
||||
await pauseFor(1000)
|
||||
return {
|
||||
code: 15,
|
||||
message: 'Unknown server',
|
||||
data: { details: 'Some details about the error here' },
|
||||
}
|
||||
}
|
||||
|
||||
async restart(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
|
||||
async forgetDrive(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
|
||||
async repairDisk(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
|
||||
async systemRebuild(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
|
||||
async getLogs(params: ServerLogsReq): Promise<LogsRes> {
|
||||
await pauseFor(1000)
|
||||
let entries: Log[]
|
||||
if (Math.random() < 0.2) {
|
||||
entries = packageLogs
|
||||
} else {
|
||||
const arrLength = params.limit
|
||||
? Math.ceil(params.limit / packageLogs.length)
|
||||
: 10
|
||||
entries = new Array(arrLength)
|
||||
.fill(packageLogs)
|
||||
.reduce((acc, val) => acc.concat(val), [])
|
||||
}
|
||||
return {
|
||||
entries,
|
||||
'start-cursor': 'startCursor',
|
||||
'end-cursor': 'endCursor',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const packageLogs = [
|
||||
{
|
||||
timestamp: '2019-12-26T14:20:30.872Z',
|
||||
message: '****** START *****',
|
||||
},
|
||||
{
|
||||
timestamp: '2019-12-26T14:21:30.872Z',
|
||||
message: 'ServerLogs ServerLogs ServerLogs ServerLogs ServerLogs',
|
||||
},
|
||||
{
|
||||
timestamp: '2019-12-26T14:22:30.872Z',
|
||||
message: '****** FINISH *****',
|
||||
},
|
||||
]
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { LoadingModule } from '@start9labs/shared'
|
||||
import { InitializingModule } from '@start9labs/shared'
|
||||
import { LoadingPage } from './loading.page'
|
||||
|
||||
const routes: Routes = [
|
||||
@@ -11,7 +11,7 @@ const routes: Routes = [
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [LoadingModule, RouterModule.forChild(routes)],
|
||||
imports: [InitializingModule, RouterModule.forChild(routes)],
|
||||
declarations: [LoadingPage],
|
||||
})
|
||||
export class LoadingPageModule {}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<app-loading
|
||||
<app-initializing
|
||||
class="ion-page"
|
||||
(finished)="navCtrl.navigateForward('/login')"
|
||||
></app-loading>
|
||||
></app-initializing>
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { LoadingController, getPlatforms } from '@ionic/angular'
|
||||
import { Component, Inject } from '@angular/core'
|
||||
import { getPlatforms } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { AuthService } from 'src/app/services/auth.service'
|
||||
import { Router } from '@angular/router'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { LoadingService } from '@start9labs/shared'
|
||||
import { TuiDestroyService } from '@taiga-ui/cdk'
|
||||
import { takeUntil } from 'rxjs'
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
|
||||
@Component({
|
||||
selector: 'login',
|
||||
templateUrl: './login.page.html',
|
||||
styleUrls: ['./login.page.scss'],
|
||||
providers: [TuiDestroyService],
|
||||
})
|
||||
export class LoginPage {
|
||||
password = ''
|
||||
unmasked = false
|
||||
error = ''
|
||||
loader?: HTMLIonLoadingElement
|
||||
secure = this.config.isSecure()
|
||||
|
||||
constructor(
|
||||
@Inject(DOCUMENT) private readonly document: Document,
|
||||
private readonly destroy$: TuiDestroyService,
|
||||
private readonly router: Router,
|
||||
private readonly authService: AuthService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly api: ApiService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
@@ -35,10 +41,6 @@ export class LoginPage {
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.loader?.dismiss()
|
||||
}
|
||||
|
||||
toggleMask() {
|
||||
this.unmasked = !this.unmasked
|
||||
}
|
||||
@@ -46,13 +48,13 @@ export class LoginPage {
|
||||
async submit() {
|
||||
this.error = ''
|
||||
|
||||
this.loader = await this.loadingCtrl.create({
|
||||
message: 'Logging in...',
|
||||
})
|
||||
await this.loader.present()
|
||||
const loader = this.loader
|
||||
.open('Logging in...')
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe()
|
||||
|
||||
try {
|
||||
document.cookie = ''
|
||||
this.document.cookie = ''
|
||||
if (this.password.length > 64) {
|
||||
this.error = 'Password must be less than 65 characters'
|
||||
return
|
||||
@@ -71,7 +73,7 @@ export class LoginPage {
|
||||
// code 7 is for incorrect password
|
||||
this.error = e.code === 7 ? 'Invalid Password' : e.message
|
||||
} finally {
|
||||
this.loader.dismiss()
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<ion-item-group>
|
||||
<ion-item-divider>
|
||||
Completed: {{ timestamp | date : 'medium' }}
|
||||
</ion-item-divider>
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>System data</h2>
|
||||
<p>
|
||||
<ion-text [color]="system.color">{{ system.result }}</ion-text>
|
||||
</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" [name]="system.icon" [color]="system.color"></ion-icon>
|
||||
</ion-item>
|
||||
<ion-item
|
||||
*ngFor="let pkg of report?.packages | keyvalue"
|
||||
style="--background: transparent"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>{{ pkg.key }}</h2>
|
||||
<p>
|
||||
<ion-text [color]="pkg.value.error ? 'danger' : 'success'">
|
||||
{{ pkg.value.error ? 'Failed: ' + pkg.value.error : 'Succeeded' }}
|
||||
</ion-text>
|
||||
</p>
|
||||
</ion-label>
|
||||
<ion-icon
|
||||
slot="end"
|
||||
[name]="pkg.value.error ? 'remove-circle-outline' : 'checkmark'"
|
||||
[color]="pkg.value.error ? 'danger' : 'success'"
|
||||
></ion-icon>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
@@ -1,24 +1,26 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { Component, Inject } from '@angular/core'
|
||||
import { BackupReport } from 'src/app/services/api/api.types'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
|
||||
@Component({
|
||||
selector: 'backup-report',
|
||||
templateUrl: './backup-report.page.html',
|
||||
templateUrl: './backup-report.component.html',
|
||||
})
|
||||
export class BackupReportPage {
|
||||
@Input() report!: BackupReport
|
||||
@Input() timestamp!: string
|
||||
|
||||
system!: {
|
||||
export class BackupReportComponent {
|
||||
readonly system: {
|
||||
result: string
|
||||
icon: 'remove' | 'remove-circle-outline' | 'checkmark'
|
||||
color: 'dark' | 'danger' | 'success'
|
||||
}
|
||||
|
||||
constructor(private readonly modalCtrl: ModalController) {}
|
||||
|
||||
ngOnInit() {
|
||||
constructor(
|
||||
@Inject(POLYMORPHEUS_CONTEXT)
|
||||
private readonly context: TuiDialogContext<
|
||||
void,
|
||||
{ report: BackupReport; timestamp: string }
|
||||
>,
|
||||
) {
|
||||
if (!this.report.server.attempted) {
|
||||
this.system = {
|
||||
result: 'Not Attempted',
|
||||
@@ -40,7 +42,11 @@ export class BackupReportPage {
|
||||
}
|
||||
}
|
||||
|
||||
async dismiss() {
|
||||
return this.modalCtrl.dismiss(true)
|
||||
get report(): BackupReport {
|
||||
return this.context.data.report
|
||||
}
|
||||
|
||||
get timestamp(): string {
|
||||
return this.context.data.timestamp
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { BackupReportPage } from './backup-report.page'
|
||||
import { BackupReportComponent } from './backup-report.component'
|
||||
|
||||
@NgModule({
|
||||
declarations: [BackupReportPage],
|
||||
declarations: [BackupReportComponent],
|
||||
imports: [CommonModule, IonicModule],
|
||||
exports: [BackupReportPage],
|
||||
exports: [BackupReportComponent],
|
||||
})
|
||||
export class BackupReportPageModule {}
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Backup Report</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<ion-item-group>
|
||||
<ion-item-divider>
|
||||
Completed: {{ timestamp | date : 'medium' }}
|
||||
</ion-item-divider>
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>System data</h2>
|
||||
<p><ion-text [color]="system.color">{{ system.result }}</ion-text></p>
|
||||
</ion-label>
|
||||
<ion-icon
|
||||
slot="end"
|
||||
[name]="system.icon"
|
||||
[color]="system.color"
|
||||
></ion-icon>
|
||||
</ion-item>
|
||||
<ion-item *ngFor="let pkg of report?.packages | keyvalue">
|
||||
<ion-label>
|
||||
<h2>{{ pkg.key }}</h2>
|
||||
<p>
|
||||
<ion-text [color]="pkg.value.error ? 'danger' : 'success'">
|
||||
{{ pkg.value.error ? 'Failed: ' + pkg.value.error : 'Succeeded' }}
|
||||
</ion-text>
|
||||
</p>
|
||||
</ion-label>
|
||||
<ion-icon
|
||||
slot="end"
|
||||
[name]="pkg.value.error ? 'remove-circle-outline' : 'checkmark'"
|
||||
[color]="pkg.value.error ? 'danger' : 'success'"
|
||||
></ion-icon>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ReactiveFormsModule } from '@angular/forms'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { TuiValueChangesModule } from '@taiga-ui/cdk'
|
||||
import { TuiButtonModule, TuiModeModule } from '@taiga-ui/core'
|
||||
import { FormModule } from 'src/app/common/form/form.module'
|
||||
@@ -10,6 +11,7 @@ import { FormPage } from './form.page'
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
TuiValueChangesModule,
|
||||
TuiButtonModule,
|
||||
TuiModeModule,
|
||||
|
||||
@@ -7,14 +7,26 @@
|
||||
<form-group [spec]="spec"></form-group>
|
||||
<footer tuiMode="onDark">
|
||||
<ng-content></ng-content>
|
||||
<button
|
||||
*ngFor="let button of buttons; let last = last"
|
||||
tuiButton
|
||||
[appearance]="last ? 'primary' : 'flat'"
|
||||
[type]="last ? 'submit' : 'button'"
|
||||
(click)="onClick(button.handler)"
|
||||
>
|
||||
{{ button.text }}
|
||||
</button>
|
||||
<ng-container *ngFor="let button of buttons; let last = last">
|
||||
<button
|
||||
*ngIf="button.handler else link"
|
||||
tuiButton
|
||||
[appearance]="last ? 'primary' : 'flat'"
|
||||
[type]="last ? 'submit' : 'button'"
|
||||
(click)="onClick(button.handler)"
|
||||
>
|
||||
{{ button.text }}
|
||||
</button>
|
||||
<ng-template #link>
|
||||
<a
|
||||
tuiButton
|
||||
appearance="flat"
|
||||
[routerLink]="button.link"
|
||||
(click)="close()"
|
||||
>
|
||||
{{ button.text }}
|
||||
</a>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
@@ -17,7 +17,8 @@ import { FormService } from 'src/app/services/form.service'
|
||||
|
||||
export interface ActionButton<T> {
|
||||
text: string
|
||||
handler: (value: T) => Promise<boolean | void> | void
|
||||
handler?: (value: T) => Promise<boolean | void> | void
|
||||
link?: string
|
||||
}
|
||||
|
||||
export interface FormContext<T> {
|
||||
@@ -65,12 +66,12 @@ export class FormPage<T extends Record<string, any>> implements OnInit {
|
||||
this.markAsDirty()
|
||||
}
|
||||
|
||||
async onClick(handler: ActionButton<T>['handler']) {
|
||||
async onClick(handler: Required<ActionButton<T>>['handler']) {
|
||||
tuiMarkControlAsTouchedAndValidate(this.form)
|
||||
this.invalidService.scrollIntoView()
|
||||
|
||||
if (this.form.valid && (await handler(this.form.value as T))) {
|
||||
this.context?.$implicit.complete()
|
||||
this.close()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,6 +79,10 @@ export class FormPage<T extends Record<string, any>> implements OnInit {
|
||||
this.dialogFormService.markAsDirty()
|
||||
}
|
||||
|
||||
close() {
|
||||
this.context?.$implicit.complete()
|
||||
}
|
||||
|
||||
private process(patch: Operation[]) {
|
||||
patch.forEach(({ op, path }) => {
|
||||
const control = this.form.get(path.substring(1).split('/'))
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<ion-content>
|
||||
<div
|
||||
style="margin: 24px 24px 12px 24px; display: flex; flex-direction: column"
|
||||
>
|
||||
<ion-item style="padding-bottom: 8px">
|
||||
<ion-label>
|
||||
<h1>{{ options.title }}</h1>
|
||||
<br />
|
||||
<p>{{ options.message }}</p>
|
||||
<ng-container *ngIf="options.warning">
|
||||
<br />
|
||||
<p>
|
||||
<ion-text color="warning">{{ options.warning }}</ion-text>
|
||||
</p>
|
||||
</ng-container>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<form (ngSubmit)="submit()">
|
||||
<div style="margin: 0 0 24px 16px">
|
||||
<p class="input-label">{{ options.label }}</p>
|
||||
<ion-item
|
||||
lines="none"
|
||||
[color]="(theme$ | async) === 'Light' ? 'light' : 'dark'"
|
||||
>
|
||||
<ion-input
|
||||
#mainInput
|
||||
type="text"
|
||||
name="value"
|
||||
[ngModel]="masked ? maskedValue : value"
|
||||
(ngModelChange)="transformInput($event)"
|
||||
[placeholder]="options.placeholder"
|
||||
(ionChange)="error = ''"
|
||||
></ion-input>
|
||||
<ion-button
|
||||
slot="end"
|
||||
*ngIf="options.useMask"
|
||||
fill="clear"
|
||||
color="light"
|
||||
(click)="toggleMask()"
|
||||
>
|
||||
<ion-icon
|
||||
slot="icon-only"
|
||||
[name]="!masked ? 'eye-off-outline' : 'eye-outline'"
|
||||
size="small"
|
||||
></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
<!-- error -->
|
||||
<p *ngIf="error">
|
||||
<ion-text color="danger">{{ error }}</ion-text>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="ion-text-right">
|
||||
<ion-button fill="clear" (click)="cancel()">Cancel</ion-button>
|
||||
<ion-button
|
||||
fill="clear"
|
||||
type="submit"
|
||||
[disabled]="!value && !options.required"
|
||||
>
|
||||
{{ options.buttonText }}
|
||||
</ion-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</ion-content>
|
||||
@@ -1,20 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { GenericInputComponent } from './generic-input.component'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
|
||||
@NgModule({
|
||||
declarations: [GenericInputComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
FormsModule,
|
||||
RouterModule.forChild([]),
|
||||
SharedPipesModule,
|
||||
],
|
||||
exports: [GenericInputComponent],
|
||||
})
|
||||
export class GenericInputComponentModule {}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { Component, inject, Input, ViewChild } from '@angular/core'
|
||||
import { ModalController, IonicSafeString, IonInput } from '@ionic/angular'
|
||||
import { getErrorMessage, THEME } from '@start9labs/shared'
|
||||
import { mask } from 'src/app/util/mask'
|
||||
|
||||
@Component({
|
||||
selector: 'generic-input',
|
||||
templateUrl: './generic-input.component.html',
|
||||
})
|
||||
export class GenericInputComponent {
|
||||
@ViewChild('mainInput') elem?: IonInput
|
||||
|
||||
@Input() options!: GenericInputOptions
|
||||
|
||||
value!: string
|
||||
masked!: boolean
|
||||
|
||||
maskedValue?: string
|
||||
|
||||
error: string | IonicSafeString = ''
|
||||
|
||||
readonly theme$ = inject(THEME)
|
||||
|
||||
constructor(private readonly modalCtrl: ModalController) {}
|
||||
|
||||
ngOnInit() {
|
||||
const defaultOptions: Partial<GenericInputOptions> = {
|
||||
buttonText: 'Submit',
|
||||
required: true,
|
||||
useMask: false,
|
||||
initialValue: '',
|
||||
}
|
||||
this.options = {
|
||||
...defaultOptions,
|
||||
...this.options,
|
||||
}
|
||||
|
||||
this.masked = !!this.options.useMask
|
||||
this.value = this.options.initialValue || ''
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
setTimeout(() => this.elem?.setFocus(), 400)
|
||||
}
|
||||
|
||||
toggleMask() {
|
||||
this.masked = !this.masked
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
|
||||
transformInput(newValue: string) {
|
||||
let i = 0
|
||||
this.value = newValue
|
||||
.split('')
|
||||
.map(x => (x === '●' ? this.value[i++] : x))
|
||||
.join('')
|
||||
this.maskedValue = mask(this.value)
|
||||
}
|
||||
|
||||
async submit() {
|
||||
const value = this.value.trim()
|
||||
|
||||
if (!value && this.options.required) return
|
||||
|
||||
try {
|
||||
const response = await this.options.submitFn(value)
|
||||
this.modalCtrl.dismiss({ response, value }, 'success')
|
||||
} catch (e: any) {
|
||||
this.error = getErrorMessage(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface GenericInputOptions {
|
||||
// required
|
||||
title: string
|
||||
message: string
|
||||
submitFn: (value: string) => Promise<any>
|
||||
// optional
|
||||
label?: string
|
||||
warning?: string
|
||||
buttonText?: string
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
useMask?: boolean
|
||||
initialValue?: string | null
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<p>{{ options.message }}</p>
|
||||
<p *ngIf="options.warning" class="warning">{{ options.warning }}</p>
|
||||
<form (ngSubmit)="submit(value.trim())">
|
||||
<tui-input
|
||||
tuiAutoFocus
|
||||
[tuiTextfieldLabelOutside]="!options.label"
|
||||
[tuiTextfieldCustomContent]="options.useMask ? toggle : ''"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[(ngModel)]="value"
|
||||
>
|
||||
{{ options.label }}
|
||||
<span *ngIf="options.required !== false && options.label">*</span>
|
||||
<input
|
||||
tuiTextfield
|
||||
[class.masked]="options.useMask && masked && value"
|
||||
[placeholder]="options.placeholder || ''"
|
||||
/>
|
||||
</tui-input>
|
||||
<footer class="modal-buttons">
|
||||
<button tuiButton type="button" appearance="secondary" (click)="cancel()">
|
||||
Cancel
|
||||
</button>
|
||||
<button tuiButton [disabled]="!value && options.required !== false">
|
||||
{{ options.buttonText || 'Submit' }}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
|
||||
<ng-template #toggle>
|
||||
<button
|
||||
tuiIconButton
|
||||
type="button"
|
||||
appearance="icon"
|
||||
title="Toggle masking"
|
||||
size="xs"
|
||||
class="button"
|
||||
[icon]="masked ? 'tuiIconEye' : 'tuiIconEyeOff'"
|
||||
(click)="masked = !masked"
|
||||
></button>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,13 @@
|
||||
.warning {
|
||||
color: var(--tui-warning-fill);
|
||||
}
|
||||
|
||||
.button {
|
||||
pointer-events: auto;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.masked {
|
||||
font-family: text-security-disc;
|
||||
-webkit-text-security: disc;
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
|
||||
import {
|
||||
POLYMORPHEUS_CONTEXT,
|
||||
PolymorpheusComponent,
|
||||
} from '@tinkoff/ng-polymorpheus'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
|
||||
@Component({
|
||||
selector: 'prompt',
|
||||
templateUrl: 'prompt.component.html',
|
||||
styleUrls: ['prompt.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PromptComponent {
|
||||
masked = this.options.useMask
|
||||
value = this.options.initialValue || ''
|
||||
|
||||
constructor(
|
||||
@Inject(POLYMORPHEUS_CONTEXT)
|
||||
private readonly context: TuiDialogContext<string, PromptOptions>,
|
||||
) {}
|
||||
|
||||
get options(): PromptOptions {
|
||||
return this.context.data
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.context.$implicit.complete()
|
||||
}
|
||||
|
||||
submit(value: string) {
|
||||
if (value || !this.options.required) {
|
||||
this.context.$implicit.next(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const PROMPT = new PolymorpheusComponent(PromptComponent)
|
||||
|
||||
export interface PromptOptions {
|
||||
message: string
|
||||
label?: string
|
||||
warning?: string
|
||||
buttonText?: string
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
useMask?: boolean
|
||||
initialValue?: string | null
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { TuiButtonModule, TuiTextfieldControllerModule } from '@taiga-ui/core'
|
||||
import { TuiInputModule } from '@taiga-ui/kit'
|
||||
import { TuiAutoFocusModule } from '@taiga-ui/cdk'
|
||||
import { PromptComponent } from './prompt.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
TuiInputModule,
|
||||
TuiButtonModule,
|
||||
TuiTextfieldControllerModule,
|
||||
TuiAutoFocusModule,
|
||||
],
|
||||
declarations: [PromptComponent],
|
||||
exports: [PromptComponent],
|
||||
})
|
||||
export class PromptModule {}
|
||||
@@ -1,77 +1,60 @@
|
||||
import { Directive, HostListener } from '@angular/core'
|
||||
import { LoadingController, ModalController } from '@ionic/angular'
|
||||
import { LoadingService } from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { BackupTarget } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { TargetSelectPage } from '../modals/target-select/target-select.page'
|
||||
import {
|
||||
CifsBackupTarget,
|
||||
DiskBackupTarget,
|
||||
} from 'src/app/services/api/api.types'
|
||||
import { BackupSelectPage } from '../modals/backup-select/backup-select.page'
|
||||
|
||||
@Directive({
|
||||
selector: '[backupCreate]',
|
||||
})
|
||||
export class BackupCreateDirective {
|
||||
serviceIds: string[] = []
|
||||
|
||||
constructor(
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly embassyApi: ApiService,
|
||||
) {}
|
||||
|
||||
@HostListener('click') onClick() {
|
||||
@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<CifsBackupTarget | DiskBackupTarget>().then(res => {
|
||||
if (res.data) {
|
||||
this.presentModalSelect(res.data.id)
|
||||
}
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
presentModalTarget() {
|
||||
this.dialogs
|
||||
.open<BackupTarget>(new PolymorpheusComponent(TargetSelectPage), {
|
||||
label: 'Select Backup Target',
|
||||
data: { type: 'create' },
|
||||
})
|
||||
.subscribe(({ id }) => {
|
||||
this.presentModalSelect(id)
|
||||
})
|
||||
}
|
||||
|
||||
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 presentModalSelect(targetId: string) {
|
||||
this.dialogs
|
||||
.open<string[]>(new PolymorpheusComponent(BackupSelectPage), {
|
||||
label: 'Select Services to Back Up',
|
||||
data: { btnText: 'Create Backup' },
|
||||
})
|
||||
.subscribe(pkgIds => {
|
||||
this.createBackup(targetId, pkgIds)
|
||||
})
|
||||
}
|
||||
|
||||
private async createBackup(
|
||||
targetId: string,
|
||||
pkgIds: string[],
|
||||
): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Beginning backup...',
|
||||
})
|
||||
await loader.present()
|
||||
const loader = this.loader.open('Beginning backup...').subscribe()
|
||||
|
||||
await this.embassyApi
|
||||
.createBackup({
|
||||
'target-id': targetId,
|
||||
'package-ids': pkgIds,
|
||||
})
|
||||
.finally(() => loader.dismiss())
|
||||
.finally(() => loader.unsubscribe())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,42 @@
|
||||
import { Directive, HostListener } from '@angular/core'
|
||||
import {
|
||||
LoadingController,
|
||||
ModalController,
|
||||
NavController,
|
||||
} from '@ionic/angular'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import {
|
||||
GenericInputComponent,
|
||||
GenericInputOptions,
|
||||
} from 'src/app/apps/ui/modals/generic-input/generic-input.component'
|
||||
import { BackupInfo, BackupTarget } from 'src/app/services/api/api.types'
|
||||
import * as argon2 from '@start9labs/argon2'
|
||||
import { TargetSelectPage } from '../modals/target-select/target-select.page'
|
||||
import { RecoverSelectPage } from '../modals/recover-select/recover-select.page'
|
||||
import {
|
||||
RecoverData,
|
||||
RecoverSelectPage,
|
||||
} from '../modals/recover-select/recover-select.page'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import {
|
||||
PROMPT,
|
||||
PromptOptions,
|
||||
} from 'src/app/apps/ui/modals/prompt/prompt.component'
|
||||
import {
|
||||
catchError,
|
||||
EMPTY,
|
||||
exhaustMap,
|
||||
map,
|
||||
Observable,
|
||||
of,
|
||||
switchMap,
|
||||
take,
|
||||
tap,
|
||||
} from 'rxjs'
|
||||
|
||||
@Directive({
|
||||
selector: '[backupRestore]',
|
||||
})
|
||||
export class BackupRestoreDirective {
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly loader: LoadingService,
|
||||
) {}
|
||||
|
||||
@HostListener('click') onClick() {
|
||||
@@ -30,92 +44,81 @@ export class BackupRestoreDirective {
|
||||
}
|
||||
|
||||
async presentModalTarget() {
|
||||
const modal = await this.modalCtrl.create({
|
||||
presentingElement: await this.modalCtrl.getTop(),
|
||||
component: TargetSelectPage,
|
||||
componentProps: { type: 'restore' },
|
||||
})
|
||||
|
||||
modal.onDidDismiss<BackupTarget>().then(res => {
|
||||
if (res.data) {
|
||||
this.presentModalPassword(res.data)
|
||||
}
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
this.dialogs
|
||||
.open<BackupTarget>(new PolymorpheusComponent(TargetSelectPage), {
|
||||
label: 'Select Backup Source',
|
||||
data: { type: 'restore' },
|
||||
})
|
||||
.subscribe(data => {
|
||||
this.presentModalPassword(data)
|
||||
})
|
||||
}
|
||||
|
||||
async presentModalPassword(target: BackupTarget): Promise<void> {
|
||||
const options: GenericInputOptions = {
|
||||
title: 'Password Required',
|
||||
presentModalPassword(target: BackupTarget) {
|
||||
const data: PromptOptions = {
|
||||
message:
|
||||
'Enter the master password that was used to encrypt this backup. On the next screen, you will select the individual services you want to restore.',
|
||||
label: 'Master Password',
|
||||
placeholder: 'Enter master password',
|
||||
useMask: true,
|
||||
buttonText: 'Next',
|
||||
submitFn: async (password: string) => {
|
||||
const passwordHash = target['embassy-os']?.['password-hash'] || ''
|
||||
argon2.verify(passwordHash, password)
|
||||
return this.getBackupInfo(target.id, password)
|
||||
},
|
||||
}
|
||||
|
||||
const modal = await this.modalCtrl.create({
|
||||
componentProps: { options },
|
||||
cssClass: 'alertlike-modal',
|
||||
presentingElement: await this.modalCtrl.getTop(),
|
||||
component: GenericInputComponent,
|
||||
})
|
||||
this.dialogs
|
||||
.open<string>(PROMPT, {
|
||||
label: 'Password Required',
|
||||
data,
|
||||
})
|
||||
.pipe(
|
||||
exhaustMap(password =>
|
||||
this.getRecoverData(
|
||||
target.id,
|
||||
password,
|
||||
target['embassy-os']?.['password-hash'] || '',
|
||||
),
|
||||
),
|
||||
take(1),
|
||||
switchMap(data => this.presentModalSelect(data)),
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.navCtrl.navigateRoot('/services')
|
||||
})
|
||||
}
|
||||
|
||||
modal.onDidDismiss().then(res => {
|
||||
if (res.data) {
|
||||
const { value, response } = res.data
|
||||
this.presentModalSelect(target.id, response, value)
|
||||
}
|
||||
})
|
||||
private getRecoverData(
|
||||
targetId: string,
|
||||
password: string,
|
||||
hash: string,
|
||||
): Observable<RecoverData> {
|
||||
return of(password).pipe(
|
||||
tap(() => argon2.verify(hash, password)),
|
||||
switchMap(() => this.getBackupInfo(targetId, password)),
|
||||
catchError(e => {
|
||||
this.errorService.handleError(e)
|
||||
|
||||
await modal.present()
|
||||
return EMPTY
|
||||
}),
|
||||
map(backupInfo => ({ targetId, password, backupInfo })),
|
||||
)
|
||||
}
|
||||
|
||||
private async getBackupInfo(
|
||||
targetId: string,
|
||||
password: string,
|
||||
): Promise<BackupInfo> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Decrypting drive...',
|
||||
})
|
||||
await loader.present()
|
||||
const loader = this.loader.open('Decrypting drive...').subscribe()
|
||||
|
||||
return this.embassyApi
|
||||
.getBackupInfo({
|
||||
'target-id': targetId,
|
||||
password,
|
||||
})
|
||||
.finally(() => loader.dismiss())
|
||||
.finally(() => loader.unsubscribe())
|
||||
}
|
||||
|
||||
private async presentModalSelect(
|
||||
targetId: string,
|
||||
backupInfo: BackupInfo,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
const modal = await this.modalCtrl.create({
|
||||
componentProps: {
|
||||
targetId,
|
||||
backupInfo,
|
||||
password,
|
||||
},
|
||||
presentingElement: await this.modalCtrl.getTop(),
|
||||
component: RecoverSelectPage,
|
||||
private presentModalSelect(data: RecoverData): Observable<void> {
|
||||
return this.dialogs.open(new PolymorpheusComponent(RecoverSelectPage), {
|
||||
label: 'Select Services to Restore',
|
||||
data,
|
||||
})
|
||||
|
||||
modal.onWillDismiss().then(res => {
|
||||
if (res.role === 'success') {
|
||||
this.navCtrl.navigateRoot('/services')
|
||||
}
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { BackupSelectPage } from './backup-select.page'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { TuiButtonModule, TuiGroupModule } from '@taiga-ui/core'
|
||||
import { TuiCheckboxBlockModule } from '@taiga-ui/kit'
|
||||
import { BackupSelectPage } from './backup-select.page'
|
||||
|
||||
@NgModule({
|
||||
declarations: [BackupSelectPage],
|
||||
imports: [CommonModule, IonicModule, FormsModule],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
TuiButtonModule,
|
||||
TuiGroupModule,
|
||||
TuiCheckboxBlockModule,
|
||||
],
|
||||
exports: [BackupSelectPage],
|
||||
})
|
||||
export class BackupSelectPageModule {}
|
||||
|
||||
@@ -1,57 +1,31 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Select Services to Back Up</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<div
|
||||
*ngIf="pkgs.length; else empty"
|
||||
tuiGroup
|
||||
orientation="vertical"
|
||||
class="pkgs"
|
||||
>
|
||||
<tui-checkbox-block
|
||||
*ngFor="let pkg of pkgs"
|
||||
[disabled]="pkg.disabled"
|
||||
[(ngModel)]="pkg.checked"
|
||||
(ngModelChange)="handleChange()"
|
||||
>
|
||||
<div class="label">
|
||||
<img class="icon" alt="" [src]="pkg.icon" />
|
||||
{{ pkg.title }}
|
||||
</div>
|
||||
</tui-checkbox-block>
|
||||
</div>
|
||||
|
||||
<ion-content>
|
||||
<ng-container *ngIf="pkgs.length; else empty">
|
||||
<ion-item-group>
|
||||
<ion-item-divider>
|
||||
<ion-buttons slot="end" style="padding-bottom: 6px">
|
||||
<ion-button fill="clear" (click)="toggleSelectAll()">
|
||||
<b>{{ selectAll ? 'Select All' : 'Deselect All' }}</b>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-item-divider>
|
||||
<ion-item *ngFor="let pkg of pkgs">
|
||||
<ion-avatar slot="start">
|
||||
<img alt="" [src]="pkg.icon" />
|
||||
</ion-avatar>
|
||||
<ion-label>
|
||||
<h2>{{ pkg.title }}</h2>
|
||||
</ion-label>
|
||||
<ion-checkbox
|
||||
slot="end"
|
||||
[(ngModel)]="pkg.checked"
|
||||
(ionChange)="handleChange()"
|
||||
[disabled]="pkg.disabled"
|
||||
></ion-checkbox>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ng-container>
|
||||
<ng-template #empty>
|
||||
<h2 class="center">No services installed!</h2>
|
||||
</ng-template>
|
||||
</ion-content>
|
||||
<ng-template #empty>
|
||||
<h2 class="center">No services installed!</h2>
|
||||
</ng-template>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="end" class="ion-padding-end">
|
||||
<ion-button
|
||||
[disabled]="!hasSelection"
|
||||
fill="solid"
|
||||
color="primary"
|
||||
(click)="done()"
|
||||
class="enter-click btn-128"
|
||||
>
|
||||
{{ btnText }}
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
<footer class="modal-buttons">
|
||||
<button tuiButton appearance="flat" (click)="toggleSelectAll()">
|
||||
Toggle all
|
||||
</button>
|
||||
<button tuiButton [disabled]="!hasSelection" (click)="done()">
|
||||
{{ btnText }}
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
.center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pkgs {
|
||||
width: 100%;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
.label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
ion-item {
|
||||
--background: transparent;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { Component, Inject, Input } from '@angular/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { firstValueFrom, map } from 'rxjs'
|
||||
import { DataModel, PackageState } from 'src/app/services/patch-db/data-model'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
|
||||
@Component({
|
||||
selector: 'backup-select',
|
||||
@@ -10,11 +11,9 @@ import { DataModel, PackageState } from 'src/app/services/patch-db/data-model'
|
||||
styleUrls: ['./backup-select.page.scss'],
|
||||
})
|
||||
export class BackupSelectPage {
|
||||
@Input() btnText!: string
|
||||
@Input() selectedIds: string[] = []
|
||||
|
||||
hasSelection = false
|
||||
selectAll = false
|
||||
pkgs: {
|
||||
id: string
|
||||
title: string
|
||||
@@ -24,10 +23,15 @@ export class BackupSelectPage {
|
||||
}[] = []
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
@Inject(POLYMORPHEUS_CONTEXT)
|
||||
private readonly context: TuiDialogContext<string[], { btnText: string }>,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
get btnText(): string {
|
||||
return this.context.data.btnText
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
this.pkgs = await firstValueFrom(
|
||||
this.patch.watch$('package-data').pipe(
|
||||
@@ -51,13 +55,8 @@ export class BackupSelectPage {
|
||||
)
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
|
||||
async done() {
|
||||
const pkgIds = this.pkgs.filter(p => p.checked).map(p => p.id)
|
||||
this.modalCtrl.dismiss(pkgIds)
|
||||
done() {
|
||||
this.context.completeWith(this.pkgs.filter(p => p.checked).map(p => p.id))
|
||||
}
|
||||
|
||||
handleChange() {
|
||||
@@ -65,7 +64,7 @@ export class BackupSelectPage {
|
||||
}
|
||||
|
||||
toggleSelectAll() {
|
||||
this.pkgs.forEach(pkg => (pkg.checked = this.selectAll))
|
||||
this.selectAll = !this.selectAll
|
||||
this.pkgs.forEach(pkg => (pkg.checked = !this.hasSelection))
|
||||
this.hasSelection = !this.hasSelection
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { TuiButtonModule, TuiGroupModule } from '@taiga-ui/core'
|
||||
import { TuiCheckboxBlockModule } from '@taiga-ui/kit'
|
||||
import { RecoverSelectPage } from './recover-select.page'
|
||||
import { ToOptionsPipe } from './to-options.pipe'
|
||||
|
||||
@NgModule({
|
||||
declarations: [RecoverSelectPage, ToOptionsPipe],
|
||||
imports: [CommonModule, IonicModule, FormsModule],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
TuiButtonModule,
|
||||
TuiGroupModule,
|
||||
TuiCheckboxBlockModule,
|
||||
],
|
||||
exports: [RecoverSelectPage],
|
||||
})
|
||||
export class RecoverSelectPageModule {}
|
||||
|
||||
@@ -1,61 +1,36 @@
|
||||
<ng-container
|
||||
*ngIf="packageData$ | toOptions : backupInfo['package-backups'] | async as options"
|
||||
>
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Select Services to Restore</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<div tuiGroup orientation="vertical" class="items">
|
||||
<tui-checkbox-block
|
||||
*ngFor="let option of options"
|
||||
[disabled]="option.installed || option['newer-eos']"
|
||||
[(ngModel)]="option.checked"
|
||||
(ngModelChange)="handleChange(options)"
|
||||
>
|
||||
<div class="label">
|
||||
<strong class="title">{{ option.title }}</strong>
|
||||
<div>Version {{ option.version }}</div>
|
||||
<div>Backup made: {{ option.timestamp | date : 'medium' }}</div>
|
||||
<div *ngIf="!option.installed && !option['newer-eos']" class="success">
|
||||
Ready to restore
|
||||
</div>
|
||||
<div *ngIf="option.installed" class="warning">
|
||||
Unavailable. {{ option.title }} is already installed.
|
||||
</div>
|
||||
<div *ngIf="option['newer-eos']" class="danger">
|
||||
Unavailable. Backup was made on a newer version of StartOS.
|
||||
</div>
|
||||
</div>
|
||||
</tui-checkbox-block>
|
||||
</div>
|
||||
|
||||
<ion-content>
|
||||
<ion-item-group>
|
||||
<ion-item *ngFor="let option of options">
|
||||
<ion-label>
|
||||
<h2>{{ option.title }}</h2>
|
||||
<p>Version {{ option.version }}</p>
|
||||
<p>Backup made: {{ option.timestamp | date : 'medium' }}</p>
|
||||
<p *ngIf="!option.installed && !option['newer-eos']">
|
||||
<ion-text color="success">Ready to restore</ion-text>
|
||||
</p>
|
||||
<p *ngIf="option.installed">
|
||||
<ion-text color="warning">
|
||||
Unavailable. {{ option.title }} is already installed.
|
||||
</ion-text>
|
||||
</p>
|
||||
<p *ngIf="option['newer-eos']">
|
||||
<ion-text color="danger">
|
||||
Unavailable. Backup was made on a newer version of StartOS.
|
||||
</ion-text>
|
||||
</p>
|
||||
</ion-label>
|
||||
<ion-checkbox
|
||||
slot="end"
|
||||
[(ngModel)]="option.checked"
|
||||
[disabled]="option.installed || option['newer-eos']"
|
||||
(ionChange)="handleChange(options)"
|
||||
></ion-checkbox>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="end" class="ion-padding-end">
|
||||
<ion-button
|
||||
fill="solid"
|
||||
color="primary"
|
||||
class="enter-click btn-128"
|
||||
[disabled]="!hasSelection"
|
||||
(click)="restore(options)"
|
||||
>
|
||||
Restore Selected
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
<button
|
||||
tuiButton
|
||||
class="button"
|
||||
[disabled]="!hasSelection"
|
||||
(click)="restore(options)"
|
||||
>
|
||||
Restore Selected
|
||||
</button>
|
||||
</ng-container>
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
.items {
|
||||
width: 100%;
|
||||
margin: 12px 0 24px;
|
||||
}
|
||||
|
||||
.label {
|
||||
padding: 8px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 16px;
|
||||
margin-bottom: 4px;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.success {
|
||||
color: var(--tui-success-fill);
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--tui-warning-fill);
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: var(--tui-error-fill);
|
||||
}
|
||||
|
||||
.button {
|
||||
float: right;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import {
|
||||
LoadingController,
|
||||
ModalController,
|
||||
IonicSafeString,
|
||||
} from '@ionic/angular'
|
||||
import { getErrorMessage } from '@start9labs/shared'
|
||||
import { Component, Inject } from '@angular/core'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
import { BackupInfo } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
@@ -12,31 +9,33 @@ import { AppRecoverOption } from './to-options.pipe'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { take } from 'rxjs'
|
||||
|
||||
export interface RecoverData {
|
||||
targetId: string
|
||||
backupInfo: BackupInfo
|
||||
password: string
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'recover-select',
|
||||
templateUrl: './recover-select.page.html',
|
||||
styleUrls: ['./recover-select.page.scss'],
|
||||
})
|
||||
export class RecoverSelectPage {
|
||||
@Input() targetId!: string
|
||||
@Input() backupInfo!: BackupInfo
|
||||
@Input() password!: string
|
||||
@Input() oldPassword?: string
|
||||
|
||||
readonly packageData$ = this.patch.watch$('package-data').pipe(take(1))
|
||||
|
||||
hasSelection = false
|
||||
error: string | IonicSafeString = ''
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
@Inject(POLYMORPHEUS_CONTEXT)
|
||||
private readonly context: TuiDialogContext<void, RecoverData>,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
dismiss() {
|
||||
this.modalCtrl.dismiss()
|
||||
get backupInfo(): BackupInfo {
|
||||
return this.context.data.backupInfo
|
||||
}
|
||||
|
||||
handleChange(options: AppRecoverOption[]) {
|
||||
@@ -45,22 +44,20 @@ export class RecoverSelectPage {
|
||||
|
||||
async restore(options: AppRecoverOption[]): Promise<void> {
|
||||
const ids = options.filter(({ checked }) => !!checked).map(({ id }) => id)
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Initializing...',
|
||||
})
|
||||
await loader.present()
|
||||
const loader = this.loader.open('Initializing...').subscribe()
|
||||
|
||||
try {
|
||||
await this.embassyApi.restorePackages({
|
||||
ids,
|
||||
'target-id': this.targetId,
|
||||
password: this.password,
|
||||
'target-id': this.context.data.targetId,
|
||||
password: this.context.data.password,
|
||||
})
|
||||
this.modalCtrl.dismiss(undefined, 'success')
|
||||
|
||||
this.context.completeWith(undefined)
|
||||
} catch (e: any) {
|
||||
this.error = getErrorMessage(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { TuiButtonModule } from '@taiga-ui/core'
|
||||
import { TargetSelectPage, TargetStatusComponent } from './target-select.page'
|
||||
import { TargetPipesModule } from '../../pipes/target-pipes.module'
|
||||
import { TextSpinnerComponentModule } from '@start9labs/shared'
|
||||
@@ -12,6 +13,7 @@ import { TextSpinnerComponentModule } from '@start9labs/shared'
|
||||
IonicModule,
|
||||
TargetPipesModule,
|
||||
TextSpinnerComponentModule,
|
||||
TuiButtonModule,
|
||||
],
|
||||
exports: [TargetSelectPage],
|
||||
})
|
||||
|
||||
@@ -1,55 +1,40 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>
|
||||
Select Backup {{ type === 'create' ? 'Target' : 'Source' }}
|
||||
</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<!-- loading -->
|
||||
<text-spinner
|
||||
*ngIf="loading$ | async; else loaded"
|
||||
[text]="type === 'create' ? 'Loading Backup Targets' : 'Loading Backup Sources'"
|
||||
></text-spinner>
|
||||
|
||||
<ion-content>
|
||||
<!-- loading -->
|
||||
<text-spinner
|
||||
*ngIf="loading$ | async; else loaded"
|
||||
[text]="type === 'create' ? 'Loading Backup Targets' : 'Loading Backup Sources'"
|
||||
></text-spinner>
|
||||
|
||||
<!-- loaded -->
|
||||
<ng-template #loaded>
|
||||
<ion-item-group>
|
||||
<ion-item-divider>Saved Targets</ion-item-divider>
|
||||
<ion-item
|
||||
button
|
||||
*ngFor="let target of targets"
|
||||
(click)="select(target)"
|
||||
[disabled]="
|
||||
(isOneOff && !target.mountable) ||
|
||||
<!-- loaded -->
|
||||
<ng-template #loaded>
|
||||
<ion-item-group>
|
||||
<ion-item-divider>Saved Targets</ion-item-divider>
|
||||
<ion-item
|
||||
button
|
||||
*ngFor="let target of targets"
|
||||
(click)="select(target)"
|
||||
[disabled]="
|
||||
!target.mountable ||
|
||||
(type === 'restore' && !target['embassy-os'])
|
||||
"
|
||||
>
|
||||
<ng-container *ngIf="target | getDisplayInfo as displayInfo">
|
||||
<ion-icon
|
||||
slot="start"
|
||||
[name]="displayInfo.icon"
|
||||
size="large"
|
||||
></ion-icon>
|
||||
<ion-label>
|
||||
<h1 style="font-size: x-large">{{ displayInfo.name }}</h1>
|
||||
<target-status [type]="type" [target]="target"></target-status>
|
||||
<p>{{ displayInfo.description }}</p>
|
||||
<p>{{ displayInfo.path }}</p>
|
||||
</ion-label>
|
||||
</ng-container>
|
||||
</ion-item>
|
||||
>
|
||||
<ng-container *ngIf="target | getDisplayInfo as displayInfo">
|
||||
<ion-icon
|
||||
slot="start"
|
||||
[name]="displayInfo.icon"
|
||||
size="large"
|
||||
></ion-icon>
|
||||
<ion-label>
|
||||
<h1 style="font-size: x-large">{{ displayInfo.name }}</h1>
|
||||
<target-status [type]="type" [target]="target"></target-status>
|
||||
<p>{{ displayInfo.description }}</p>
|
||||
<p>{{ displayInfo.path }}</p>
|
||||
</ion-label>
|
||||
</ng-container>
|
||||
</ion-item>
|
||||
|
||||
<div *ngIf="!targets.length" class="ion-text-center ion-padding-top">
|
||||
<h2 class="ion-padding-bottom">No saved targets</h2>
|
||||
<ion-button (click)="goToTargets()">Go to Targets</ion-button>
|
||||
</div>
|
||||
</ion-item-group>
|
||||
</ng-template>
|
||||
</ion-content>
|
||||
<div *ngIf="!targets.length" class="ion-text-center ion-padding-top">
|
||||
<h2 class="ion-padding-bottom">No saved targets</h2>
|
||||
<button tuiButton (click)="goToTargets()">Go to Targets</button>
|
||||
</div>
|
||||
</ion-item-group>
|
||||
</ng-template>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
ion-item {
|
||||
--background: transparent;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { ModalController, NavController } from '@ionic/angular'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
import { BackupTarget } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { BackupType } from '../../pages/backup-targets/backup-targets.page'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
|
||||
@Component({
|
||||
selector: 'target-select',
|
||||
@@ -13,36 +20,36 @@ import { BackupType } from '../../pages/backup-targets/backup-targets.page'
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TargetSelectPage {
|
||||
@Input() type!: BackupType
|
||||
@Input() isOneOff = true
|
||||
|
||||
targets: BackupTarget[] = []
|
||||
|
||||
loading$ = new BehaviorSubject(true)
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
@Inject(POLYMORPHEUS_CONTEXT)
|
||||
private readonly context: TuiDialogContext<
|
||||
BackupTarget,
|
||||
{ type: BackupType }
|
||||
>,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly api: ApiService,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly errorService: ErrorService,
|
||||
) {}
|
||||
|
||||
get type(): BackupType {
|
||||
return this.context.data.type
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.getTargets()
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
|
||||
select(target: BackupTarget): void {
|
||||
this.modalCtrl.dismiss(target)
|
||||
this.context.completeWith(target)
|
||||
}
|
||||
|
||||
goToTargets() {
|
||||
this.modalCtrl
|
||||
.dismiss()
|
||||
.then(() => this.navCtrl.navigateForward(`/backups/targets`))
|
||||
this.context.$implicit.complete()
|
||||
this.navCtrl.navigateForward(`/backups/targets`)
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
@@ -54,7 +61,7 @@ export class TargetSelectPage {
|
||||
try {
|
||||
this.targets = (await this.api.getBackupTargets({})).saved
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading$.next(false)
|
||||
}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { BackupReport, BackupRun } from 'src/app/services/api/api.types'
|
||||
import { 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/apps/ui/modals/backup-report/backup-report.page'
|
||||
import { BackupReportComponent } from '../../../../modals/backup-report/backup-report.component'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
|
||||
@Component({
|
||||
selector: 'backup-history',
|
||||
@@ -18,9 +19,9 @@ export class BackupHistoryPage {
|
||||
loading$ = new BehaviorSubject(true)
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly api: ApiService,
|
||||
) {}
|
||||
|
||||
@@ -28,7 +29,7 @@ export class BackupHistoryPage {
|
||||
try {
|
||||
this.runs = await this.api.getBackupRuns({})
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading$.next(false)
|
||||
}
|
||||
@@ -42,15 +43,16 @@ export class BackupHistoryPage {
|
||||
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()
|
||||
presentModalReport(run: BackupRun) {
|
||||
this.dialogs
|
||||
.open(new PolymorpheusComponent(BackupReportComponent), {
|
||||
label: 'Backup Report',
|
||||
data: {
|
||||
report: run.report,
|
||||
timestamp: run['completed-at'],
|
||||
},
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
async toggleChecked(id: string) {
|
||||
@@ -71,20 +73,16 @@ export class BackupHistoryPage {
|
||||
|
||||
async deleteSelected(): Promise<void> {
|
||||
const ids = Object.keys(this.selected)
|
||||
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Deleting...',
|
||||
})
|
||||
await loader.present()
|
||||
const loader = this.loader.open('Deleting...').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.deleteBackupRuns({ ids })
|
||||
this.selected = {}
|
||||
this.runs = this.runs.filter(r => !ids.includes(r.id))
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
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 { NgModule } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import {
|
||||
TuiButtonModule,
|
||||
TuiNotificationModule,
|
||||
TuiWrapperModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiInputModule, TuiToggleModule } from '@taiga-ui/kit'
|
||||
import { BackupJobsPage } from './backup-jobs.page'
|
||||
import { EditJobComponent } from './edit-job/edit-job.component'
|
||||
import { ToHumanCronPipe } from './pipes'
|
||||
import { TargetSelectPageModule } from '../../modals/target-select/target-select.module'
|
||||
import { TargetPipesModule } from '../../pipes/target-pipes.module'
|
||||
|
||||
@@ -26,13 +30,12 @@ const routes: Routes = [
|
||||
FormsModule,
|
||||
TargetSelectPageModule,
|
||||
TargetPipesModule,
|
||||
TuiNotificationModule,
|
||||
TuiButtonModule,
|
||||
TuiInputModule,
|
||||
TuiToggleModule,
|
||||
TuiWrapperModule,
|
||||
],
|
||||
declarations: [
|
||||
BackupJobsPage,
|
||||
ToHumanCronPipe,
|
||||
NewJobPage,
|
||||
EditJobPage,
|
||||
JobOptionsComponent,
|
||||
],
|
||||
declarations: [BackupJobsPage, ToHumanCronPipe, EditJobComponent],
|
||||
})
|
||||
export class BackupJobsPageModule {}
|
||||
|
||||
@@ -8,20 +8,16 @@
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding-top">
|
||||
<ion-item-group>
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>
|
||||
Scheduling automatic backups is an excellent way to ensure your
|
||||
Embassy data is safely backed up. Your Embassy will issue a
|
||||
notification whenever one of your scheduled backups succeeds or fails.
|
||||
<a [href]="docsUrl" target="_blank" rel="noreferrer">
|
||||
View instructions
|
||||
</a>
|
||||
</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<div class="ion-padding-start ion-padding-end">
|
||||
<tui-notification>
|
||||
Scheduling automatic backups is an excellent way to ensure your Embassy
|
||||
data is safely backed up. Your Embassy will issue a notification whenever
|
||||
one of your scheduled backups succeeds or fails.
|
||||
<a [href]="docsUrl" target="_blank" rel="noreferrer">View instructions</a>
|
||||
</tui-notification>
|
||||
</div>
|
||||
|
||||
<ion-item-group>
|
||||
<ion-item-divider>
|
||||
Saved Jobs
|
||||
<ion-button
|
||||
@@ -31,7 +27,7 @@
|
||||
(click)="presentModalCreate()"
|
||||
>
|
||||
<ion-icon slot="start" name="add"></ion-icon>
|
||||
New Job
|
||||
Create New Job
|
||||
</ion-button>
|
||||
</ion-item-divider>
|
||||
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
import { Component } from '@angular/core'
|
||||
import {
|
||||
AlertController,
|
||||
LoadingController,
|
||||
ModalController,
|
||||
} from '@ionic/angular'
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { BehaviorSubject, filter } from 'rxjs'
|
||||
import { BackupJob } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { EditJobPage } from './edit-job/edit-job.page'
|
||||
import { NewJobPage } from './new-job/new-job.page'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { EditJobComponent } from './edit-job/edit-job.component'
|
||||
import { BackupJobBuilder } from './edit-job/job-builder'
|
||||
|
||||
@Component({
|
||||
selector: 'backup-jobs',
|
||||
@@ -25,10 +23,9 @@ export class BackupJobsPage {
|
||||
loading$ = new BehaviorSubject(true)
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly api: ApiService,
|
||||
) {}
|
||||
|
||||
@@ -36,86 +33,64 @@ export class BackupJobsPage {
|
||||
try {
|
||||
this.jobs = await this.api.getBackupJobs({})
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.errorService.handleError(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()
|
||||
presentModalCreate() {
|
||||
this.dialogs
|
||||
.open<BackupJob>(new PolymorpheusComponent(EditJobComponent), {
|
||||
label: 'Create New Job',
|
||||
data: new BackupJobBuilder({
|
||||
name: `Backup Job ${this.jobs.length + 1}`,
|
||||
}),
|
||||
})
|
||||
.subscribe(job => this.jobs.push(job))
|
||||
}
|
||||
|
||||
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()
|
||||
presentModalUpdate(data: BackupJob) {
|
||||
this.dialogs
|
||||
.open<BackupJob>(new PolymorpheusComponent(EditJobComponent), {
|
||||
label: 'Edit Job',
|
||||
data: new BackupJobBuilder(data),
|
||||
})
|
||||
.subscribe(job => {
|
||||
data.name = job.name
|
||||
data.target = job.target
|
||||
data.cron = job.cron
|
||||
data['package-ids'] = job['package-ids']
|
||||
})
|
||||
}
|
||||
|
||||
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',
|
||||
presentAlertDelete(id: string, index: number) {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, {
|
||||
label: 'Confirm',
|
||||
size: 's',
|
||||
data: {
|
||||
content: 'Delete backup job? This action cannot be undone.',
|
||||
yes: 'Delete',
|
||||
no: 'Cancel',
|
||||
},
|
||||
{
|
||||
text: 'Delete',
|
||||
handler: () => {
|
||||
this.delete(id, index)
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => {
|
||||
this.delete(id, index)
|
||||
})
|
||||
}
|
||||
|
||||
private async delete(id: string, i: number): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Deleting...',
|
||||
})
|
||||
await loader.present()
|
||||
const loader = this.loader.open('Deleting...').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.removeBackupTarget({ id })
|
||||
this.jobs.splice(i, 1)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
<form>
|
||||
<tui-input [ngModelOptions]="{ standalone: true }" [(ngModel)]="job.name">
|
||||
Job Name
|
||||
<input tuiTextfield placeholder="My Backup Job" />
|
||||
</tui-input>
|
||||
|
||||
<button
|
||||
tuiWrapper
|
||||
appearance="secondary"
|
||||
type="button"
|
||||
class="button"
|
||||
(click)="presentModalTarget()"
|
||||
>
|
||||
Target
|
||||
<span class="value">{{ job.target.type || 'Select target' }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
tuiWrapper
|
||||
appearance="secondary"
|
||||
type="button"
|
||||
class="button"
|
||||
(click)="presentModalPackages()"
|
||||
>
|
||||
Packages
|
||||
<span class="value">{{ job['package-ids'].length + ' selected' }}</span>
|
||||
</button>
|
||||
|
||||
<tui-input [ngModelOptions]="{ standalone: true }" [(ngModel)]="job.cron">
|
||||
Schedule
|
||||
<input tuiTextfield placeholder="* * * * *" />
|
||||
</tui-input>
|
||||
|
||||
<p *ngIf="job.cron | toHumanCron as human" [style.color]="human.color">
|
||||
{{ human.message }}
|
||||
</p>
|
||||
|
||||
<div *ngIf="!job.job.id" class="toggle">
|
||||
Also Execute Now
|
||||
<tui-toggle
|
||||
size="l"
|
||||
[ngModelOptions]="{ standalone: true }"
|
||||
[(ngModel)]="job.now"
|
||||
></tui-toggle>
|
||||
</div>
|
||||
<button tuiButton class="submit" (click)="save()">Save Job</button>
|
||||
</form>
|
||||
@@ -0,0 +1,33 @@
|
||||
.button {
|
||||
height: var(--tui-height-l);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 1rem 0;
|
||||
padding: 0 1rem;
|
||||
border-radius: var(--tui-radius-m);
|
||||
font: var(--tui-font-text-l);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.value {
|
||||
font: var(--tui-font-text-m);
|
||||
color: var(--tui-positive);
|
||||
}
|
||||
|
||||
.toggle {
|
||||
height: var(--tui-height-l);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 1rem;
|
||||
box-shadow: inset 0 0 0 1px var(--tui-base-03);
|
||||
font: var(--tui-font-text-l);
|
||||
font-weight: bold;
|
||||
border-radius: var(--tui-radius-m);
|
||||
}
|
||||
|
||||
.submit {
|
||||
float: right;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Component, Inject } from '@angular/core'
|
||||
import { TuiDialogContext, TuiDialogService } from '@taiga-ui/core'
|
||||
import {
|
||||
POLYMORPHEUS_CONTEXT,
|
||||
PolymorpheusComponent,
|
||||
} from '@tinkoff/ng-polymorpheus'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { BackupJob, BackupTarget } from 'src/app/services/api/api.types'
|
||||
import { TargetSelectPage } from '../../../modals/target-select/target-select.page'
|
||||
import { BackupSelectPage } from '../../../modals/backup-select/backup-select.page'
|
||||
import { BackupJobBuilder } from './job-builder'
|
||||
|
||||
@Component({
|
||||
selector: 'edit-job',
|
||||
templateUrl: './edit-job.component.html',
|
||||
styleUrls: ['./edit-job.component.scss'],
|
||||
})
|
||||
export class EditJobComponent {
|
||||
constructor(
|
||||
@Inject(POLYMORPHEUS_CONTEXT)
|
||||
private readonly context: TuiDialogContext<BackupJob, BackupJobBuilder>,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly api: ApiService,
|
||||
private readonly errorService: ErrorService,
|
||||
) {}
|
||||
|
||||
get job() {
|
||||
return this.context.data
|
||||
}
|
||||
|
||||
async save() {
|
||||
const loader = this.loader.open('Saving Job').subscribe()
|
||||
|
||||
try {
|
||||
const { id } = this.job.job
|
||||
let job: BackupJob
|
||||
|
||||
if (id) {
|
||||
job = await this.api.updateBackupJob(this.job.buildUpdate(id))
|
||||
} else {
|
||||
job = await this.api.createBackupJob(this.job.buildCreate())
|
||||
}
|
||||
|
||||
this.context.completeWith(job)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
presentModalTarget() {
|
||||
this.dialogs
|
||||
.open<BackupTarget>(new PolymorpheusComponent(TargetSelectPage), {
|
||||
label: 'Select Backup Target',
|
||||
data: { type: 'create' },
|
||||
})
|
||||
.subscribe(target => {
|
||||
this.job.target = target
|
||||
})
|
||||
}
|
||||
|
||||
presentModalPackages() {
|
||||
this.dialogs
|
||||
.open<string[]>(new PolymorpheusComponent(BackupSelectPage), {
|
||||
label: 'Select Services to Back Up',
|
||||
data: { btnText: 'Done' },
|
||||
})
|
||||
.subscribe(id => {
|
||||
this.job['package-ids'] = id
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Edit Job</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<ion-item-group>
|
||||
<job-options [job]="job"></job-options>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="end" class="ion-padding-end">
|
||||
<ion-button
|
||||
fill="solid"
|
||||
color="primary"
|
||||
[disabled]="!job.target || saving"
|
||||
(click)="save()"
|
||||
class="enter-click btn-128"
|
||||
[class.no-click]="saving"
|
||||
>
|
||||
Save
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
@@ -1,3 +0,0 @@
|
||||
h2 {
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { BackupJob, BackupTarget, RR } from 'src/app/services/api/api.types'
|
||||
|
||||
export class BackupJobBuilder {
|
||||
name: string
|
||||
target: BackupTarget
|
||||
cron: string
|
||||
'package-ids': string[]
|
||||
now = false
|
||||
|
||||
constructor(readonly job: Partial<BackupJob>) {
|
||||
const { name, target, cron } = job
|
||||
this.name = name || ''
|
||||
this.target = target || ({} as BackupTarget)
|
||||
this.cron = cron || '0 2 * * *'
|
||||
this['package-ids'] = job['package-ids'] || []
|
||||
}
|
||||
|
||||
buildCreate(): RR.CreateBackupJobReq {
|
||||
const { name, target, cron, now } = this
|
||||
|
||||
return {
|
||||
name,
|
||||
'target-id': target.id,
|
||||
cron,
|
||||
'package-ids': this['package-ids'],
|
||||
now,
|
||||
}
|
||||
}
|
||||
|
||||
buildUpdate(id: string): RR.UpdateBackupJobReq {
|
||||
const { name, target, cron } = this
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
'target-id': target.id,
|
||||
cron,
|
||||
'package-ids': this['package-ids'],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<div class="ion-padding-start">
|
||||
<p class="input-label">Job Name</p>
|
||||
<ion-item color="dark">
|
||||
<ion-input placeholder="My Backup Job" [(ngModel)]="job.name"></ion-input>
|
||||
</ion-item>
|
||||
</div>
|
||||
|
||||
<ion-item button (click)="presentModalTarget()">
|
||||
<ion-label>
|
||||
<h2>Target</h2>
|
||||
</ion-label>
|
||||
<ion-note slot="end" color="success">
|
||||
{{ job.target.type || 'Select target' }}
|
||||
</ion-note>
|
||||
</ion-item>
|
||||
|
||||
<ion-item button (click)="presentModalPackages()">
|
||||
<ion-label>
|
||||
<h2>Packages</h2>
|
||||
</ion-label>
|
||||
<ion-note slot="end" color="success">
|
||||
{{ job['package-ids'].length + ' selected' }}
|
||||
</ion-note>
|
||||
</ion-item>
|
||||
|
||||
<div class="ion-padding-start">
|
||||
<p class="input-label">Schedule</p>
|
||||
<ion-item color="dark">
|
||||
<ion-input placeholder="* * * * *" [(ngModel)]="job.cron"></ion-input>
|
||||
</ion-item>
|
||||
<p *ngIf="job.cron | toHumanCron as human" style="padding-left: 6px">
|
||||
<ion-text [color]="human.color">{{ human.message }}</ion-text>
|
||||
</p>
|
||||
</div>
|
||||
@@ -1,9 +0,0 @@
|
||||
h2 {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
margin-bottom: 6px;
|
||||
font-size: medium;
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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<BackupTarget>().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<BackupJob>) {
|
||||
const { name, target, cron } = job
|
||||
this.name = name || ''
|
||||
this.target = target || ({} as BackupTarget)
|
||||
this.cron = cron || '0 2 * * *'
|
||||
this['package-ids'] = job['package-ids'] || []
|
||||
}
|
||||
|
||||
buildCreate(): RR.CreateBackupJobReq {
|
||||
const { name, target, cron, now } = this
|
||||
|
||||
return {
|
||||
name,
|
||||
'target-id': target.id,
|
||||
cron,
|
||||
'package-ids': this['package-ids'],
|
||||
now,
|
||||
}
|
||||
}
|
||||
|
||||
buildUpdate(id: string): RR.UpdateBackupJobReq {
|
||||
const { name, target, cron } = this
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
'target-id': target.id,
|
||||
cron,
|
||||
'package-ids': this['package-ids'],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Create New Job</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<ion-item-group>
|
||||
<job-options [job]="job"></job-options>
|
||||
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>Also Execute Now</h2>
|
||||
</ion-label>
|
||||
<ion-toggle slot="end" [(ngModel)]="job.now"></ion-toggle>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="end" class="ion-padding-end">
|
||||
<ion-button
|
||||
fill="solid"
|
||||
color="primary"
|
||||
[disabled]="!job.target || saving"
|
||||
(click)="save()"
|
||||
class="enter-click btn-128"
|
||||
[class.no-click]="saving"
|
||||
>
|
||||
Save Job
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
@@ -1,3 +0,0 @@
|
||||
h2 {
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ export class ToHumanCronPipe implements PipeTransform {
|
||||
transform(cron: string): { message: string; color: string } {
|
||||
const toReturn = {
|
||||
message: '',
|
||||
color: 'success',
|
||||
color: 'var(--tui-positive)',
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -26,7 +26,7 @@ export class ToHumanCronPipe implements PipeTransform {
|
||||
toReturn.message = human
|
||||
} catch (e) {
|
||||
toReturn.message = e as string
|
||||
toReturn.color = 'danger'
|
||||
toReturn.color = 'var(--tui-negative)'
|
||||
}
|
||||
|
||||
return toReturn
|
||||
|
||||
@@ -6,6 +6,7 @@ import { UnitConversionPipesModule } from '@start9labs/shared'
|
||||
import { SkeletonListComponentModule } from 'src/app/common/skeleton-list/skeleton-list.component.module'
|
||||
import { FormPageModule } from 'src/app/apps/ui/modals/form/form.module'
|
||||
import { BackupTargetsPage } from './backup-targets.page'
|
||||
import { TuiNotificationModule } from '@taiga-ui/core'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
@@ -23,6 +24,7 @@ const routes: Routes = [
|
||||
UnitConversionPipesModule,
|
||||
FormPageModule,
|
||||
RouterModule.forChild(routes),
|
||||
TuiNotificationModule,
|
||||
],
|
||||
})
|
||||
export class BackupTargetsPageModule {}
|
||||
|
||||
@@ -8,21 +8,17 @@
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding-top">
|
||||
<ion-item-group>
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>
|
||||
Backup targets are physical or virtual locations for storing encrypted
|
||||
backups. They can be physical drives plugged into your server, shared
|
||||
folders on your Local Area Network (LAN), or third party clouds such
|
||||
as Dropbox or Google Drive.
|
||||
<a [href]="docsUrl" target="_blank" rel="noreferrer">
|
||||
View instructions
|
||||
</a>
|
||||
</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<div class="ion-padding-start ion-padding-end">
|
||||
<tui-notification>
|
||||
Backup targets are physical or virtual locations for storing encrypted
|
||||
backups. They can be physical drives plugged into your server, shared
|
||||
folders on your Local Area Network (LAN), or third party clouds such as
|
||||
Dropbox or Google Drive.
|
||||
<a [href]="docsUrl" target="_blank" rel="noreferrer">View instructions</a>
|
||||
</tui-notification>
|
||||
</div>
|
||||
|
||||
<ion-item-group>
|
||||
<!-- unknown disks -->
|
||||
<ion-item-divider>
|
||||
Unknown Physical Drives
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
import { BehaviorSubject, filter } from 'rxjs'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import {
|
||||
InputSpec,
|
||||
unionSelectKey,
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
} from '@start9labs/start-sdk/lib/config/configTypes'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { FormPage } from 'src/app/apps/ui/modals/form/form.page'
|
||||
import { LoadingService } from 'src/app/common/loading/loading.service'
|
||||
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec'
|
||||
|
||||
type BackupConfig =
|
||||
|
||||
@@ -4,7 +4,6 @@ import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { BadgeMenuComponentModule } from 'src/app/common/badge-menu-button/badge-menu.component.module'
|
||||
import { InsecureWarningComponentModule } from 'src/app/common/insecure-warning/insecure-warning.module'
|
||||
import { GenericInputComponentModule } from 'src/app/apps/ui/modals/generic-input/generic-input.component.module'
|
||||
import { BackupCreateDirective } from '../../directives/backup-create.directive'
|
||||
import { BackupRestoreDirective } from '../../directives/backup-restore.directive'
|
||||
import {
|
||||
@@ -15,6 +14,7 @@ import { BackupSelectPageModule } from '../../modals/backup-select/backup-select
|
||||
import { RecoverSelectPageModule } from '../../modals/recover-select/recover-select.module'
|
||||
import { TargetPipesModule } from '../../pipes/target-pipes.module'
|
||||
import { BackupsPage } from './backups.page'
|
||||
import { PromptModule } from 'src/app/apps/ui/modals/prompt/prompt.module'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
@@ -33,7 +33,7 @@ const routes: Routes = [
|
||||
BadgeMenuComponentModule,
|
||||
InsecureWarningComponentModule,
|
||||
TargetPipesModule,
|
||||
GenericInputComponentModule,
|
||||
PromptModule,
|
||||
],
|
||||
declarations: [
|
||||
BackupsPage,
|
||||
|
||||
@@ -14,9 +14,9 @@ import {
|
||||
ItemModule,
|
||||
SearchModule,
|
||||
SkeletonModule,
|
||||
StoreIconComponentModule,
|
||||
} from '@start9labs/marketplace'
|
||||
import { BadgeMenuComponentModule } from 'src/app/common/badge-menu-button/badge-menu.component.module'
|
||||
import { StoreIconComponentModule } from 'src/app/common/store-icon/store-icon.component.module'
|
||||
import { MarketplaceStatusModule } from '../marketplace-status/marketplace-status.module'
|
||||
import { MarketplaceListPage } from './marketplace-list.page'
|
||||
import { MarketplaceSettingsPageModule } from './marketplace-settings/marketplace-settings.module'
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
class="icon"
|
||||
size="80px"
|
||||
[url]="details.url"
|
||||
[marketplace]="config.marketplace"
|
||||
></store-icon>
|
||||
<h1 class="montserrat">{{ details.name }}</h1>
|
||||
</div>
|
||||
|
||||
@@ -41,7 +41,7 @@ export class MarketplaceListPage {
|
||||
if (url === start9) {
|
||||
color = 'success'
|
||||
description =
|
||||
'Services from this registry are packaged and maintained by the Start9 team. If you experience an issue or have a question related to a service from this registry, one of our dedicated support staff will be happy to assist you.'
|
||||
'Services from this registry are packaged and maintained by the Start9 team. If you experience an issue or have questions related to a service from this registry, one of our dedicated support staff will be happy to assist you.'
|
||||
} else if (url === community) {
|
||||
color = 'tertiary'
|
||||
description =
|
||||
@@ -75,7 +75,7 @@ export class MarketplaceListPage {
|
||||
@Inject(AbstractMarketplaceService)
|
||||
private readonly marketplaceService: MarketplaceService,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly config: ConfigService,
|
||||
readonly config: ConfigService,
|
||||
private readonly route: ActivatedRoute,
|
||||
) {}
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ import {
|
||||
TuiHostedDropdownModule,
|
||||
TuiSvgModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { StoreIconComponentModule } from 'src/app/common/store-icon/store-icon.component.module'
|
||||
import { FormPageModule } from 'src/app/apps/ui/modals/form/form.module'
|
||||
import { MarketplaceSettingsPage } from './marketplace-settings.page'
|
||||
import { StoreIconComponentModule } from '@start9labs/marketplace'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
(click)="s.selected ? '' : connect(s.url)"
|
||||
>
|
||||
<ion-avatar slot="start">
|
||||
<store-icon [url]="s.url"></store-icon>
|
||||
<store-icon
|
||||
[url]="s.url"
|
||||
[marketplace]="config.marketplace"
|
||||
></store-icon>
|
||||
</ion-avatar>
|
||||
<ion-label>
|
||||
<h2>{{ s.name }}</h2>
|
||||
@@ -42,7 +45,11 @@
|
||||
>
|
||||
<ion-item detail="false" [button]="!a.selected">
|
||||
<ion-avatar slot="start">
|
||||
<store-icon [url]="a.url" size="36px"></store-icon>
|
||||
<store-icon
|
||||
[url]="a.url"
|
||||
[marketplace]="config.marketplace"
|
||||
size="36px"
|
||||
></store-icon>
|
||||
</ion-avatar>
|
||||
<ion-label>
|
||||
<h2>{{ a.name }}</h2>
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
|
||||
import { ErrorService, sameUrl, toUrl } from '@start9labs/shared'
|
||||
import {
|
||||
ErrorService,
|
||||
LoadingService,
|
||||
sameUrl,
|
||||
toUrl,
|
||||
} from '@start9labs/shared'
|
||||
import { AbstractMarketplaceService } from '@start9labs/marketplace'
|
||||
import { ValueSpecObject } from '@start9labs/start-sdk/lib/config/configTypes'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
@@ -11,7 +16,7 @@ import { DataModel, UIStore } from 'src/app/services/patch-db/data-model'
|
||||
import { MarketplaceService } from 'src/app/services/marketplace.service'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { FormPage } from 'src/app/apps/ui/modals/form/form.page'
|
||||
import { LoadingService } from 'src/app/common/loading/loading.service'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-settings',
|
||||
@@ -47,6 +52,7 @@ export class MarketplaceSettingsPage {
|
||||
private readonly marketplaceService: MarketplaceService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
readonly config: ConfigService,
|
||||
) {}
|
||||
|
||||
async presentModalAdd() {
|
||||
|
||||
@@ -4,17 +4,19 @@ import {
|
||||
Inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { AlertController, LoadingController } from '@ionic/angular'
|
||||
import {
|
||||
AbstractMarketplaceService,
|
||||
MarketplacePkg,
|
||||
} from '@start9labs/marketplace'
|
||||
import {
|
||||
Emver,
|
||||
ErrorToastService,
|
||||
ErrorService,
|
||||
isEmptyObject,
|
||||
LoadingService,
|
||||
sameUrl,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { filter, firstValueFrom, of, Subscription, switchMap } from 'rxjs'
|
||||
import {
|
||||
DataModel,
|
||||
PackageDataEntry,
|
||||
@@ -27,7 +29,7 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { Breakages } from 'src/app/services/api/api.types'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { getAllPackages } from 'src/app/util/get-package-data'
|
||||
import { firstValueFrom } from 'rxjs'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-show-controls',
|
||||
@@ -50,13 +52,13 @@ export class MarketplaceShowControlsComponent {
|
||||
readonly PackageState = PackageState
|
||||
|
||||
constructor(
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly ClientStorageService: ClientStorageService,
|
||||
@Inject(AbstractMarketplaceService)
|
||||
private readonly marketplaceService: MarketplaceService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly emver: Emver,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
@@ -112,39 +114,26 @@ export class MarketplaceShowControlsComponent {
|
||||
}
|
||||
|
||||
return new Promise(async resolve => {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Warning',
|
||||
message: `This service was originally ${
|
||||
originalName ? 'installed from ' + originalName : 'side loaded'
|
||||
}, but you are currently connected to ${name}. To install from ${name} anyway, click "Continue".`,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
handler: () => {
|
||||
resolve(false)
|
||||
},
|
||||
this.dialogs
|
||||
.open<boolean>(TUI_PROMPT, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content: `This service was originally ${
|
||||
originalName ? 'installed from ' + originalName : 'side loaded'
|
||||
}, but you are currently connected to ${name}. To install from ${name} anyway, click "Continue".`,
|
||||
yes: 'Continue',
|
||||
no: 'Cancel',
|
||||
},
|
||||
{
|
||||
text: 'Continue',
|
||||
handler: () => {
|
||||
resolve(true)
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
cssClass: 'alert-warning-message',
|
||||
})
|
||||
|
||||
await alert.present()
|
||||
})
|
||||
.subscribe(response => resolve(response))
|
||||
})
|
||||
}
|
||||
|
||||
private async dryInstall(url: string) {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Checking dependent services...',
|
||||
})
|
||||
await loader.present()
|
||||
const loader = this.loader
|
||||
.open('Checking dependent services...')
|
||||
.subscribe()
|
||||
|
||||
const { id, version } = this.pkg.manifest
|
||||
|
||||
@@ -157,49 +146,47 @@ export class MarketplaceShowControlsComponent {
|
||||
if (isEmptyObject(breakages)) {
|
||||
this.install(url, loader)
|
||||
} else {
|
||||
await loader.dismiss()
|
||||
loader.unsubscribe()
|
||||
const proceed = await this.presentAlertBreakages(breakages)
|
||||
if (proceed) {
|
||||
this.install(url)
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.errorService.handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
private async alertInstall(url: string) {
|
||||
const installAlert = this.pkg.manifest.alerts.install
|
||||
|
||||
if (!installAlert) return this.install(url)
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Alert',
|
||||
message: installAlert,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Install',
|
||||
handler: () => {
|
||||
this.install(url)
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
private alertInstall(url: string) {
|
||||
of(this.pkg.manifest.alerts.install)
|
||||
.pipe(
|
||||
switchMap(content =>
|
||||
content
|
||||
? of(true)
|
||||
: this.dialogs.open<boolean>(TUI_PROMPT, {
|
||||
label: 'Alert',
|
||||
size: 's',
|
||||
data: {
|
||||
content,
|
||||
yes: 'Install',
|
||||
no: 'Cancel',
|
||||
},
|
||||
}),
|
||||
),
|
||||
filter(Boolean),
|
||||
)
|
||||
.subscribe(() => this.install(url))
|
||||
}
|
||||
|
||||
private async install(url: string, loader?: HTMLIonLoadingElement) {
|
||||
private async install(url: string, loader?: Subscription) {
|
||||
const message = 'Beginning Install...'
|
||||
|
||||
if (loader) {
|
||||
loader.message = message
|
||||
loader.unsubscribe()
|
||||
loader.closed = false
|
||||
loader.add(this.loader.open(message).subscribe())
|
||||
} else {
|
||||
loader = await this.loadingCtrl.create({ message })
|
||||
await loader.present()
|
||||
loader = this.loader.open(message).subscribe()
|
||||
}
|
||||
|
||||
const { id, version } = this.pkg.manifest
|
||||
@@ -207,46 +194,34 @@ export class MarketplaceShowControlsComponent {
|
||||
try {
|
||||
await this.marketplaceService.installPackage(id, version, url)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async presentAlertBreakages(breakages: Breakages): Promise<boolean> {
|
||||
let message: string =
|
||||
let content: string =
|
||||
'As a result of this update, the following services will no longer work properly and may crash:<ul>'
|
||||
const localPkgs = await getAllPackages(this.patch)
|
||||
const bullets = Object.keys(breakages).map(id => {
|
||||
const title = localPkgs[id].manifest.title
|
||||
return `<li><b>${title}</b></li>`
|
||||
})
|
||||
message = `${message}${bullets.join('')}</ul>`
|
||||
content = `${content}${bullets.join('')}</ul>`
|
||||
|
||||
return new Promise(async resolve => {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Warning',
|
||||
message,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
handler: () => {
|
||||
resolve(false)
|
||||
},
|
||||
this.dialogs
|
||||
.open<boolean>(TUI_PROMPT, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content,
|
||||
yes: 'Continue',
|
||||
no: 'Cancel',
|
||||
},
|
||||
{
|
||||
text: 'Continue',
|
||||
handler: () => {
|
||||
resolve(true)
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
cssClass: 'alert-warning-message',
|
||||
})
|
||||
|
||||
await alert.present()
|
||||
})
|
||||
.subscribe(response => resolve(response))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
import { TuiPromptModule } from '@taiga-ui/kit'
|
||||
import { NotificationsPage } from './notifications.page'
|
||||
import { BadgeMenuComponentModule } from 'src/app/common/badge-menu-button/badge-menu.component.module'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
import { BackupReportPageModule } from '../../modals/backup-report/backup-report.module'
|
||||
|
||||
const routes: Routes = [
|
||||
@@ -22,6 +23,7 @@ const routes: Routes = [
|
||||
BadgeMenuComponentModule,
|
||||
SharedPipesModule,
|
||||
BackupReportPageModule,
|
||||
TuiPromptModule,
|
||||
],
|
||||
declarations: [NotificationsPage],
|
||||
})
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { filter, first } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import {
|
||||
ServerNotifications,
|
||||
NotificationLevel,
|
||||
ServerNotification,
|
||||
} from 'src/app/services/api/api.types'
|
||||
import {
|
||||
AlertController,
|
||||
LoadingController,
|
||||
ModalController,
|
||||
} from '@ionic/angular'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { BackupReportPage } from 'src/app/apps/ui/modals/backup-report/backup-report.page'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { BackupReportComponent } from '../../modals/backup-report/backup-report.component'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { first } from 'rxjs'
|
||||
|
||||
@Component({
|
||||
selector: 'notifications',
|
||||
@@ -33,10 +31,9 @@ export class NotificationsPage {
|
||||
|
||||
constructor(
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
@@ -66,77 +63,55 @@ export class NotificationsPage {
|
||||
|
||||
return notifications
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.errorService.handleError(e)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
async delete(id: number, index: number): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Deleting...',
|
||||
})
|
||||
await loader.present()
|
||||
const loader = this.loader.open('Deleting...').subscribe()
|
||||
|
||||
try {
|
||||
await this.embassyApi.deleteNotification({ id })
|
||||
this.notifications.splice(index, 1)
|
||||
this.beforeCursor = this.notifications[this.notifications.length - 1]?.id
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
async presentAlertDeleteAll() {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Delete All?',
|
||||
message: 'Are you sure you want to delete all notifications?',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
presentAlertDeleteAll() {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, {
|
||||
label: 'Delete All?',
|
||||
size: 's',
|
||||
data: {
|
||||
content: 'Are you sure you want to delete all notifications?',
|
||||
yes: 'Delete',
|
||||
no: 'Cancel',
|
||||
},
|
||||
{
|
||||
text: 'Delete',
|
||||
handler: () => {
|
||||
this.deleteAll()
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => this.deleteAll())
|
||||
}
|
||||
|
||||
async viewBackupReport(notification: ServerNotification<1>) {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: BackupReportPage,
|
||||
componentProps: {
|
||||
report: notification.data,
|
||||
timestamp: notification['created-at'],
|
||||
},
|
||||
})
|
||||
await modal.present()
|
||||
this.dialogs
|
||||
.open(new PolymorpheusComponent(BackupReportComponent), {
|
||||
label: 'Backup Report',
|
||||
data: {
|
||||
report: notification.data,
|
||||
timestamp: notification['created-at'],
|
||||
},
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
async viewFullMessage(header: string, message: string) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header,
|
||||
message,
|
||||
cssClass: 'notification-detail-alert',
|
||||
buttons: [
|
||||
{
|
||||
text: `OK`,
|
||||
handler: () => {
|
||||
alert.dismiss()
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
viewFullMessage(label: string, message: string) {
|
||||
this.dialogs.open(message, { label }).subscribe()
|
||||
}
|
||||
|
||||
truncate(message: string): string {
|
||||
@@ -159,10 +134,7 @@ export class NotificationsPage {
|
||||
}
|
||||
|
||||
private async deleteAll(): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Deleting...',
|
||||
})
|
||||
await loader.present()
|
||||
const loader = this.loader.open('Deleting...').subscribe()
|
||||
|
||||
try {
|
||||
await this.embassyApi.deleteAllNotifications({
|
||||
@@ -171,9 +143,9 @@ export class NotificationsPage {
|
||||
this.notifications = []
|
||||
this.beforeCursor = undefined
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,35 +1,22 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Execution Complete</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()" class="enter-click">
|
||||
<ion-icon name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<h2 class="ion-padding">{{ actionRes.message }}</h2>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<h2 class="ion-padding">{{ actionRes.message }}</h2>
|
||||
|
||||
<div *ngIf="actionRes.value" class="ion-text-center" style="padding: 48px 0">
|
||||
<div *ngIf="actionRes.qr" class="ion-padding-bottom">
|
||||
<qr-code [value]="actionRes.value" size="240"></qr-code>
|
||||
</div>
|
||||
|
||||
<p *ngIf="!actionRes.copyable">{{ actionRes.value }}</p>
|
||||
<a
|
||||
*ngIf="actionRes.copyable"
|
||||
style="cursor: copy"
|
||||
(click)="copy(actionRes.value)"
|
||||
>
|
||||
<b>{{ actionRes.value }}</b>
|
||||
<sup>
|
||||
<ion-icon
|
||||
name="copy-outline"
|
||||
style="padding-left: 6px; font-size: small"
|
||||
></ion-icon>
|
||||
</sup>
|
||||
</a>
|
||||
<div *ngIf="actionRes.value" class="ion-text-center" style="padding: 48px 0">
|
||||
<div *ngIf="actionRes.qr" class="ion-padding-bottom">
|
||||
<qr-code [value]="actionRes.value" size="240"></qr-code>
|
||||
</div>
|
||||
</ion-content>
|
||||
|
||||
<p *ngIf="!actionRes.copyable">{{ actionRes.value }}</p>
|
||||
<a
|
||||
*ngIf="actionRes.copyable"
|
||||
style="cursor: copy"
|
||||
(click)="copyService.copy(actionRes.value)"
|
||||
>
|
||||
<b>{{ actionRes.value }}</b>
|
||||
<sup>
|
||||
<ion-icon
|
||||
name="copy-outline"
|
||||
style="padding-left: 6px; font-size: small"
|
||||
></ion-icon>
|
||||
</sup>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,38 +1,21 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController, ToastController } from '@ionic/angular'
|
||||
import { Component, Inject } from '@angular/core'
|
||||
import { CopyService } from '@start9labs/shared'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { ActionResponse } from 'src/app/services/api/api.types'
|
||||
import { copyToClipboard } from '@start9labs/shared'
|
||||
|
||||
@Component({
|
||||
selector: 'action-success',
|
||||
templateUrl: './action-success.page.html',
|
||||
})
|
||||
export class ActionSuccessPage {
|
||||
@Input()
|
||||
actionRes!: ActionResponse
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly toastCtrl: ToastController,
|
||||
@Inject(POLYMORPHEUS_CONTEXT)
|
||||
private readonly context: TuiDialogContext<void, ActionResponse>,
|
||||
readonly copyService: CopyService,
|
||||
) {}
|
||||
|
||||
async copy(address: string) {
|
||||
let message = ''
|
||||
await copyToClipboard(address || '').then(success => {
|
||||
message = success
|
||||
? 'Copied to clipboard!'
|
||||
: 'Failed to copy to clipboard.'
|
||||
})
|
||||
|
||||
const toast = await this.toastCtrl.create({
|
||||
header: message,
|
||||
position: 'bottom',
|
||||
duration: 1000,
|
||||
})
|
||||
await toast.present()
|
||||
}
|
||||
|
||||
async dismiss() {
|
||||
return this.modalCtrl.dismiss()
|
||||
get actionRes(): ActionResponse {
|
||||
return this.context.data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,27 +6,30 @@ import {
|
||||
PipeTransform,
|
||||
} from '@angular/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { AlertController, ModalController, NavController } from '@ionic/angular'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import {
|
||||
isEmptyObject,
|
||||
getPkgId,
|
||||
WithId,
|
||||
ErrorService,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { filter, switchMap, timer } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import {
|
||||
Action,
|
||||
DataModel,
|
||||
PackageDataEntry,
|
||||
PackageState,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import {
|
||||
isEmptyObject,
|
||||
getPkgId,
|
||||
WithId,
|
||||
ErrorService,
|
||||
} from '@start9labs/shared'
|
||||
import { ActionSuccessPage } from './action-success/action-success.page'
|
||||
import { hasCurrentDeps } from 'src/app/util/has-deps'
|
||||
import { filter } from 'rxjs'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { FormPage } from 'src/app/apps/ui/modals/form/form.page'
|
||||
import { LoadingService } from 'src/app/common/loading/loading.service'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
|
||||
@Component({
|
||||
selector: 'app-actions',
|
||||
@@ -43,8 +46,7 @@ export class AppActionsPage {
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly navCtrl: NavController,
|
||||
@@ -54,13 +56,12 @@ export class AppActionsPage {
|
||||
|
||||
async handleAction(action: WithId<Action>) {
|
||||
if (action.disabled) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Forbidden',
|
||||
message: action.disabled,
|
||||
buttons: ['OK'],
|
||||
cssClass: 'alert-error-message enter-click',
|
||||
})
|
||||
await alert.present()
|
||||
this.dialogs
|
||||
.open(action.disabled, {
|
||||
label: 'Forbidden',
|
||||
size: 's',
|
||||
})
|
||||
.subscribe()
|
||||
} else {
|
||||
if (action['input-spec'] && !isEmptyObject(action['input-spec'])) {
|
||||
this.formDialog.open(FormPage, {
|
||||
@@ -77,24 +78,20 @@ export class AppActionsPage {
|
||||
},
|
||||
})
|
||||
} else {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Confirm',
|
||||
message: `Are you sure you want to execute action "${action.name}"? ${
|
||||
action.warning || ''
|
||||
}`,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, {
|
||||
label: 'Confirm',
|
||||
size: 's',
|
||||
data: {
|
||||
content: `Are you sure you want to execute action "${
|
||||
action.name
|
||||
}"? ${action.warning || ''}`,
|
||||
yes: 'Execute',
|
||||
no: 'Cancel',
|
||||
},
|
||||
{
|
||||
text: 'Execute',
|
||||
handler: async () => this.executeAction(action.id),
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => this.executeAction(action.id))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,34 +99,26 @@ export class AppActionsPage {
|
||||
async tryUninstall(pkg: PackageDataEntry): Promise<void> {
|
||||
const { title, alerts, id } = pkg.manifest
|
||||
|
||||
let message =
|
||||
let content =
|
||||
alerts.uninstall ||
|
||||
`Uninstalling ${title} will permanently delete its data`
|
||||
|
||||
if (await hasCurrentDeps(this.patch, id)) {
|
||||
message = `${message}. Services that depend on ${title} will no longer work properly and may crash`
|
||||
content = `${content}. Services that depend on ${title} will no longer work properly and may crash`
|
||||
}
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Warning',
|
||||
message,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content,
|
||||
yes: 'Uninstall',
|
||||
no: 'Cancel',
|
||||
},
|
||||
{
|
||||
text: 'Uninstall',
|
||||
handler: () => {
|
||||
this.uninstall()
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
cssClass: 'alert-warning-message',
|
||||
})
|
||||
|
||||
await alert.present()
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => this.uninstall())
|
||||
}
|
||||
|
||||
private async uninstall() {
|
||||
@@ -155,20 +144,23 @@ export class AppActionsPage {
|
||||
const loader = this.loader.open('Executing action...').subscribe()
|
||||
|
||||
try {
|
||||
const res = await this.embassyApi.executePackageAction({
|
||||
const data = await this.embassyApi.executePackageAction({
|
||||
id: this.pkgId,
|
||||
'action-id': actionId,
|
||||
input,
|
||||
})
|
||||
|
||||
const successModal = await this.modalCtrl.create({
|
||||
component: ActionSuccessPage,
|
||||
componentProps: {
|
||||
actionRes: res,
|
||||
},
|
||||
})
|
||||
timer(500)
|
||||
.pipe(
|
||||
switchMap(() =>
|
||||
this.dialogs.open(new PolymorpheusComponent(ActionSuccessPage), {
|
||||
label: 'Execution Complete',
|
||||
data,
|
||||
}),
|
||||
),
|
||||
)
|
||||
.subscribe()
|
||||
|
||||
setTimeout(() => successModal.present(), 500)
|
||||
return true
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
size="small"
|
||||
></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" (click)="copy(cred.value)">
|
||||
<ion-button fill="clear" (click)="copyService.copy(cred.value)">
|
||||
<ion-icon
|
||||
slot="icon-only"
|
||||
name="copy-outline"
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ToastController } from '@ionic/angular'
|
||||
import {
|
||||
ErrorToastService,
|
||||
getPkgId,
|
||||
copyToClipboard,
|
||||
} from '@start9labs/shared'
|
||||
import { getPkgId, CopyService, ErrorService } from '@start9labs/shared'
|
||||
import { mask } from 'src/app/util/mask'
|
||||
|
||||
@Component({
|
||||
@@ -23,8 +18,8 @@ export class AppCredentialsPage {
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly toastCtrl: ToastController,
|
||||
private readonly errorService: ErrorService,
|
||||
readonly copyService: CopyService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
@@ -35,20 +30,6 @@ export class AppCredentialsPage {
|
||||
await this.getCredentials()
|
||||
}
|
||||
|
||||
async copy(text: string): Promise<void> {
|
||||
const success = await copyToClipboard(text)
|
||||
const message = success
|
||||
? 'Copied. Clearing clipboard in 20 seconds'
|
||||
: 'Failed to copy.'
|
||||
|
||||
const toast = await this.toastCtrl.create({
|
||||
header: message,
|
||||
position: 'bottom',
|
||||
duration: 2000,
|
||||
})
|
||||
await toast.present()
|
||||
}
|
||||
|
||||
mask(value: string) {
|
||||
return mask(value, 64)
|
||||
}
|
||||
@@ -64,7 +45,7 @@ export class AppCredentialsPage {
|
||||
id: this.pkgId,
|
||||
})
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
name="qr-code-outline"
|
||||
></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button fill="clear" (click)="copy(address)">
|
||||
<ion-button fill="clear" (click)="copyService.copy(address)">
|
||||
<ion-icon size="small" slot="icon-only" name="copy-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { ModalController, ToastController } from '@ionic/angular'
|
||||
import { getPkgId, copyToClipboard } from '@start9labs/shared'
|
||||
import { getPkgId, CopyService } from '@start9labs/shared'
|
||||
import { AddressInfo, DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { map } from 'rxjs'
|
||||
import { QRComponent } from './qr.component'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
|
||||
@Component({
|
||||
selector: 'app-interfaces',
|
||||
@@ -39,38 +40,20 @@ export class AppInterfacesItemComponent {
|
||||
addressInfo!: AddressInfo
|
||||
|
||||
constructor(
|
||||
private readonly toastCtrl: ToastController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
readonly copyService: CopyService,
|
||||
) {}
|
||||
|
||||
launch(url: string): void {
|
||||
window.open(url, '_blank', 'noreferrer')
|
||||
}
|
||||
|
||||
async showQR(text: string): Promise<void> {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: QRComponent,
|
||||
componentProps: {
|
||||
text,
|
||||
},
|
||||
cssClass: 'qr-modal',
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
async copy(address: string): Promise<void> {
|
||||
let message = ''
|
||||
await copyToClipboard(address || '').then(success => {
|
||||
message = success
|
||||
? 'Copied to clipboard!'
|
||||
: 'Failed to copy to clipboard.'
|
||||
})
|
||||
|
||||
const toast = await this.toastCtrl.create({
|
||||
header: message,
|
||||
position: 'bottom',
|
||||
duration: 1000,
|
||||
})
|
||||
await toast.present()
|
||||
showQR(data: string) {
|
||||
this.dialogs
|
||||
.open(new PolymorpheusComponent(QRComponent), {
|
||||
size: 'auto',
|
||||
data,
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { Component, Inject } from '@angular/core'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
|
||||
@Component({
|
||||
selector: 'qr',
|
||||
template: '<qr-code [value]="text" size="400"></qr-code>',
|
||||
template: '<qr-code [value]="context.data" size="400"></qr-code>',
|
||||
})
|
||||
export class QRComponent {
|
||||
@Input() text!: string
|
||||
constructor(
|
||||
@Inject(POLYMORPHEUS_CONTEXT)
|
||||
readonly context: TuiDialogContext<void, string>,
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
*ngIf="manifest['git-hash'] as gitHash; else noHash"
|
||||
button
|
||||
detail="false"
|
||||
(click)="copy(gitHash)"
|
||||
(click)="copyService.copy(gitHash)"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>Git Hash</h2>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { ModalController, ToastController } from '@ionic/angular'
|
||||
import { copyToClipboard, MarkdownComponent } from '@start9labs/shared'
|
||||
import { CopyService, MarkdownComponent } from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { from } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
@@ -15,40 +16,26 @@ export class AppShowAdditionalComponent {
|
||||
pkg!: PackageDataEntry
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly toastCtrl: ToastController,
|
||||
readonly copyService: CopyService,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly api: ApiService,
|
||||
) {}
|
||||
|
||||
async copy(address: string): Promise<void> {
|
||||
const success = await copyToClipboard(address)
|
||||
const message = success
|
||||
? 'Copied to clipboard!'
|
||||
: 'Failed to copy to clipboard.'
|
||||
|
||||
const toast = await this.toastCtrl.create({
|
||||
header: message,
|
||||
position: 'bottom',
|
||||
duration: 1000,
|
||||
})
|
||||
await toast.present()
|
||||
}
|
||||
|
||||
async presentModalLicense() {
|
||||
presentModalLicense() {
|
||||
const { id, version } = this.pkg.manifest
|
||||
|
||||
const modal = await this.modalCtrl.create({
|
||||
componentProps: {
|
||||
title: 'License',
|
||||
content: from(
|
||||
this.api.getStatic(
|
||||
`/public/package-data/${id}/${version}/LICENSE.md`,
|
||||
this.dialogs
|
||||
.open(new PolymorpheusComponent(MarkdownComponent), {
|
||||
label: 'License',
|
||||
size: 'l',
|
||||
data: {
|
||||
content: from(
|
||||
this.api.getStatic(
|
||||
`/public/package-data/${id}/${version}/LICENSE.md`,
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
component: MarkdownComponent,
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
},
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@ import {
|
||||
Input,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { filter } from 'rxjs'
|
||||
import {
|
||||
PackageStatus,
|
||||
PrimaryRendering,
|
||||
@@ -16,8 +21,6 @@ import {
|
||||
PackageDataEntry,
|
||||
PackageState,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { AlertController, LoadingController } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import {
|
||||
@@ -27,7 +30,6 @@ import {
|
||||
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 '../../../launch-menu/launch-menu.component'
|
||||
|
||||
@Component({
|
||||
@@ -51,9 +53,9 @@ export class AppShowStatusComponent {
|
||||
readonly connected$ = this.connectionService.connected$
|
||||
|
||||
constructor(
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly formDialog: FormDialogService,
|
||||
private readonly connectionService: ConnectionService,
|
||||
@@ -122,33 +124,25 @@ export class AppShowStatusComponent {
|
||||
async tryStop(): Promise<void> {
|
||||
const { title, alerts, id } = this.pkg.manifest
|
||||
|
||||
let message = alerts.stop || ''
|
||||
let content = alerts.stop || ''
|
||||
if (await hasCurrentDeps(this.patch, id)) {
|
||||
const depMessage = `Services that depend on ${title} will no longer work properly and may crash`
|
||||
message = message ? `${message}.\n\n${depMessage}` : depMessage
|
||||
content = content ? `${content}.\n\n${depMessage}` : depMessage
|
||||
}
|
||||
|
||||
if (message) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Warning',
|
||||
message,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
if (content) {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content,
|
||||
yes: 'Stop',
|
||||
no: 'Cancel',
|
||||
},
|
||||
{
|
||||
text: 'Stop',
|
||||
handler: () => {
|
||||
this.stop()
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
cssClass: 'alert-warning-message',
|
||||
})
|
||||
|
||||
await alert.present()
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => this.stop())
|
||||
} else {
|
||||
this.stop()
|
||||
}
|
||||
@@ -158,99 +152,71 @@ export class AppShowStatusComponent {
|
||||
const { id, title } = this.pkg.manifest
|
||||
|
||||
if (await hasCurrentDeps(this.patch, id)) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Warning',
|
||||
message: `Services that depend on ${title} may temporarily experiences issues`,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content: `Services that depend on ${title} may temporarily experiences issues`,
|
||||
yes: 'Restart',
|
||||
no: 'Cancel',
|
||||
},
|
||||
{
|
||||
text: 'Restart',
|
||||
handler: () => {
|
||||
this.restart()
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
cssClass: 'alert-warning-message',
|
||||
})
|
||||
|
||||
await alert.present()
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => this.restart())
|
||||
} else {
|
||||
this.restart()
|
||||
}
|
||||
}
|
||||
|
||||
private async start(): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: `Starting...`,
|
||||
})
|
||||
await loader.present()
|
||||
const loader = this.loader.open(`Starting...`).subscribe()
|
||||
|
||||
try {
|
||||
await this.embassyApi.startPackage({ id: this.id })
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async stop(): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Stopping...',
|
||||
})
|
||||
await loader.present()
|
||||
const loader = this.loader.open(`Stopping...`).subscribe()
|
||||
|
||||
try {
|
||||
await this.embassyApi.stopPackage({ id: this.id })
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async restart(): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: `Restarting...`,
|
||||
})
|
||||
await loader.present()
|
||||
const loader = this.loader.open(`Restarting...`).subscribe()
|
||||
|
||||
try {
|
||||
await this.embassyApi.restartPackage({ id: this.id })
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
private async presentAlertStart(message: string): Promise<boolean> {
|
||||
private async presentAlertStart(content: string): Promise<boolean> {
|
||||
return new Promise(async resolve => {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Alert',
|
||||
message,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
handler: () => {
|
||||
resolve(false)
|
||||
},
|
||||
this.dialogs
|
||||
.open<boolean>(TUI_PROMPT, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content,
|
||||
yes: 'Continue',
|
||||
no: 'Cancel',
|
||||
},
|
||||
{
|
||||
text: 'Continue',
|
||||
handler: () => {
|
||||
resolve(true)
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await alert.present()
|
||||
})
|
||||
.subscribe(response => resolve(response))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { Component, Inject } from '@angular/core'
|
||||
import { endWith, firstValueFrom, Subscription } from 'rxjs'
|
||||
import { tuiIsString } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiAlertService,
|
||||
TuiDialogContext,
|
||||
TuiDialogService,
|
||||
TuiNotification,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiDialogContext, TuiDialogService } from '@taiga-ui/core'
|
||||
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { getErrorMessage, isEmptyObject } from '@start9labs/shared'
|
||||
import {
|
||||
ErrorService,
|
||||
getErrorMessage,
|
||||
isEmptyObject,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { InputSpec } from '@start9labs/start-sdk/lib/config/configTypes'
|
||||
import {
|
||||
DataModel,
|
||||
@@ -22,7 +21,6 @@ import { hasCurrentDeps } from 'src/app/util/has-deps'
|
||||
import { getAllPackages, getPackage } from 'src/app/util/get-package-data'
|
||||
import { Breakages } from 'src/app/services/api/api.types'
|
||||
import { InvalidService } from 'src/app/common/form/invalid.service'
|
||||
import { LoadingService } from 'src/app/common/loading/loading.service'
|
||||
import { DependentInfo } from 'src/app/types/dependent-info'
|
||||
import { ActionButton } from 'src/app/apps/ui/modals/form/form.page'
|
||||
|
||||
@@ -63,7 +61,7 @@ export class AppConfigPage {
|
||||
@Inject(POLYMORPHEUS_CONTEXT)
|
||||
private readonly context: TuiDialogContext<void, PackageConfigData>,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly alerts: TuiAlertService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly patchDb: PatchDB<DataModel>,
|
||||
@@ -99,9 +97,7 @@ export class AppConfigPage {
|
||||
this.spec = spec
|
||||
}
|
||||
} catch (e: any) {
|
||||
const message = getErrorMessage(e)
|
||||
|
||||
this.loadingError = tuiIsString(message) ? message : message.value
|
||||
this.loadingError = getErrorMessage(e)
|
||||
} finally {
|
||||
this.loadingText = ''
|
||||
}
|
||||
@@ -119,7 +115,7 @@ export class AppConfigPage {
|
||||
await this.configure(config, loader)
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.showError(e)
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
@@ -186,16 +182,4 @@ export class AppConfigPage {
|
||||
this.dialogs.open<boolean>(TUI_PROMPT, { data }).pipe(endWith(false)),
|
||||
)
|
||||
}
|
||||
|
||||
private showError(e: any) {
|
||||
const message = getErrorMessage(e)
|
||||
|
||||
this.alerts
|
||||
.open(tuiIsString(message) ? message : message.value, {
|
||||
status: TuiNotification.Error,
|
||||
autoClose: false,
|
||||
label: 'Error',
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { ModalController, NavController } from '@ionic/angular'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { MarkdownComponent } from '@start9labs/shared'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import {
|
||||
DataModel,
|
||||
PackageDataEntry,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { from, map, Observable } from 'rxjs'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
|
||||
export interface Button {
|
||||
title: string
|
||||
@@ -31,7 +33,7 @@ export class ToButtonsPipe implements PipeTransform {
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly formDialog: FormDialogService,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
@@ -110,19 +112,19 @@ export class ToButtonsPipe implements PipeTransform {
|
||||
.setDbValue<boolean>(['ack-instructions', id], true)
|
||||
.catch(e => console.error('Failed to mark instructions as seen', e))
|
||||
|
||||
const modal = await this.modalCtrl.create({
|
||||
componentProps: {
|
||||
title: 'Instructions',
|
||||
content: from(
|
||||
this.apiService.getStatic(
|
||||
`/public/package-data/${id}/${version}/INSTRUCTIONS.md`,
|
||||
this.dialogs
|
||||
.open(new PolymorpheusComponent(MarkdownComponent), {
|
||||
label: 'Instructions',
|
||||
size: 'l',
|
||||
data: {
|
||||
content: from(
|
||||
this.apiService.getStatic(
|
||||
`/public/package-data/${id}/${version}/INSTRUCTIONS.md`,
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
component: MarkdownComponent,
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
},
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
|
||||
private viewInMarketplaceButton(pkg: PackageDataEntry): Button {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Config } from '@start9labs/start-sdk/lib/config/builder/config'
|
||||
import { Value } from '@start9labs/start-sdk/lib/config/builder/value'
|
||||
|
||||
export const domainSpec = Config.of({
|
||||
provider: Value.select({
|
||||
name: 'Provider',
|
||||
required: { default: null },
|
||||
values: {
|
||||
namecheap: 'Namecheap',
|
||||
googledomains: 'Google Domains',
|
||||
duckdns: 'Duck DNS',
|
||||
changeip: 'ChangeIP',
|
||||
easydns: 'easyDNS',
|
||||
zoneedit: 'Zoneedit',
|
||||
dyn: 'DynDNS',
|
||||
},
|
||||
}),
|
||||
domain: Value.text({
|
||||
name: 'Domain Name',
|
||||
required: { default: null },
|
||||
placeholder: 'yourdomain.com',
|
||||
}),
|
||||
username: Value.text({
|
||||
name: 'Username',
|
||||
required: { default: null },
|
||||
}),
|
||||
password: Value.text({
|
||||
name: 'Password',
|
||||
required: { default: null },
|
||||
masked: true,
|
||||
}),
|
||||
})
|
||||
|
||||
export type DomainSpec = typeof domainSpec.validator._TYPE
|
||||
@@ -1,14 +1,15 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Routes, RouterModule } from '@angular/router'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { LANPage } from './lan.page'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { DomainsPage } from './domains.page'
|
||||
import { TuiNotificationModule } from '@taiga-ui/core'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: LANPage,
|
||||
component: DomainsPage,
|
||||
},
|
||||
]
|
||||
|
||||
@@ -16,9 +17,10 @@ const routes: Routes = [
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
TuiNotificationModule,
|
||||
RouterModule.forChild(routes),
|
||||
SharedPipesModule,
|
||||
],
|
||||
declarations: [LANPage],
|
||||
declarations: [DomainsPage],
|
||||
})
|
||||
export class LANPageModule {}
|
||||
export class DomainsPageModule {}
|
||||
@@ -0,0 +1,126 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button defaultHref="system"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Domains</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding-top with-widgets">
|
||||
<div class="ion-padding-start ion-padding-end">
|
||||
<tui-notification>
|
||||
Adding domains to StartOS enables you to access your server and service
|
||||
interfaces over clearnet.
|
||||
<a [href]="docsUrl" target="_blank" rel="noreferrer">View instructions</a>
|
||||
</tui-notification>
|
||||
</div>
|
||||
|
||||
<ion-item-group *ngIf="domains$ | async as domains">
|
||||
<ion-item-divider>
|
||||
Start9.me
|
||||
<ion-button
|
||||
*ngIf="!domains.start9Me"
|
||||
class="ion-padding-start"
|
||||
strong
|
||||
size="small"
|
||||
(click)="presentAlertClaimStart9MeDomain()"
|
||||
>
|
||||
<ion-icon slot="start" name="add-outline"></ion-icon>
|
||||
Claim
|
||||
</ion-button>
|
||||
</ion-item-divider>
|
||||
|
||||
<div class="grid-fixed">
|
||||
<ion-grid class="ion-padding">
|
||||
<ion-row class="grid-headings">
|
||||
<ion-col size="3">Domain</ion-col>
|
||||
<ion-col size="2.5">Added</ion-col>
|
||||
<ion-col size="2.5">Provider</ion-col>
|
||||
<ion-col size="2">In Use</ion-col>
|
||||
<ion-col size="2"></ion-col>
|
||||
</ion-row>
|
||||
<ion-row
|
||||
*ngIf="domains.start9Me as start9Me"
|
||||
class="ion-align-items-center grid-row-border"
|
||||
>
|
||||
<ion-col size="3">{{ start9Me.value }}</ion-col>
|
||||
<ion-col size="2.5">{{ start9Me.createdAt| date: 'medium' }}</ion-col>
|
||||
<ion-col size="2.5">Start9</ion-col>
|
||||
<ion-col size="2" *ngIf="start9Me.usedBy as usedBy">
|
||||
<a
|
||||
*ngIf="usedBy.length as qty; else unused"
|
||||
(click)="presentAlertUsedBy(start9Me.value, usedBy)"
|
||||
>
|
||||
{{ qty }} Interfaces
|
||||
</a>
|
||||
<ng-template #unused>
|
||||
<span>N/A</span>
|
||||
</ng-template>
|
||||
</ion-col>
|
||||
<ion-col size="2">
|
||||
<ion-buttons style="float: right">
|
||||
<ion-button size="small" (click)="presentAlertDeleteStart9Me()">
|
||||
<ion-icon name="trash"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</div>
|
||||
|
||||
<ion-item-divider>
|
||||
Custom Domains
|
||||
<ion-button
|
||||
class="ion-padding-start"
|
||||
strong
|
||||
size="small"
|
||||
(click)="presentModalAdd()"
|
||||
>
|
||||
<ion-icon slot="start" name="add"></ion-icon>
|
||||
Add Domain
|
||||
</ion-button>
|
||||
</ion-item-divider>
|
||||
|
||||
<div class="grid-fixed">
|
||||
<ion-grid class="ion-padding">
|
||||
<ion-row class="grid-headings">
|
||||
<ion-col size="3">Domain</ion-col>
|
||||
<ion-col size="2.5">Added</ion-col>
|
||||
<ion-col size="2.5">Provider</ion-col>
|
||||
<ion-col size="2">In Use</ion-col>
|
||||
<ion-col size="2"></ion-col>
|
||||
</ion-row>
|
||||
<ion-row
|
||||
*ngFor="let domain of domains.custom"
|
||||
class="ion-align-items-center grid-row-border"
|
||||
>
|
||||
<ion-col size="3">{{ domain.value }}</ion-col>
|
||||
<ion-col size="2.5">{{ domain.createdAt| date: 'medium' }}</ion-col>
|
||||
<ion-col size="2.5">{{ domain.provider }}</ion-col>
|
||||
<ion-col size="2" *ngIf="domain.usedBy as usedBy">
|
||||
<a
|
||||
*ngIf="usedBy.length as qty; else unused"
|
||||
(click)="presentAlertUsedBy(domain.value, usedBy)"
|
||||
>
|
||||
{{ qty }} Interfaces
|
||||
</a>
|
||||
<ng-template #unused>
|
||||
<span>N/A</span>
|
||||
</ng-template>
|
||||
</ion-col>
|
||||
<ion-col size="2">
|
||||
<ion-buttons style="float: right">
|
||||
<ion-button
|
||||
size="small"
|
||||
(click)="presentAlertDelete(domain.value)"
|
||||
>
|
||||
<ion-icon name="trash"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</div>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user