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:
Matt Hill
2023-03-07 14:37:14 -07:00
committed by Aiden McClelland
parent c03778ec8b
commit 38c2c47789
268 changed files with 4746 additions and 4784 deletions

View File

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

View File

@@ -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,

View File

@@ -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],

View File

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

View File

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

View File

@@ -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',

View File

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

View File

@@ -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;
}

View File

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

View File

@@ -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()
}
})
}
}

View File

@@ -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],
})

View File

@@ -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 {}

View File

@@ -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 {}

View File

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

View File

@@ -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;
}

View 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()
}
}
}

View File

@@ -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 {}

View File

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

View File

@@ -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)
}
}
}

View File

@@ -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 }
}

View File

@@ -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
}
}

View File

@@ -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 *****',
},
]

View File

@@ -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 {}

View File

@@ -1,4 +1,4 @@
<app-loading
<app-initializing
class="ion-page"
(finished)="navCtrl.navigateForward('/login')"
></app-loading>
></app-initializing>

View File

@@ -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()
}
}
}

View File

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

View File

@@ -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
}
}

View File

@@ -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 {}

View File

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

View File

@@ -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,

View File

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

View File

@@ -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('/'))

View File

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

View File

@@ -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 {}

View File

@@ -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
}

View File

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

View File

@@ -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;
}

View File

@@ -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
}

View File

@@ -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 {}

View File

@@ -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())
}
}

View File

@@ -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()
}
}

View File

@@ -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 {}

View File

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

View File

@@ -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;
}

View File

@@ -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
}
}

View File

@@ -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 {}

View File

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

View File

@@ -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;
}

View File

@@ -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()
}
}
}

View File

@@ -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],
})

View File

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

View File

@@ -0,0 +1,3 @@
ion-item {
--background: transparent;
}

View File

@@ -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)
}

View File

@@ -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()
}
}
}

View File

@@ -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 {}

View File

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

View File

@@ -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()
}
}
}

View File

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

View File

@@ -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;
}

View File

@@ -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
})
}
}

View File

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

View File

@@ -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
}
}
}

View File

@@ -0,0 +1,41 @@
import { BackupJob, BackupTarget, RR } from 'src/app/services/api/api.types'
export class BackupJobBuilder {
name: string
target: BackupTarget
cron: string
'package-ids': string[]
now = false
constructor(readonly job: Partial<BackupJob>) {
const { name, target, cron } = job
this.name = name || ''
this.target = target || ({} as BackupTarget)
this.cron = cron || '0 2 * * *'
this['package-ids'] = job['package-ids'] || []
}
buildCreate(): RR.CreateBackupJobReq {
const { name, target, cron, now } = this
return {
name,
'target-id': target.id,
cron,
'package-ids': this['package-ids'],
now,
}
}
buildUpdate(id: string): RR.UpdateBackupJobReq {
const { name, target, cron } = this
return {
id,
name,
'target-id': target.id,
cron,
'package-ids': this['package-ids'],
}
}
}

View File

@@ -1,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>

View File

@@ -1,9 +0,0 @@
h2 {
font-weight: bold;
}
.input-label {
margin-bottom: 6px;
font-size: medium;
font-weight: bold;
}

View File

@@ -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'],
}
}
}

View File

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

View File

@@ -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
}
}
}

View File

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

View File

@@ -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 {}

View File

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

View File

@@ -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 =

View File

@@ -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,

View File

@@ -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'

View File

@@ -27,6 +27,7 @@
class="icon"
size="80px"
[url]="details.url"
[marketplace]="config.marketplace"
></store-icon>
<h1 class="montserrat">{{ details.name }}</h1>
</div>

View File

@@ -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,
) {}

View File

@@ -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: [

View File

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

View File

@@ -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() {

View File

@@ -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))
})
}
}

View File

@@ -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],
})

View File

@@ -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()
}
}
}

View File

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

View File

@@ -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
}
}

View File

@@ -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)

View File

@@ -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"

View File

@@ -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
}

View File

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

View File

@@ -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()
}
}

View File

@@ -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>,
) {}
}

View File

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

View File

@@ -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()
}
}

View File

@@ -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))
})
}
}

View File

@@ -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()
}
}

View File

@@ -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 {

View File

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

View File

@@ -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 {}

View File

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