mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 04:01:58 +00:00
feat: move all frontend projects under the same Angular workspace (#1141)
* feat: move all frontend projects under the same Angular workspace * Refactor/angular workspace (#1154) * update frontend build steps Co-authored-by: waterplea <alexander@inkin.ru> Co-authored-by: Matt Hill <matthewonthemoon@gmail.com> Co-authored-by: Lucy Cifferello <12953208+elvece@users.noreply.github.com>
This commit is contained in:
15
frontend/projects/ui/postprocess.ts
Normal file
15
frontend/projects/ui/postprocess.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { parse } from 'node-html-parser'
|
||||
import * as fs from 'fs'
|
||||
|
||||
let index = fs.readFileSync('./dist/ui/index.html').toString('utf-8')
|
||||
|
||||
const root = parse(index)
|
||||
for (let elem of root.querySelectorAll('link')) {
|
||||
if (elem.getAttribute('rel') === 'stylesheet') {
|
||||
const sheet = fs
|
||||
.readFileSync('./dist/ui/' + elem.getAttribute('href'))
|
||||
.toString('utf-8')
|
||||
index = index.replace(elem.toString(), '<style>' + sheet + '</style>')
|
||||
}
|
||||
}
|
||||
fs.writeFileSync('./dist/ui/index.html', index)
|
||||
53
frontend/projects/ui/src/app/app-routing.module.ts
Normal file
53
frontend/projects/ui/src/app/app-routing.module.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { PreloadAllModules, RouterModule, Routes } from '@angular/router'
|
||||
import { AuthGuard } from './guards/auth.guard'
|
||||
import { UnauthGuard } from './guards/unauth.guard'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
redirectTo: 'services',
|
||||
pathMatch: 'full',
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
canActivate: [UnauthGuard],
|
||||
loadChildren: () => import('./pages/login/login.module').then(m => m.LoginPageModule),
|
||||
},
|
||||
{
|
||||
path: 'embassy',
|
||||
canActivate: [AuthGuard],
|
||||
canActivateChild: [AuthGuard],
|
||||
loadChildren: () => import('./pages/server-routes/server-routing.module').then(m => m.ServerRoutingModule),
|
||||
},
|
||||
{
|
||||
path: 'marketplace',
|
||||
canActivate: [AuthGuard],
|
||||
canActivateChild: [AuthGuard],
|
||||
loadChildren: () => import('./pages/marketplace-routes/marketplace-routing.module').then(m => m.MarketplaceRoutingModule),
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
canActivate: [AuthGuard],
|
||||
loadChildren: () => import('./pages/notifications/notifications.module').then(m => m.NotificationsPageModule),
|
||||
},
|
||||
{
|
||||
path: 'services',
|
||||
canActivate: [AuthGuard],
|
||||
canActivateChild: [AuthGuard],
|
||||
loadChildren: () => import('./pages/apps-routes/apps-routing.module').then(m => m.AppsRoutingModule),
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forRoot(routes, {
|
||||
scrollPositionRestoration: 'enabled',
|
||||
preloadingStrategy: PreloadAllModules,
|
||||
initialNavigation: 'disabled',
|
||||
useHash: true,
|
||||
}),
|
||||
],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class AppRoutingModule { }
|
||||
197
frontend/projects/ui/src/app/app.component.html
Normal file
197
frontend/projects/ui/src/app/app.component.html
Normal file
@@ -0,0 +1,197 @@
|
||||
<ion-app>
|
||||
<ion-content>
|
||||
<ion-split-pane [disabled]="!showMenu" (ionSplitPaneVisible)="splitPaneVisible($event)" contentId="main-content">
|
||||
<ion-menu contentId="main-content" type="overlay">
|
||||
<ion-content color="light" scrollY="false">
|
||||
<div style="text-align: center;" class="ion-padding">
|
||||
<img style="width: 45%; cursor: pointer;" src="assets/img/logo.png" (click)="goToWebsite()">
|
||||
</div>
|
||||
<div class="divider"></div>
|
||||
<ion-item-group style="padding: 30px 0px;">
|
||||
<ion-menu-toggle auto-hide="false" *ngFor="let page of appPages; let i = index">
|
||||
<ion-item
|
||||
style="padding-left: 10px;"
|
||||
color="transparent"
|
||||
button
|
||||
(click)="selectedIndex = i"
|
||||
routerDirection="root"
|
||||
[routerLink]="[page.url]"
|
||||
lines="none"
|
||||
detail="false"
|
||||
>
|
||||
<ion-icon slot="start" [name]="page.icon" [class]="selectedIndex === i ? 'bold' : 'dim'"></ion-icon>
|
||||
<ion-label
|
||||
style="font-family: 'Montserrat';"
|
||||
[class]="selectedIndex === i ? 'bold' : 'dim'"
|
||||
>
|
||||
{{ page.title }}
|
||||
</ion-label>
|
||||
<ion-badge *ngIf="page.url === '/notifications' && unreadCount" color="danger" style="margin-right: 3%;" [class.selected-badge]="selectedIndex == i">{{ unreadCount }}</ion-badge>
|
||||
</ion-item>
|
||||
</ion-menu-toggle>
|
||||
</ion-item-group>
|
||||
<div style="text-align: center; height: 75px; position:absolute; bottom:0; right:0; width: 100%;">
|
||||
<div class="divider" style="margin-bottom: 10px;"></div>
|
||||
<ion-menu-toggle auto-hide="false">
|
||||
<ion-item button lines="none" style="--background: transparent; margin-bottom: 86px; text-align: center;" fill="clear" (click)="presentAlertLogout()">
|
||||
<ion-label><ion-text
|
||||
style="font-family: 'Montserrat';"
|
||||
color="dark"
|
||||
>
|
||||
Log Out
|
||||
</ion-text></ion-label>
|
||||
</ion-item>
|
||||
</ion-menu-toggle>
|
||||
</div>
|
||||
</ion-content>
|
||||
</ion-menu>
|
||||
<ion-router-outlet id="main-content"></ion-router-outlet>
|
||||
|
||||
</ion-split-pane>
|
||||
|
||||
<section id="preload" style="display: none;">
|
||||
<!-- 3rd party components -->
|
||||
<qr-code value="hello"></qr-code>
|
||||
|
||||
<!-- Ionicons -->
|
||||
<ion-icon name="add"></ion-icon>
|
||||
<ion-icon name="alert-outline"></ion-icon>
|
||||
<ion-icon name="alert-circle-outline"></ion-icon>
|
||||
<ion-icon name="aperture-outline"></ion-icon>
|
||||
<ion-icon name="arrow-back"></ion-icon>
|
||||
<ion-icon name="arrow-up"></ion-icon>
|
||||
<ion-icon name="briefcase-outline"></ion-icon>
|
||||
<ion-icon name="bookmark-outline"></ion-icon>
|
||||
<ion-icon name="cellular-outline"></ion-icon>
|
||||
<ion-icon name="checkmark"></ion-icon>
|
||||
<ion-icon name="chevron-down"></ion-icon>
|
||||
<ion-icon name="chevron-up"></ion-icon>
|
||||
<ion-icon name="chevron-forward"></ion-icon> <!-- needed for detail="true" on ion-item button -->
|
||||
<ion-icon name="close"></ion-icon>
|
||||
<ion-icon name="cloud-outline"></ion-icon>
|
||||
<ion-icon name="cloud-done-outline"></ion-icon>
|
||||
<ion-icon name="cloud-download-outline"></ion-icon>
|
||||
<ion-icon name="cloud-offline-outline"></ion-icon>
|
||||
<ion-icon name="cloud-upload-outline"></ion-icon>
|
||||
<ion-icon name="code-outline"></ion-icon>
|
||||
<ion-icon name="color-wand-outline"></ion-icon>
|
||||
<ion-icon name="construct-outline"></ion-icon>
|
||||
<ion-icon name="copy-outline"></ion-icon>
|
||||
<ion-icon name="cube-outline"></ion-icon>
|
||||
<ion-icon name="desktop-outline"></ion-icon>
|
||||
<ion-icon name="download-outline"></ion-icon>
|
||||
<ion-icon name="earth-outline"></ion-icon>
|
||||
<ion-icon name="ellipse"></ion-icon>
|
||||
<ion-icon name="eye-off-outline"></ion-icon>
|
||||
<ion-icon name="eye-outline"></ion-icon>
|
||||
<ion-icon name="file-tray-stacked-outline"></ion-icon>
|
||||
<ion-icon name="finger-print-outline"></ion-icon>
|
||||
<ion-icon name="flash-outline"></ion-icon>
|
||||
<ion-icon name="folder-open-outline"></ion-icon>
|
||||
<ion-icon name="grid-outline"></ion-icon>
|
||||
<ion-icon name="help-circle-outline"></ion-icon>
|
||||
<ion-icon name="home-outline"></ion-icon>
|
||||
<ion-icon name="information-circle-outline"></ion-icon>
|
||||
<ion-icon name="key-outline"></ion-icon>
|
||||
<ion-icon name="list-outline"></ion-icon>
|
||||
<ion-icon name="lock-closed-outline"></ion-icon>
|
||||
<ion-icon name="logo-bitcoin"></ion-icon>
|
||||
<ion-icon name="mail-outline"></ion-icon>
|
||||
<ion-icon name="medkit-outline"></ion-icon>
|
||||
<ion-icon name="newspaper-outline"></ion-icon>
|
||||
<ion-icon name="notifications-outline"></ion-icon>
|
||||
<ion-icon name="options-outline"></ion-icon>
|
||||
<ion-icon name="phone-portrait-outline"></ion-icon>
|
||||
<ion-icon name="play-circle-outline"></ion-icon>
|
||||
<ion-icon name="power"></ion-icon>
|
||||
<ion-icon name="pulse"></ion-icon>
|
||||
<ion-icon name="qr-code-outline"></ion-icon>
|
||||
<ion-icon name="receipt-outline"></ion-icon>
|
||||
<ion-icon name="refresh"></ion-icon>
|
||||
<ion-icon name="reload"></ion-icon>
|
||||
<ion-icon name="remove"></ion-icon>
|
||||
<ion-icon name="remove-circle-outline"></ion-icon>
|
||||
<ion-icon name="save-outline"></ion-icon>
|
||||
<ion-icon name="shield-checkmark-outline"></ion-icon>
|
||||
<ion-icon name="storefront-outline"></ion-icon>
|
||||
<ion-icon name="swap-vertical"></ion-icon>
|
||||
<ion-icon name="terminal-outline"></ion-icon>
|
||||
<ion-icon name="trash-outline"></ion-icon>
|
||||
<ion-icon name="warning-outline"></ion-icon>
|
||||
<ion-icon name="wifi"></ion-icon>
|
||||
|
||||
<!-- Ionic components -->
|
||||
<ion-action-sheet></ion-action-sheet>
|
||||
<ion-alert></ion-alert>
|
||||
<ion-badge></ion-badge>
|
||||
<ion-button></ion-button>
|
||||
<ion-buttons></ion-buttons>
|
||||
<ion-card></ion-card>
|
||||
<ion-card-content></ion-card-content>
|
||||
<ion-card-header></ion-card-header>
|
||||
<ion-checkbox></ion-checkbox>
|
||||
<ion-content></ion-content>
|
||||
<ion-fab></ion-fab>
|
||||
<ion-fab-button></ion-fab-button>
|
||||
<ion-footer></ion-footer>
|
||||
<ion-grid></ion-grid>
|
||||
<ion-header></ion-header>
|
||||
<ion-popover></ion-popover>
|
||||
<ion-content>
|
||||
<ion-refresher slot="fixed"></ion-refresher>
|
||||
<ion-refresher-content pullingContent="lines"></ion-refresher-content>
|
||||
<ion-infinite-scroll></ion-infinite-scroll>
|
||||
<ion-infinite-scroll-content loadingSpinner="lines"></ion-infinite-scroll-content>
|
||||
</ion-content>
|
||||
<ion-input></ion-input>
|
||||
<ion-item></ion-item>
|
||||
<ion-item-divider></ion-item-divider>
|
||||
<ion-item-group></ion-item-group>
|
||||
<ion-label></ion-label>
|
||||
<ion-list></ion-list>
|
||||
<ion-loading></ion-loading>
|
||||
<ion-modal></ion-modal>
|
||||
<ion-note></ion-note>
|
||||
<ion-radio></ion-radio>
|
||||
<ion-row></ion-row>
|
||||
<ion-segment></ion-segment>
|
||||
<ion-segment-button></ion-segment-button>
|
||||
<ion-select></ion-select>
|
||||
<ion-select-option></ion-select-option>
|
||||
<ion-slides></ion-slides>
|
||||
<ion-spinner name="lines"></ion-spinner>
|
||||
<ion-text></ion-text>
|
||||
<ion-text style="font-weight: bold">load bold</ion-text>
|
||||
<ion-title></ion-title>
|
||||
<ion-toast></ion-toast>
|
||||
<ion-toggle></ion-toggle>
|
||||
<ion-toolbar></ion-toolbar>
|
||||
<ion-menu-button></ion-menu-button>
|
||||
</section>
|
||||
</ion-content>
|
||||
<ion-footer
|
||||
[ngStyle]="{
|
||||
'max-height': osUpdateProgress ? '100px' : '0px',
|
||||
'overflow': 'hidden',
|
||||
'transition-property': 'max-height',
|
||||
'transition-duration': '1s',
|
||||
'transition-delay': '.05s'
|
||||
}"
|
||||
>
|
||||
<ion-toolbar style="border-top: 1px solid var(--ion-color-dark);" color="light">
|
||||
<ion-list>
|
||||
<ion-list-header>
|
||||
<ion-label>Downloading EOS: {{ (100 * (osUpdateProgress?.downloaded || 1) / (osUpdateProgress?.size || 1)).toFixed(0) }}%</ion-label>
|
||||
</ion-list-header>
|
||||
<div style="padding: 0 16px 16px 16px;">
|
||||
<ion-progress-bar
|
||||
color="secondary"
|
||||
[value]="osUpdateProgress && osUpdateProgress.downloaded / osUpdateProgress.size"
|
||||
></ion-progress-bar>
|
||||
</div>
|
||||
</ion-list>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
</ion-app>
|
||||
|
||||
|
||||
11
frontend/projects/ui/src/app/app.component.scss
Normal file
11
frontend/projects/ui/src/app/app.component.scss
Normal file
@@ -0,0 +1,11 @@
|
||||
.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.dim {
|
||||
color: var(--ion-color-dark-shade);
|
||||
}
|
||||
|
||||
ion-split-pane {
|
||||
--side-max-width: 280px;
|
||||
}
|
||||
408
frontend/projects/ui/src/app/app.component.ts
Normal file
408
frontend/projects/ui/src/app/app.component.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
import { Component, HostListener, NgZone } from '@angular/core'
|
||||
import { Storage } from '@ionic/storage-angular'
|
||||
import { AuthService, AuthState } from './services/auth.service'
|
||||
import { ApiService } from './services/api/embassy-api.service'
|
||||
import { Router, RoutesRecognized } from '@angular/router'
|
||||
import { debounceTime, distinctUntilChanged, filter, finalize, take, takeWhile } from 'rxjs/operators'
|
||||
import { AlertController, IonicSafeString, LoadingController, ToastController } from '@ionic/angular'
|
||||
import { Emver } from './services/emver.service'
|
||||
import { SplitPaneTracker } from './services/split-pane.service'
|
||||
import { ToastButton } from '@ionic/core'
|
||||
import { PatchDbService } from './services/patch-db/patch-db.service'
|
||||
import { ServerStatus } from './services/patch-db/data-model'
|
||||
import { ConnectionFailure, ConnectionService } from './services/connection.service'
|
||||
import { StartupAlertsService } from './services/startup-alerts.service'
|
||||
import { ConfigService } from './services/config.service'
|
||||
import { debounce, isEmptyObject, pauseFor } from './util/misc.util'
|
||||
import { ErrorToastService } from './services/error-toast.service'
|
||||
import { Subscription } from 'rxjs'
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: 'app.component.html',
|
||||
styleUrls: ['app.component.scss'],
|
||||
})
|
||||
export class AppComponent {
|
||||
@HostListener('document:keydown.enter', ['$event'])
|
||||
@debounce()
|
||||
handleKeyboardEvent () {
|
||||
const elems = document.getElementsByClassName('enter-click')
|
||||
const elem = elems[elems.length - 1] as HTMLButtonElement
|
||||
if (!elem || elem.classList.contains('no-click') || elem.disabled) return
|
||||
if (elem) elem.click()
|
||||
}
|
||||
|
||||
ServerStatus = ServerStatus
|
||||
showMenu = false
|
||||
selectedIndex = 0
|
||||
offlineToast: HTMLIonToastElement
|
||||
updateToast: HTMLIonToastElement
|
||||
notificationToast: HTMLIonToastElement
|
||||
serverName: string
|
||||
unreadCount: number
|
||||
subscriptions: Subscription[] = []
|
||||
osUpdateProgress: { size: number, downloaded: number }
|
||||
appPages = [
|
||||
{
|
||||
title: 'Services',
|
||||
url: '/services',
|
||||
icon: 'grid-outline',
|
||||
},
|
||||
{
|
||||
title: 'Embassy',
|
||||
url: '/embassy',
|
||||
icon: 'cube-outline',
|
||||
},
|
||||
{
|
||||
title: 'Marketplace',
|
||||
url: '/marketplace',
|
||||
icon: 'storefront-outline',
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
url: '/notifications',
|
||||
icon: 'notifications-outline',
|
||||
},
|
||||
]
|
||||
|
||||
constructor (
|
||||
private readonly storage: Storage,
|
||||
private readonly authService: AuthService,
|
||||
private readonly router: Router,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly emver: Emver,
|
||||
private readonly connectionService: ConnectionService,
|
||||
private readonly startupAlertsService: StartupAlertsService,
|
||||
private readonly toastCtrl: ToastController,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly patch: PatchDbService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly zone: NgZone,
|
||||
readonly splitPane: SplitPaneTracker,
|
||||
) {
|
||||
this.init()
|
||||
}
|
||||
|
||||
async init () {
|
||||
await this.storage.create()
|
||||
await this.authService.init()
|
||||
|
||||
this.router.initialNavigation()
|
||||
|
||||
// watch auth
|
||||
this.authService.watch$()
|
||||
.subscribe(async auth => {
|
||||
// VERIFIED
|
||||
if (auth === AuthState.VERIFIED) {
|
||||
await this.patch.start()
|
||||
|
||||
this.showMenu = true
|
||||
// if on the login screen, route to dashboard
|
||||
if (this.router.url.startsWith('/login')) {
|
||||
this.router.navigate([''], { replaceUrl: true })
|
||||
}
|
||||
|
||||
this.subscriptions = this.subscriptions.concat([
|
||||
// start the connection monitor
|
||||
...this.connectionService.start(),
|
||||
// watch connection to display connectivity issues
|
||||
this.watchConnection(),
|
||||
// watch router to highlight selected menu item
|
||||
this.watchRouter(),
|
||||
// watch status to display/hide maintenance page
|
||||
])
|
||||
|
||||
this.patch.watch$()
|
||||
.pipe(
|
||||
filter(obj => !isEmptyObject(obj)),
|
||||
take(1),
|
||||
)
|
||||
.subscribe(_ => {
|
||||
this.subscriptions = this.subscriptions.concat([
|
||||
// watch status to present toast for updated state
|
||||
this.watchStatus(),
|
||||
// watch update-progress to present progress bar when server is updating
|
||||
this.watchUpdateProgress(),
|
||||
// watch version to refresh browser window
|
||||
this.watchVersion(),
|
||||
// watch unread notification count to display toast
|
||||
this.watchNotifications(),
|
||||
// run startup alerts
|
||||
this.startupAlertsService.runChecks(),
|
||||
])
|
||||
})
|
||||
// UNVERIFIED
|
||||
} else if (auth === AuthState.UNVERIFIED) {
|
||||
this.subscriptions.forEach(sub => sub.unsubscribe())
|
||||
this.subscriptions = []
|
||||
this.showMenu = false
|
||||
this.patch.stop()
|
||||
this.storage.clear()
|
||||
if (this.errToast) this.errToast.dismiss()
|
||||
if (this.updateToast) this.updateToast.dismiss()
|
||||
if (this.notificationToast) this.notificationToast.dismiss()
|
||||
if (this.offlineToast) this.offlineToast.dismiss()
|
||||
this.zone.run(() => {
|
||||
this.router.navigate(['/login'], { replaceUrl: true })
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async goToWebsite (): Promise<void> {
|
||||
let url: string
|
||||
if (this.config.isTor()) {
|
||||
url = 'http://privacy34kn4ez3y3nijweec6w4g54i3g54sdv7r5mr6soma3w4begyd.onion'
|
||||
} else {
|
||||
url = 'https://start9.com'
|
||||
}
|
||||
window.open(url, '_blank', 'noreferrer')
|
||||
}
|
||||
|
||||
async presentAlertLogout () {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Caution',
|
||||
message: 'Do you know your password? If you log out and forget your password, you may permanently lose access to your Embassy.',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Logout',
|
||||
cssClass: 'enter-click',
|
||||
handler: () => {
|
||||
this.logout()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
// should wipe cache independant of actual BE logout
|
||||
private async logout () {
|
||||
this.embassyApi.logout({ })
|
||||
this.authService.setUnverified()
|
||||
}
|
||||
|
||||
private watchConnection (): Subscription {
|
||||
return this.connectionService.watchFailure$()
|
||||
.pipe(
|
||||
distinctUntilChanged(),
|
||||
debounceTime(500),
|
||||
)
|
||||
.subscribe(async connectionFailure => {
|
||||
if (connectionFailure === ConnectionFailure.None) {
|
||||
if (this.offlineToast) {
|
||||
await this.offlineToast.dismiss()
|
||||
this.offlineToast = undefined
|
||||
}
|
||||
} else {
|
||||
let message: string | IonicSafeString
|
||||
let link: string
|
||||
switch (connectionFailure) {
|
||||
case ConnectionFailure.Network:
|
||||
message = 'Phone or computer has no network connection.'
|
||||
break
|
||||
case ConnectionFailure.Tor:
|
||||
message = 'Browser unable to connect over Tor.'
|
||||
link = 'https://docs.start9.com/support/FAQ/troubleshooting.html#tor-failure'
|
||||
break
|
||||
case ConnectionFailure.Lan:
|
||||
message = 'Embassy not found on Local Area Network.'
|
||||
link = 'https://docs.start9.com/support/FAQ/troubleshooting.html#lan-failure'
|
||||
break
|
||||
}
|
||||
await this.presentToastOffline(message, link)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private watchRouter (): Subscription {
|
||||
return this.router.events
|
||||
.pipe(
|
||||
filter((e: RoutesRecognized) => !!e.urlAfterRedirects),
|
||||
)
|
||||
.subscribe(e => {
|
||||
const appPageIndex = this.appPages.findIndex(
|
||||
appPage => e.urlAfterRedirects.startsWith(appPage.url),
|
||||
)
|
||||
if (appPageIndex > -1) this.selectedIndex = appPageIndex
|
||||
})
|
||||
}
|
||||
|
||||
private watchStatus (): Subscription {
|
||||
return this.patch.watch$('server-info', 'status')
|
||||
.subscribe(status => {
|
||||
if (status === ServerStatus.Updated && !this.updateToast) {
|
||||
this.presentToastUpdated()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private watchUpdateProgress (): Subscription {
|
||||
return this.patch.watch$('server-info', 'update-progress')
|
||||
.subscribe(progress => {
|
||||
this.osUpdateProgress = progress
|
||||
})
|
||||
}
|
||||
|
||||
private watchVersion (): Subscription {
|
||||
return this.patch.watch$('server-info', 'version')
|
||||
.subscribe(version => {
|
||||
if (this.emver.compare(this.config.version, version) !== 0) {
|
||||
this.presentAlertRefreshNeeded()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private watchNotifications (): Subscription {
|
||||
let previous: number
|
||||
return this.patch.watch$('server-info', 'unread-notification-count')
|
||||
.subscribe(count => {
|
||||
this.unreadCount = count
|
||||
if (previous !== undefined && count > previous) this.presentToastNotifications()
|
||||
previous = count
|
||||
})
|
||||
}
|
||||
|
||||
private async presentAlertRefreshNeeded () {
|
||||
const alert = await this.alertCtrl.create({
|
||||
backdropDismiss: false,
|
||||
header: 'Refresh Needed',
|
||||
message: 'Your user interface is cached and out of date. Hard refresh the page to get the latest UI.',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Refresh Page',
|
||||
cssClass: 'enter-click',
|
||||
handler: () => {
|
||||
location.reload()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
private async presentToastUpdated () {
|
||||
if (this.updateToast) return
|
||||
|
||||
this.updateToast = await this.toastCtrl.create({
|
||||
header: 'EOS download complete!',
|
||||
message: 'Restart your Embassy for these updates to take effect. It can take several minutes to come back online.',
|
||||
position: 'bottom',
|
||||
duration: 0,
|
||||
cssClass: 'success-toast',
|
||||
buttons: [
|
||||
{
|
||||
side: 'start',
|
||||
icon: 'close',
|
||||
handler: () => {
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
side: 'end',
|
||||
text: 'Restart',
|
||||
handler: () => {
|
||||
this.restart()
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await this.updateToast.present()
|
||||
}
|
||||
|
||||
private async presentToastNotifications () {
|
||||
if (this.notificationToast) return
|
||||
|
||||
this.notificationToast = await this.toastCtrl.create({
|
||||
header: 'Embassy',
|
||||
message: `New notifications`,
|
||||
position: 'bottom',
|
||||
duration: 4000,
|
||||
buttons: [
|
||||
{
|
||||
side: 'start',
|
||||
icon: 'close',
|
||||
handler: () => {
|
||||
return true
|
||||
},
|
||||
},
|
||||
{
|
||||
side: 'end',
|
||||
text: 'View',
|
||||
handler: () => {
|
||||
this.router.navigate(['/notifications'], { queryParams: { toast: true } })
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
await this.notificationToast.present()
|
||||
}
|
||||
|
||||
private async presentToastOffline (message: string | IonicSafeString, link?: string) {
|
||||
if (this.offlineToast) {
|
||||
this.offlineToast.message = message
|
||||
return
|
||||
}
|
||||
|
||||
let buttons: ToastButton[] = [
|
||||
{
|
||||
side: 'start',
|
||||
icon: 'close',
|
||||
handler: () => {
|
||||
return true
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
if (link) {
|
||||
buttons.push(
|
||||
{
|
||||
side: 'end',
|
||||
text: 'View solutions',
|
||||
handler: () => {
|
||||
window.open(link, '_blank', 'noreferrer')
|
||||
return false
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
this.offlineToast = await this.toastCtrl.create({
|
||||
header: 'Unable to Connect',
|
||||
cssClass: 'warning-toast',
|
||||
message,
|
||||
position: 'bottom',
|
||||
duration: 0,
|
||||
buttons,
|
||||
})
|
||||
await this.offlineToast.present()
|
||||
}
|
||||
|
||||
private async restart (): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
spinner: 'lines',
|
||||
message: 'Restarting...',
|
||||
cssClass: 'loader',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.embassyApi.restartServer({ })
|
||||
} catch (e) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
splitPaneVisible (e: any) {
|
||||
this.splitPane.sidebarOpen$.next(e.detail.visible)
|
||||
}
|
||||
}
|
||||
81
frontend/projects/ui/src/app/app.module.ts
Normal file
81
frontend/projects/ui/src/app/app.module.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
import { NgModule, CUSTOM_ELEMENTS_SCHEMA, ErrorHandler } from '@angular/core'
|
||||
import { BrowserModule } from '@angular/platform-browser'
|
||||
import { RouteReuseStrategy } from '@angular/router'
|
||||
import { IonicModule, IonicRouteStrategy, IonNav } from '@ionic/angular'
|
||||
import { Drivers } from '@ionic/storage'
|
||||
import { IonicStorageModule, Storage } from '@ionic/storage-angular'
|
||||
import { HttpClientModule } from '@angular/common/http'
|
||||
import { AppComponent } from './app.component'
|
||||
import { AppRoutingModule } from './app-routing.module'
|
||||
import { ApiService } from './services/api/embassy-api.service'
|
||||
import { PatchDbServiceFactory } from './services/patch-db/patch-db.factory'
|
||||
import { ConfigService } from './services/config.service'
|
||||
import { QrCodeModule } from 'ng-qrcode'
|
||||
import { OSWelcomePageModule } from './modals/os-welcome/os-welcome.module'
|
||||
import { MarkdownPageModule } from './modals/markdown/markdown.module'
|
||||
import { PatchDbService } from './services/patch-db/patch-db.service'
|
||||
import { LocalStorageBootstrap } from './services/patch-db/local-storage-bootstrap'
|
||||
import { SharingModule } from './modules/sharing.module'
|
||||
import { FormBuilder } from '@angular/forms'
|
||||
import { GenericInputComponentModule } from './modals/generic-input/generic-input.component.module'
|
||||
import { AuthService } from './services/auth.service'
|
||||
import { GlobalErrorHandler } from './services/global-error-handler.service'
|
||||
import { MockApiService } from './services/api/embassy-mock-api.service'
|
||||
import { LiveApiService } from './services/api/embassy-live-api.service'
|
||||
import { WorkspaceConfig } from '@shared'
|
||||
|
||||
const { useMocks } = require('../../../../config.json') as WorkspaceConfig
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent],
|
||||
entryComponents: [],
|
||||
imports: [
|
||||
HttpClientModule,
|
||||
BrowserModule,
|
||||
IonicModule.forRoot({
|
||||
mode: 'md',
|
||||
}),
|
||||
AppRoutingModule,
|
||||
IonicStorageModule.forRoot({
|
||||
storeName: '_embassykv',
|
||||
dbKey: '_embassykey',
|
||||
name: '_embassystorage',
|
||||
driverOrder: [Drivers.LocalStorage, Drivers.IndexedDB],
|
||||
}),
|
||||
QrCodeModule,
|
||||
OSWelcomePageModule,
|
||||
MarkdownPageModule,
|
||||
GenericInputComponentModule,
|
||||
SharingModule,
|
||||
],
|
||||
providers: [
|
||||
FormBuilder,
|
||||
IonNav,
|
||||
{
|
||||
provide: RouteReuseStrategy,
|
||||
useClass: IonicRouteStrategy,
|
||||
},
|
||||
{
|
||||
provide: ApiService,
|
||||
useClass: useMocks ? MockApiService : LiveApiService,
|
||||
},
|
||||
{
|
||||
provide: PatchDbService,
|
||||
useFactory: PatchDbServiceFactory,
|
||||
deps: [
|
||||
ConfigService,
|
||||
ApiService,
|
||||
LocalStorageBootstrap,
|
||||
AuthService,
|
||||
Storage,
|
||||
],
|
||||
},
|
||||
{
|
||||
provide: ErrorHandler,
|
||||
useClass: GlobalErrorHandler,
|
||||
},
|
||||
],
|
||||
bootstrap: [AppComponent],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,14 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button defaultHref="embassy"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{ title }}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button [disabled]="backupService.loading" (click)="refresh()">
|
||||
Refresh
|
||||
<ion-icon slot="end" name="refresh"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
@@ -0,0 +1,16 @@
|
||||
<div class="inline">
|
||||
<h2 *ngIf="type === 'create'; else restore">
|
||||
<ion-icon name="cloud-outline" color="success"></ion-icon>
|
||||
{{ hasValidBackup ? 'Available, contains existing backup' : 'Available for fresh backup' }}
|
||||
</h2>
|
||||
<ng-template #restore>
|
||||
<h2 *ngIf="hasValidBackup">
|
||||
<ion-icon name="cloud-done-outline" color="success"></ion-icon>
|
||||
Embassy backup detected
|
||||
</h2>
|
||||
<h2 *ngIf="!hasValidBackup">
|
||||
<ion-icon name="cloud-offline-outline" color="danger"></ion-icon>
|
||||
No Embassy backup
|
||||
</h2>
|
||||
</ng-template>
|
||||
</div>
|
||||
@@ -0,0 +1,82 @@
|
||||
<!-- loading -->
|
||||
<text-spinner *ngIf="backupService.loading; else loaded" [text]="loadingText"></text-spinner>
|
||||
|
||||
<!-- loaded -->
|
||||
<ng-template #loaded>
|
||||
<!-- error -->
|
||||
<ion-item *ngIf="backupService.loadingError; else noError">
|
||||
<ion-label>
|
||||
<ion-text color="danger">
|
||||
{{ backupService.loadingError }}
|
||||
</ion-text>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ng-template #noError>
|
||||
<ion-item-group>
|
||||
<!-- ** cifs ** -->
|
||||
<ion-item-divider>Shared Network Folders</ion-item-divider>
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>
|
||||
Shared folders are the recommended way to create Embassy backups. View the <a href="https://docs.start9.com/user-manual/general/backups.html#shared-network-folder" target="_blank" noreferrer>Instructions</a>
|
||||
</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<!-- add new cifs -->
|
||||
<ion-item button detail="false" (click)="presentModalAddCifs()">
|
||||
<ion-icon slot="start" name="add" size="large" color="dark"></ion-icon>
|
||||
<ion-label>New shared folder</ion-label>
|
||||
</ion-item>
|
||||
<!-- cifs list -->
|
||||
<ng-container *ngFor="let target of backupService.cifs; let i = index">
|
||||
<ion-item button *ngIf="target.entry as cifs" (click)="presentActionCifs(target, i)">
|
||||
<ion-icon slot="start" name="folder-open-outline" size="large"></ion-icon>
|
||||
<ion-label>
|
||||
<h1>{{ cifs.path.split('/').pop() }}</h1>
|
||||
<ng-container *ngIf="cifs.mountable">
|
||||
<backup-drives-status [type]="type" [hasValidBackup]="target.hasValidBackup"></backup-drives-status>
|
||||
</ng-container>
|
||||
<h2 *ngIf="!cifs.mountable" class="inline">
|
||||
<ion-icon name="cellular-outline" color="danger"></ion-icon>
|
||||
Unable to connect
|
||||
</h2>
|
||||
<p>Hostname: {{ cifs.hostname }}</p>
|
||||
<p>Path: {{ cifs.path }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
|
||||
<!-- ** drives ** -->
|
||||
<ion-item-divider>Physical Drives</ion-item-divider>
|
||||
<!-- no drives -->
|
||||
<ion-item *ngIf="!backupService.drives.length; else hasDrives" class="ion-padding-bottom">
|
||||
<ion-label>
|
||||
<h2>
|
||||
<ion-text color="warning">
|
||||
Warning! Plugging a 2nd physical drive directly into your Embassy can lead to data corruption.
|
||||
</ion-text>
|
||||
</h2>
|
||||
<br />
|
||||
<h2>
|
||||
To backup to a physical drive, please follow the <a href="https://docs.start9.com/user-manual/general/backups.html#physical-drive" target="_blank" noreferrer>instructions</a>.
|
||||
</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<!-- drives detected -->
|
||||
<ng-template #hasDrives>
|
||||
<ion-item button *ngFor="let target of backupService.drives" (click)="select(target)">
|
||||
<ion-icon slot="start" name="save-outline" size="large"></ion-icon>
|
||||
<ng-container *ngIf="target.entry as drive">
|
||||
<ion-label>
|
||||
<h1>{{ drive.label || drive.logicalname }}</h1>
|
||||
<backup-drives-status [type]="type" [hasValidBackup]="target.hasValidBackup"></backup-drives-status>
|
||||
<p>{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model || 'Unknown Model' }}</p>
|
||||
<p>Capacity: {{ drive.capacity | convertBytes }}</p>
|
||||
</ion-label>
|
||||
</ng-container>
|
||||
</ion-item>
|
||||
</ng-template>
|
||||
</ion-item-group>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { BackupDrivesComponent, BackupDrivesHeaderComponent, BackupDrivesStatusComponent } from './backup-drives.component'
|
||||
import { SharingModule } from '../../modules/sharing.module'
|
||||
import { GenericFormPageModule } from 'src/app/modals/generic-form/generic-form.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
BackupDrivesComponent,
|
||||
BackupDrivesHeaderComponent,
|
||||
BackupDrivesStatusComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
SharingModule,
|
||||
GenericFormPageModule,
|
||||
],
|
||||
exports: [
|
||||
BackupDrivesComponent,
|
||||
BackupDrivesHeaderComponent,
|
||||
BackupDrivesStatusComponent,
|
||||
],
|
||||
})
|
||||
export class BackupDrivesComponentModule { }
|
||||
@@ -0,0 +1,274 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||
import { BackupService } from './backup.service'
|
||||
import { CifsBackupTarget, DiskBackupTarget, RR } from 'src/app/services/api/api.types'
|
||||
import { ActionSheetController, AlertController, LoadingController, ModalController } from '@ionic/angular'
|
||||
import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page'
|
||||
import { ConfigSpec } from 'src/app/pkg-config/config-types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ErrorToastService } from 'src/app/services/error-toast.service'
|
||||
import { MappedBackupTarget } from 'src/app/util/misc.util'
|
||||
|
||||
@Component({
|
||||
selector: 'backup-drives',
|
||||
templateUrl: './backup-drives.component.html',
|
||||
styleUrls: ['./backup-drives.component.scss'],
|
||||
})
|
||||
export class BackupDrivesComponent {
|
||||
@Input() type: 'create' | 'restore'
|
||||
@Output() onSelect: EventEmitter<MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>> = new EventEmitter()
|
||||
loadingText: string
|
||||
|
||||
constructor (
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly actionCtrl: ActionSheetController,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly errToast: ErrorToastService,
|
||||
public readonly backupService: BackupService,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
this.loadingText = this.type === 'create' ? 'Fetching Backup Targets' : 'Fetching Backup Sources'
|
||||
this.backupService.getBackupTargets()
|
||||
}
|
||||
|
||||
select (target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>): void {
|
||||
if (target.entry.type === 'cifs' && !target.entry.mountable) {
|
||||
const message = 'Unable to connect to shared folder. Ensure (1) target computer is connected to LAN, (2) target folder is being shared, and (3) hostname, path, and credentials are accurate.'
|
||||
this.presentAlertError(message)
|
||||
return
|
||||
}
|
||||
|
||||
if (this.type === 'restore' && !target.hasValidBackup) {
|
||||
const message = `${target.entry.type === 'cifs' ? 'Shared folder' : 'Drive partition'} does not contain a valid Embassy backup.`
|
||||
this.presentAlertError(message)
|
||||
return
|
||||
}
|
||||
|
||||
this.onSelect.emit(target)
|
||||
}
|
||||
|
||||
async presentModalAddCifs (): Promise<void> {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: GenericFormPage,
|
||||
componentProps: {
|
||||
title: 'New Shared Folder',
|
||||
spec: CifsSpec,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Save',
|
||||
handler: (value: RR.AddBackupTargetReq) => {
|
||||
return this.addCifs(value)
|
||||
},
|
||||
isSubmit: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
async presentActionCifs (target: MappedBackupTarget<CifsBackupTarget>, index: number): Promise<void> {
|
||||
const entry = target.entry as CifsBackupTarget
|
||||
|
||||
const action = await this.actionCtrl.create({
|
||||
header: entry.hostname,
|
||||
subHeader: 'Shared Folder',
|
||||
mode: 'ios',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Forget',
|
||||
icon: 'trash',
|
||||
role: 'destructive',
|
||||
handler: () => {
|
||||
this.deleteCifs(target.id, index)
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Edit',
|
||||
icon: 'pencil',
|
||||
handler: () => {
|
||||
this.presentModalEditCifs(target.id, entry, index)
|
||||
},
|
||||
},
|
||||
{
|
||||
text: this.type === 'create' ? 'Create Backup' : 'Restore From Backup',
|
||||
icon: this.type === 'create' ? 'cloud-upload-outline' : 'cloud-download-outline',
|
||||
handler: () => {
|
||||
this.select(target)
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
await action.present()
|
||||
}
|
||||
|
||||
private async presentAlertError (message: string): Promise<void> {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Error',
|
||||
message,
|
||||
buttons: ['OK'],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
private async addCifs (value: RR.AddBackupTargetReq): Promise<boolean> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
spinner: 'lines',
|
||||
message: 'Testing connectivity to shared folder...',
|
||||
cssClass: 'loader',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
const res = await this.embassyApi.addBackupTarget(value)
|
||||
const [id, entry] = Object.entries(res)[0]
|
||||
this.backupService.cifs.unshift({
|
||||
id,
|
||||
hasValidBackup: this.backupService.hasValidBackup(entry),
|
||||
entry,
|
||||
})
|
||||
return true
|
||||
} catch (e) {
|
||||
this.errToast.present(e)
|
||||
return false
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private async presentModalEditCifs (id: string, entry: CifsBackupTarget, index: number): Promise<void> {
|
||||
const { hostname, path, username } = entry
|
||||
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: GenericFormPage,
|
||||
componentProps: {
|
||||
title: 'Update Shared Folder',
|
||||
spec: CifsSpec,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Save',
|
||||
handler: (value: RR.AddBackupTargetReq) => {
|
||||
return this.editCifs({ id, ...value }, index)
|
||||
},
|
||||
isSubmit: true,
|
||||
},
|
||||
],
|
||||
initialValue: {
|
||||
hostname,
|
||||
path,
|
||||
username,
|
||||
},
|
||||
},
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
private async editCifs (value: RR.UpdateBackupTargetReq, index: number): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
spinner: 'lines',
|
||||
message: 'Testing connectivity to shared folder...',
|
||||
cssClass: 'loader',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
const res = await this.embassyApi.updateBackupTarget(value)
|
||||
const entry = Object.values(res)[0]
|
||||
this.backupService.cifs[index].entry = entry
|
||||
} catch (e) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private async deleteCifs (id: string, index: number): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
spinner: 'lines',
|
||||
message: 'Removing...',
|
||||
cssClass: 'loader',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.embassyApi.removeBackupTarget({ id })
|
||||
this.backupService.cifs.splice(index, 1)
|
||||
} catch (e) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'backup-drives-header',
|
||||
templateUrl: './backup-drives-header.component.html',
|
||||
styleUrls: ['./backup-drives.component.scss'],
|
||||
})
|
||||
export class BackupDrivesHeaderComponent {
|
||||
@Input() title: string
|
||||
@Output() onClose: EventEmitter<void> = new EventEmitter()
|
||||
|
||||
constructor (
|
||||
public readonly backupService: BackupService,
|
||||
) { }
|
||||
|
||||
refresh () {
|
||||
this.backupService.getBackupTargets()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'backup-drives-status',
|
||||
templateUrl: './backup-drives-status.component.html',
|
||||
styleUrls: ['./backup-drives.component.scss'],
|
||||
})
|
||||
export class BackupDrivesStatusComponent {
|
||||
@Input() type: string
|
||||
@Input() hasValidBackup: boolean
|
||||
}
|
||||
|
||||
const CifsSpec: ConfigSpec = {
|
||||
hostname: {
|
||||
type: 'string',
|
||||
name: 'Hostname',
|
||||
description: 'The hostname of your target device on the Local Area Network.',
|
||||
placeholder: `e.g. 'My Computer' OR 'my-computer.local'`,
|
||||
pattern: '^[a-zA-Z0-9._-]+( [a-zA-Z0-9]+)*$',
|
||||
'pattern-description': `Must be a valid hostname. e.g. 'My Computer' OR 'my-computer.local'`,
|
||||
nullable: false,
|
||||
masked: false,
|
||||
copyable: false,
|
||||
},
|
||||
path: {
|
||||
type: 'string',
|
||||
name: 'Path',
|
||||
description: 'The directory path to the shared folder on your target device.',
|
||||
placeholder: 'e.g. /Desktop/my-folder',
|
||||
nullable: false,
|
||||
masked: false,
|
||||
copyable: false,
|
||||
},
|
||||
username: {
|
||||
type: 'string',
|
||||
name: 'Username',
|
||||
description: 'The username of the user account on your target device.',
|
||||
nullable: false,
|
||||
masked: false,
|
||||
copyable: false,
|
||||
},
|
||||
password: {
|
||||
type: 'string',
|
||||
name: 'Password',
|
||||
description: 'The password of the user account on your target device.',
|
||||
nullable: true,
|
||||
masked: true,
|
||||
copyable: false,
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { IonicSafeString } from '@ionic/core'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { getErrorMessage } from 'src/app/services/error-toast.service'
|
||||
import { BackupTarget, CifsBackupTarget, DiskBackupTarget } from 'src/app/services/api/api.types'
|
||||
import { Emver } from 'src/app/services/emver.service'
|
||||
import { MappedBackupTarget } from 'src/app/util/misc.util'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class BackupService {
|
||||
cifs: MappedBackupTarget<CifsBackupTarget>[]
|
||||
drives: MappedBackupTarget<DiskBackupTarget>[]
|
||||
loading = true
|
||||
loadingError: string | IonicSafeString
|
||||
|
||||
constructor (
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly emver: Emver,
|
||||
) { }
|
||||
|
||||
async getBackupTargets (): Promise<void> {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
const targets = await this.embassyApi.getBackupTargets({ })
|
||||
// cifs
|
||||
this.cifs = Object.entries(targets)
|
||||
.filter(([_, target]) => target.type === 'cifs')
|
||||
.map(([id, cifs]) => {
|
||||
return {
|
||||
id,
|
||||
hasValidBackup: this.hasValidBackup(cifs),
|
||||
entry: cifs as CifsBackupTarget,
|
||||
}
|
||||
})
|
||||
// drives
|
||||
this.drives = Object.entries(targets)
|
||||
.filter(([_, target]) => target.type === 'disk')
|
||||
.map(([id, drive]) => {
|
||||
return {
|
||||
id,
|
||||
hasValidBackup: this.hasValidBackup(drive),
|
||||
entry: drive as DiskBackupTarget,
|
||||
}
|
||||
})
|
||||
} catch (e) {
|
||||
this.loadingError = getErrorMessage(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
hasValidBackup (target: BackupTarget): boolean {
|
||||
return [0, 1].includes(this.emver.compare(target['embassy-os']?.version, '0.3.0'))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<div style="position: relative; margin-right: 1vh;">
|
||||
<ion-badge mode="md" class="md-badge" *ngIf="unreadCount && !sidebarOpen" color="danger">{{ unreadCount }}</ion-badge>
|
||||
<ion-menu-button color="dark"></ion-menu-button>
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { BadgeMenuComponent } from './badge-menu.component'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
BadgeMenuComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
SharingModule,
|
||||
],
|
||||
exports: [BadgeMenuComponent],
|
||||
})
|
||||
export class BadgeMenuComponentModule { }
|
||||
@@ -0,0 +1,9 @@
|
||||
.md-badge {
|
||||
background-color: var(--ion-color-danger);
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
left: 56%;
|
||||
border-radius: 6px;
|
||||
z-index: 1;
|
||||
font-size: 80%;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { SplitPaneTracker } from 'src/app/services/split-pane.service'
|
||||
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
|
||||
import { combineLatest, Subscription } from 'rxjs'
|
||||
|
||||
@Component({
|
||||
selector: 'badge-menu-button',
|
||||
templateUrl: './badge-menu.component.html',
|
||||
styleUrls: ['./badge-menu.component.scss'],
|
||||
})
|
||||
|
||||
export class BadgeMenuComponent {
|
||||
unreadCount: number
|
||||
sidebarOpen: boolean
|
||||
|
||||
subs: Subscription[] = []
|
||||
|
||||
constructor (
|
||||
private readonly splitPane: SplitPaneTracker,
|
||||
private readonly patch: PatchDbService,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
this.subs = [
|
||||
combineLatest([
|
||||
this.patch.watch$('server-info', 'unread-notification-count'),
|
||||
this.splitPane.sidebarOpen$,
|
||||
])
|
||||
.subscribe(([unread, menu]) => {
|
||||
this.unreadCount = unread
|
||||
this.sidebarOpen = menu
|
||||
}),
|
||||
]
|
||||
}
|
||||
|
||||
ngOnDestroy () {
|
||||
this.subs.forEach(sub => sub.unsubscribe())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<div [hidden]="!control.dirty && !control.touched" class="validation-error">
|
||||
<!-- primitive -->
|
||||
<p *ngIf="control.hasError('required')">
|
||||
{{ spec.name }} is required
|
||||
</p>
|
||||
|
||||
<!-- string -->
|
||||
<p *ngIf="control.hasError('pattern')">
|
||||
{{ spec['pattern-description'] }}
|
||||
</p>
|
||||
|
||||
<!-- number -->
|
||||
<ng-container *ngIf="spec.type === 'number'">
|
||||
<p *ngIf="control.hasError('numberNotInteger')">
|
||||
{{ spec.name }} must be an integer
|
||||
</p>
|
||||
<p *ngIf="control.hasError('numberNotInRange')">
|
||||
{{ control.errors['numberNotInRange'].value }}
|
||||
</p>
|
||||
<p *ngIf="control.hasError('notNumber')">
|
||||
{{ spec.name }} must be a number
|
||||
</p>
|
||||
</ng-container>
|
||||
|
||||
<!-- list -->
|
||||
<ng-container *ngIf="spec.type === 'list'">
|
||||
<p *ngIf="control.hasError('listNotInRange')">
|
||||
{{ control.errors['listNotInRange'].value }}
|
||||
</p>
|
||||
<p *ngIf="control.hasError('listNotUnique')">
|
||||
{{ control.errors['listNotUnique'].value }}
|
||||
</p>
|
||||
<p *ngIf="control.hasError('listItemIssue')">
|
||||
{{ control.errors['listItemIssue'].value }}
|
||||
</p>
|
||||
</ng-container>
|
||||
</div>
|
||||
@@ -0,0 +1,17 @@
|
||||
<ion-button *ngIf="data.spec.description" class="slot-start" fill="clear" size="small" (click)="presentAlertDescription()">
|
||||
<ion-icon name="help-circle-outline"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
<!-- this is a button for css purposes only -->
|
||||
<ion-button *ngIf="data.invalid" class="slot-start" fill="clear" size="small" color="danger">
|
||||
<ion-icon name="warning-outline"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
<span>{{ data.spec.name }}</span>
|
||||
|
||||
<ion-text color="success" *ngIf="data.new"> (New)</ion-text>
|
||||
<ion-text color="warning" *ngIf="data.edited"> (Edited)</ion-text>
|
||||
|
||||
<span *ngIf="(['string', 'number'] | includes : data.spec.type) && !$any(data.spec).nullable"> *</span>
|
||||
|
||||
<span *ngIf="data.spec.type === 'list' && Range.from(data.spec.range).min"> *</span>
|
||||
@@ -0,0 +1,243 @@
|
||||
<ion-item-group [formGroup]="formGroup">
|
||||
<div *ngFor="let entry of formGroup.controls | keyvalue : asIsOrder">
|
||||
<!-- union enum -->
|
||||
<ng-container *ngIf="unionSpec && entry.key === unionSpec.tag.id">
|
||||
<p class="input-label">{{ unionSpec.tag.name }}</p>
|
||||
<ion-item>
|
||||
<ion-button *ngIf="unionSpec.tag.description" class="slot-start" fill="clear" size="small" (click)="presentUnionTagDescription(unionSpec.tag.name, unionSpec.tag.description)">
|
||||
<ion-icon name="help-circle-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-label>{{ unionSpec.tag.name }}</ion-label>
|
||||
<!-- class enter-click disables the enter click on the modal behind the select -->
|
||||
<ion-select
|
||||
[interfaceOptions]="{ message: getWarningText(unionSpec.warning), cssClass: 'enter-click' }"
|
||||
slot="end"
|
||||
placeholder="Select"
|
||||
[formControlName]="unionSpec.tag.id"
|
||||
[selectedText]="unionSpec.tag['variant-names'][entry.value.value]"
|
||||
(ionChange)="updateUnion($event)"
|
||||
>
|
||||
<ion-select-option *ngFor="let option of Object.keys(unionSpec.variants)" [value]="option">
|
||||
{{ unionSpec.tag['variant-names'][option] }}
|
||||
</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
<ng-container *ngIf="objectSpec[entry.key] as spec">
|
||||
<!-- primitive -->
|
||||
<ng-container *ngIf="['string', 'number', 'boolean', 'enum'] | includes : spec.type">
|
||||
<!-- label -->
|
||||
<h4 class="input-label">
|
||||
<form-label [data]="{
|
||||
spec: spec,
|
||||
new: current && current[entry.key] === undefined,
|
||||
edited: entry.value.dirty
|
||||
}"></form-label>
|
||||
</h4>
|
||||
<!-- string or number -->
|
||||
<ion-item color="dark" *ngIf="spec.type === 'string' || spec.type === 'number'">
|
||||
<ion-input
|
||||
[type]="spec.type === 'string' && spec.masked && !unmasked[entry.key] ? 'password' : 'text'"
|
||||
[inputmode]="spec.type === 'number' ? 'tel' : 'text'"
|
||||
[placeholder]="spec.placeholder || 'Enter ' + spec.name"
|
||||
[formControlName]="entry.key"
|
||||
(ionFocus)="presentAlertChangeWarning(entry.key, spec)"
|
||||
(ionChange)="handleInputChange()"
|
||||
>
|
||||
</ion-input>
|
||||
<ion-button *ngIf="spec.type === 'string' && spec.masked" slot="end" fill="clear" color="light" (click)="unmasked[entry.key] = !unmasked[entry.key]">
|
||||
<ion-icon slot="icon-only" [name]="unmasked[entry.key] ? 'eye-off-outline' : 'eye-outline'" size="small"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-note *ngIf="spec.type === 'number' && spec.units" slot="end" color="light" style="font-size: medium;">{{ spec.units }}</ion-note>
|
||||
</ion-item>
|
||||
<!-- boolean -->
|
||||
<ion-item *ngIf="spec.type === 'boolean'">
|
||||
<ion-label>{{ spec.name }}</ion-label>
|
||||
<ion-toggle slot="end" [formControlName]="entry.key" (ionChange)="handleBooleanChange(entry.key, spec)"></ion-toggle>
|
||||
</ion-item>
|
||||
<!-- enum -->
|
||||
<ion-item *ngIf="spec.type === 'enum'">
|
||||
<ion-label>{{ spec.name }}</ion-label>
|
||||
<!-- class enter-click disables the enter click on the modal behind the select -->
|
||||
<ion-select
|
||||
[interfaceOptions]="{ message: getWarningText(spec.warning), cssClass: 'enter-click' }"
|
||||
slot="end"
|
||||
placeholder="Select"
|
||||
[formControlName]="entry.key"
|
||||
[selectedText]="spec['value-names'][formGroup.get(entry.key).value]"
|
||||
>
|
||||
<ion-select-option *ngFor="let option of spec.values" [value]="option">
|
||||
{{ spec['value-names'][option] }}
|
||||
</ion-select-option>
|
||||
</ion-select>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
<!-- object or union -->
|
||||
<ng-container *ngIf="spec.type === 'object' || spec.type ==='union'">
|
||||
<!-- label -->
|
||||
<ion-item-divider (click)="toggleExpandObject(entry.key)" style="cursor: pointer;">
|
||||
<form-label [data]="{
|
||||
spec: spec,
|
||||
new: current && current[entry.key] === undefined,
|
||||
edited: entry.value.dirty
|
||||
}"
|
||||
></form-label>
|
||||
<ion-icon
|
||||
slot="end"
|
||||
name="chevron-up"
|
||||
[ngStyle]="{
|
||||
'transform': objectDisplay[entry.key].expanded ? 'rotate(0deg)' : 'rotate(180deg)',
|
||||
'transition': 'transform 0.25s ease-out'
|
||||
}"
|
||||
></ion-icon>
|
||||
</ion-item-divider>
|
||||
<!-- body -->
|
||||
<div
|
||||
[id]="getElementId(entry.key)"
|
||||
[ngStyle]="{
|
||||
'max-height': objectDisplay[entry.key].height,
|
||||
'overflow': 'hidden',
|
||||
'transition-property': 'max-height',
|
||||
'transition-duration': '.25s'
|
||||
}"
|
||||
>
|
||||
<div class="nested-wrapper">
|
||||
<form-object
|
||||
[objectSpec]="
|
||||
spec.type === 'union' ?
|
||||
spec.variants[$any(entry.value).controls[spec.tag.id].value] :
|
||||
spec.spec"
|
||||
[formGroup]="$any(entry.value)"
|
||||
[current]="current ? current[entry.key] : undefined"
|
||||
[unionSpec]="spec.type === 'union' ? spec : undefined"
|
||||
(onExpand)="resize(entry.key)"
|
||||
></form-object>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
<!-- list (not enum) -->
|
||||
<ng-container *ngIf="spec.type === 'list' && spec.subtype !== 'enum'">
|
||||
<ng-container *ngIf="formGroup.get(entry.key) as formArr" [formArrayName]="entry.key">
|
||||
<!-- label -->
|
||||
<ion-item-divider>
|
||||
<form-label [data]="{
|
||||
spec: spec,
|
||||
new: current && current[entry.key] === undefined,
|
||||
edited: entry.value.dirty
|
||||
}"></form-label>
|
||||
<ion-button fill="clear" color="primary" slot="end" (click)="addListItemWrapper(entry.key, spec)">
|
||||
<ion-icon slot="start" name="add"></ion-icon>
|
||||
Add
|
||||
</ion-button>
|
||||
</ion-item-divider>
|
||||
<!-- body -->
|
||||
<div class="nested-wrapper">
|
||||
<div
|
||||
*ngFor="let abstractControl of $any(formArr).controls; let i = index;"
|
||||
class="ion-padding-top"
|
||||
>
|
||||
<!-- nested -->
|
||||
<ng-container *ngIf="spec.subtype === 'object' || spec.subtype === 'union'">
|
||||
<!-- nested label -->
|
||||
<ion-item button (click)="toggleExpandListObject(entry.key, i)">
|
||||
<form-label [data]="{
|
||||
spec: $any({ name: objectListDisplay[entry.key][i].displayAs || 'Entry ' + (i + 1) }),
|
||||
new: false,
|
||||
edited: abstractControl.dirty,
|
||||
invalid: abstractControl.invalid
|
||||
}"></form-label>
|
||||
<ion-icon
|
||||
slot="end"
|
||||
name="chevron-up"
|
||||
[ngStyle]="{
|
||||
'transform': objectListDisplay[entry.key][i].expanded ? 'rotate(0deg)' : 'rotate(180deg)',
|
||||
'transition': 'transform 0.4s ease-out'
|
||||
}"
|
||||
></ion-icon>
|
||||
</ion-item>
|
||||
<!-- nested body -->
|
||||
<div
|
||||
[id]="getElementId(entry.key, i)"
|
||||
[ngStyle]="{
|
||||
'max-height': objectListDisplay[entry.key][i].height,
|
||||
'overflow': 'hidden',
|
||||
'transition-property': 'max-height',
|
||||
'transition-duration': '.5s',
|
||||
'transition-delay': '.05s'
|
||||
}"
|
||||
>
|
||||
<form-object
|
||||
[objectSpec]="
|
||||
spec.subtype === 'union' ?
|
||||
$any(spec.spec).variants[abstractControl.controls[$any(spec.spec).tag.id].value] :
|
||||
$any(spec.spec).spec"
|
||||
[formGroup]="abstractControl"
|
||||
[current]="current && current[entry.key] ? current[entry.key][i] : undefined"
|
||||
[unionSpec]="spec.subtype === 'union' ? $any(spec.spec) : undefined"
|
||||
(onInputChange)="updateLabel(entry.key, i, spec.spec['display-as'])"
|
||||
(onExpand)="resize(entry.key, i)"
|
||||
></form-object>
|
||||
<div style="text-align: right; padding-top: 12px;">
|
||||
<ion-button fill="clear" (click)="presentAlertDelete(entry.key, i)" color="danger">
|
||||
<ion-icon slot="start" name="close"></ion-icon>
|
||||
Delete
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
<!-- string or number -->
|
||||
<ion-item-group *ngIf="spec.subtype === 'string' || spec.subtype === 'number'">
|
||||
<ion-item color="dark">
|
||||
<ion-input
|
||||
[type]="$any(spec.spec).masked ? 'password' : 'text'"
|
||||
[inputmode]="spec.subtype === 'number' ? 'tel' : 'text'"
|
||||
[placeholder]="$any(spec.spec).placeholder || 'Enter ' + spec.name"
|
||||
[formControlName]="i"
|
||||
>
|
||||
</ion-input>
|
||||
<ion-button slot="end" color="danger" (click)="presentAlertDelete(entry.key, i)">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
<form-error
|
||||
*ngIf="abstractControl.errors"
|
||||
[control]="abstractControl"
|
||||
[spec]="$any(spec.spec)"
|
||||
>
|
||||
</form-error>
|
||||
</ion-item-group>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<!-- list (enum) -->
|
||||
<ng-container *ngIf="spec.type === 'list' && spec.subtype === 'enum'">
|
||||
<ng-container *ngIf="formGroup.get(entry.key) as formArr" [formArrayName]="entry.key">
|
||||
<!-- label -->
|
||||
<p class="input-label">
|
||||
<form-label [data]="{
|
||||
spec: spec,
|
||||
new: current && current[entry.key] === undefined,
|
||||
edited: entry.value.dirty
|
||||
}"></form-label>
|
||||
</p>
|
||||
<!-- list -->
|
||||
<ion-item button detail="false" color="dark" (click)="presentModalEnumList(entry.key, $any(spec), formArr.value)">
|
||||
<ion-label style="white-space: nowrap !important;">
|
||||
<h2>{{ getEnumListDisplay(formArr.value, $any(spec.spec)) }}</h2>
|
||||
</ion-label>
|
||||
<ion-button slot="end" fill="clear" color="light">
|
||||
<ion-icon slot="icon-only" name="chevron-down"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<form-error
|
||||
*ngIf="formGroup.get(entry.key).errors"
|
||||
[control]="$any(formGroup.get(entry.key))"
|
||||
[spec]="spec"
|
||||
>
|
||||
</form-error>
|
||||
</ng-container>
|
||||
</div>
|
||||
</ion-item-group>
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { FormObjectComponent, FormLabelComponent, FormErrorComponent } from './form-object.component'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
import { EnumListPageModule } from 'src/app/modals/enum-list/enum-list.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
FormObjectComponent,
|
||||
FormLabelComponent,
|
||||
FormErrorComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
SharingModule,
|
||||
EnumListPageModule,
|
||||
],
|
||||
exports: [
|
||||
FormObjectComponent,
|
||||
FormLabelComponent,
|
||||
FormErrorComponent,
|
||||
],
|
||||
})
|
||||
export class FormObjectComponentModule { }
|
||||
@@ -0,0 +1,26 @@
|
||||
.slot-start {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
ion-input {
|
||||
font-weight: 500;
|
||||
--placeholder-font-weight: 400;
|
||||
}
|
||||
|
||||
ion-item-divider {
|
||||
text-transform: unset;
|
||||
--padding-start: 0;
|
||||
border-bottom: 1px solid var(--ion-item-border-color, var(--ion-border-color, var(--ion-color-step-150, rgba(0, 0, 0, 0.13))))
|
||||
}
|
||||
|
||||
.nested-wrapper {
|
||||
padding: 0 0 30px 30px;
|
||||
}
|
||||
|
||||
.validation-error {
|
||||
p {
|
||||
font-size: small;
|
||||
color: var(--ion-color-danger);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { Component, Input, Output, EventEmitter } from '@angular/core'
|
||||
import { AbstractFormGroupDirective, FormArray, FormGroup } from '@angular/forms'
|
||||
import { AlertButton, AlertController, IonicSafeString, ModalController } from '@ionic/angular'
|
||||
import { ConfigSpec, ListValueSpecOf, ValueSpec, ValueSpecBoolean, ValueSpecList, ValueSpecListOf, ValueSpecUnion } from 'src/app/pkg-config/config-types'
|
||||
import { FormService } from 'src/app/services/form.service'
|
||||
import { Range } from 'src/app/pkg-config/config-utilities'
|
||||
import { EnumListPage } from 'src/app/modals/enum-list/enum-list.page'
|
||||
import { pauseFor } from 'src/app/util/misc.util'
|
||||
import { v4 } from 'uuid'
|
||||
const Mustache = require('mustache')
|
||||
|
||||
@Component({
|
||||
selector: 'form-object',
|
||||
templateUrl: './form-object.component.html',
|
||||
styleUrls: ['./form-object.component.scss'],
|
||||
})
|
||||
export class FormObjectComponent {
|
||||
@Input() objectSpec: ConfigSpec
|
||||
@Input() formGroup: FormGroup
|
||||
@Input() unionSpec: ValueSpecUnion
|
||||
@Input() current: { [key: string]: any }
|
||||
@Input() showEdited: boolean = false
|
||||
@Output() onInputChange = new EventEmitter<void>()
|
||||
@Output() onExpand = new EventEmitter<void>()
|
||||
warningAck: { [key: string]: boolean } = { }
|
||||
unmasked: { [key: string]: boolean } = { }
|
||||
objectDisplay: { [key: string]: { expanded: boolean, height: string } } = { }
|
||||
objectListDisplay: { [key: string]: { expanded: boolean, height: string, displayAs: string }[] } = { }
|
||||
private objectId = v4()
|
||||
|
||||
Object = Object
|
||||
|
||||
constructor (
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly formService: FormService,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
Object.keys(this.objectSpec).forEach(key => {
|
||||
const spec = this.objectSpec[key]
|
||||
if (spec.type === 'list' && ['object', 'union'].includes(spec.subtype)) {
|
||||
this.objectListDisplay[key] = [];
|
||||
(this.formGroup.get(key).value as any[]).forEach((obj, index) => {
|
||||
const displayAs = (spec.spec as ListValueSpecOf<'object'>)['display-as']
|
||||
this.objectListDisplay[key][index] = {
|
||||
expanded: false,
|
||||
height: '0px',
|
||||
displayAs: displayAs ? (Mustache as any).render(displayAs, obj) : '',
|
||||
}
|
||||
})
|
||||
} else if (['object', 'union'].includes(spec.type)) {
|
||||
this.objectDisplay[key] = {
|
||||
expanded: false,
|
||||
height: '0px',
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
getEnumListDisplay (arr: string[], spec: ListValueSpecOf<'enum'>): string {
|
||||
return arr.map((v: string) => spec['value-names'][v]).join(', ')
|
||||
}
|
||||
|
||||
updateUnion (e: any): void {
|
||||
const primary = this.unionSpec.tag.id
|
||||
|
||||
Object.keys(this.formGroup.controls).forEach(control => {
|
||||
if (control === primary) return
|
||||
this.formGroup.removeControl(control)
|
||||
})
|
||||
|
||||
const unionGroup = this.formService.getUnionObject(this.unionSpec as ValueSpecUnion, e.detail.value)
|
||||
|
||||
Object.keys(unionGroup.controls).forEach(control => {
|
||||
if (control === primary) return
|
||||
this.formGroup.addControl(control, unionGroup.controls[control])
|
||||
})
|
||||
|
||||
Object.entries(this.unionSpec.variants[e.detail.value]).forEach(([key, value]) => {
|
||||
if (['object', 'union'].includes(value.type)) {
|
||||
this.objectDisplay[key] = {
|
||||
expanded: false,
|
||||
height: '0px',
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
this.onExpand.emit()
|
||||
}
|
||||
|
||||
resize (key: string, i?: number): void {
|
||||
setTimeout(() => {
|
||||
if (i !== undefined) {
|
||||
this.objectListDisplay[key][i].height = this.getDocSize(key, i)
|
||||
} else {
|
||||
this.objectDisplay[key].height = this.getDocSize(key)
|
||||
}
|
||||
this.onExpand.emit()
|
||||
}, 250) // 250 to match transition-duration, defined in html
|
||||
}
|
||||
|
||||
addListItemWrapper (key: string, spec: ValueSpec) {
|
||||
this.presentAlertChangeWarning(key, spec, () => this.addListItem(key))
|
||||
}
|
||||
|
||||
addListItem (key: string, markDirty = true, val?: string): void {
|
||||
const arr = this.formGroup.get(key) as FormArray
|
||||
if (markDirty) arr.markAsDirty()
|
||||
const listSpec = this.objectSpec[key] as ValueSpecList
|
||||
const newItem = this.formService.getListItem(listSpec, val)
|
||||
newItem.markAllAsTouched()
|
||||
arr.insert(0, newItem)
|
||||
if (['object', 'union'].includes(listSpec.subtype)) {
|
||||
const displayAs = (listSpec.spec as ListValueSpecOf<'object'>)['display-as']
|
||||
this.objectListDisplay[key].unshift({
|
||||
height: '0px',
|
||||
expanded: true,
|
||||
displayAs: displayAs ? Mustache.render(displayAs, newItem.value) : '',
|
||||
})
|
||||
|
||||
pauseFor(200).then(() => {
|
||||
this.objectListDisplay[key][0].height = this.getDocSize(key, 0)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
toggleExpandObject (key: string) {
|
||||
this.objectDisplay[key].expanded = !this.objectDisplay[key].expanded
|
||||
this.objectDisplay[key].height = this.objectDisplay[key].expanded ? this.getDocSize(key) : '0px'
|
||||
this.onExpand.emit()
|
||||
}
|
||||
|
||||
toggleExpandListObject (key: string, i: number) {
|
||||
this.objectListDisplay[key][i].expanded = !this.objectListDisplay[key][i].expanded
|
||||
this.objectListDisplay[key][i].height = this.objectListDisplay[key][i].expanded ? this.getDocSize(key, i) : '0px'
|
||||
}
|
||||
|
||||
updateLabel (key: string, i: number, displayAs: string) {
|
||||
this.objectListDisplay[key][i].displayAs = displayAs ? Mustache.render(displayAs, this.formGroup.get(key).value[i]) : ''
|
||||
}
|
||||
|
||||
getWarningText (text: string): IonicSafeString {
|
||||
if (text) return new IonicSafeString(`<ion-text color="warning">${text}</ion-text>`)
|
||||
}
|
||||
|
||||
handleInputChange () {
|
||||
this.onInputChange.emit()
|
||||
}
|
||||
|
||||
handleBooleanChange (key: string, spec: ValueSpecBoolean) {
|
||||
if (spec.warning) {
|
||||
const current = this.formGroup.get(key).value
|
||||
const cancelFn = () => this.formGroup.get(key).setValue(!current)
|
||||
this.presentAlertChangeWarning(key, spec, undefined, cancelFn)
|
||||
}
|
||||
}
|
||||
|
||||
async presentModalEnumList (key: string, spec: ValueSpecListOf<'enum'>, current: string[]) {
|
||||
const modal = await this.modalCtrl.create({
|
||||
componentProps: {
|
||||
key,
|
||||
spec,
|
||||
current,
|
||||
},
|
||||
component: EnumListPage,
|
||||
})
|
||||
|
||||
modal.onWillDismiss().then((res: { data: string[] }) => {
|
||||
const data = res.data
|
||||
if (!data) return
|
||||
this.updateEnumList(key, current, data)
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
async presentAlertChangeWarning (key: string, spec: ValueSpec, okFn?: Function, cancelFn?: Function) {
|
||||
if (!spec.warning || this.warningAck[key]) return okFn ? okFn() : null
|
||||
this.warningAck[key] = true
|
||||
|
||||
const buttons: AlertButton[] = [
|
||||
{
|
||||
text: 'Ok',
|
||||
handler: () => {
|
||||
if (okFn) okFn()
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
]
|
||||
|
||||
if (okFn || cancelFn) {
|
||||
buttons.unshift({
|
||||
text: 'Cancel',
|
||||
handler: () => {
|
||||
if (cancelFn) cancelFn()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Warning',
|
||||
subHeader: `Editing ${spec.name} has consequences:`,
|
||||
message: spec.warning,
|
||||
buttons,
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
async presentAlertDelete (key: string, index: number) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Confirm',
|
||||
message: 'Are you sure you want to delete this entry?',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Delete',
|
||||
handler: () => {
|
||||
this.deleteListItem(key, index)
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
private deleteListItem (key: string, index: number, markDirty = true): void {
|
||||
if (this.objectListDisplay[key]) this.objectListDisplay[key][index].height = '0px'
|
||||
const arr = this.formGroup.get(key) as FormArray
|
||||
if (markDirty) arr.markAsDirty()
|
||||
pauseFor(250).then(() => {
|
||||
if (this.objectListDisplay[key]) this.objectListDisplay[key].splice(index, 1)
|
||||
arr.removeAt(index)
|
||||
})
|
||||
}
|
||||
|
||||
private updateEnumList (key: string, current: string[], updated: string[]) {
|
||||
this.formGroup.get(key).markAsDirty()
|
||||
|
||||
for (let i = current.length - 1; i >= 0; i--) {
|
||||
if (!updated.includes(current[i])) {
|
||||
this.deleteListItem(key, i, false)
|
||||
}
|
||||
}
|
||||
|
||||
updated.forEach(val => {
|
||||
if (!current.includes(val)) {
|
||||
this.addListItem(key, false, val)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private getDocSize (key: string, index = 0) {
|
||||
const element = document.getElementById(this.getElementId(key, index))
|
||||
return `${element.scrollHeight}px`
|
||||
}
|
||||
|
||||
getElementId (key: string, index = 0): string {
|
||||
return `${key}-${index}-${this.objectId}`
|
||||
}
|
||||
|
||||
async presentUnionTagDescription (name: string, description: string) {
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: name,
|
||||
message: description,
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
asIsOrder () {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
interface HeaderData {
|
||||
spec: ValueSpec
|
||||
edited: boolean
|
||||
new: boolean
|
||||
invalid?: boolean
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'form-label',
|
||||
templateUrl: './form-label.component.html',
|
||||
styleUrls: ['./form-object.component.scss'],
|
||||
})
|
||||
export class FormLabelComponent {
|
||||
Range = Range
|
||||
@Input() data: HeaderData
|
||||
|
||||
constructor (
|
||||
private readonly alertCtrl: AlertController,
|
||||
) { }
|
||||
|
||||
async presentAlertDescription () {
|
||||
const { name, description } = this.data.spec
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: name,
|
||||
message: description,
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'form-error',
|
||||
templateUrl: './form-error.component.html',
|
||||
styleUrls: ['./form-object.component.scss'],
|
||||
})
|
||||
export class FormErrorComponent {
|
||||
@Input() control: AbstractFormGroupDirective
|
||||
@Input() spec: ValueSpec
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
<div class="slide-content">
|
||||
<div style="margin-top: 25px;">
|
||||
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;">
|
||||
<ion-label [color]="params.titleColor" style="font-size: xx-large; font-weight: bold;">
|
||||
{{ params.title }}
|
||||
</ion-label>
|
||||
</div>
|
||||
<div class="long-message" [innerHTML]="params.message | markdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { AlertComponent } from './alert.component'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AlertComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild([]),
|
||||
SharingModule,
|
||||
],
|
||||
exports: [AlertComponent],
|
||||
})
|
||||
export class AlertComponentModule { }
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { BehaviorSubject, Subject } from 'rxjs'
|
||||
|
||||
@Component({
|
||||
selector: 'alert',
|
||||
templateUrl: './alert.component.html',
|
||||
styleUrls: ['../install-wizard.component.scss'],
|
||||
})
|
||||
export class AlertComponent {
|
||||
@Input() params: {
|
||||
title: string
|
||||
message: string
|
||||
titleColor: string
|
||||
}
|
||||
|
||||
loading$ = new BehaviorSubject(false)
|
||||
cancel$ = new Subject<void>()
|
||||
|
||||
load () { }
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<div *ngIf="loading$ | async" class="center-spinner">
|
||||
<ion-spinner color="warning" name="lines"></ion-spinner>
|
||||
<ion-label class="long-message">{{ message }}</ion-label>
|
||||
</div>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { CompleteComponent } from './complete.component'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule } from '@angular/router'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
CompleteComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild([]),
|
||||
],
|
||||
exports: [CompleteComponent],
|
||||
})
|
||||
export class CompleteComponentModule { }
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { BehaviorSubject, from, Subject } from 'rxjs'
|
||||
import { takeUntil } from 'rxjs/operators'
|
||||
import { capitalizeFirstLetter } from 'src/app/util/misc.util'
|
||||
import { markAsLoadingDuring$ } from '../loadable'
|
||||
import { WizardAction } from '../wizard-types'
|
||||
|
||||
@Component({
|
||||
selector: 'complete',
|
||||
templateUrl: './complete.component.html',
|
||||
styleUrls: ['../install-wizard.component.scss'],
|
||||
})
|
||||
export class CompleteComponent {
|
||||
@Input() params: {
|
||||
action: WizardAction
|
||||
verb: string // loader verb: '*stopping* ...'
|
||||
title: string
|
||||
executeAction: () => Promise<any>
|
||||
}
|
||||
|
||||
@Input() transitions: {
|
||||
cancel: () => any
|
||||
next: (prevResult?: any) => any
|
||||
final: () => any
|
||||
error: (e: Error) => any
|
||||
}
|
||||
|
||||
loading$ = new BehaviorSubject(false)
|
||||
cancel$ = new Subject<void>()
|
||||
|
||||
message: string
|
||||
|
||||
load () {
|
||||
markAsLoadingDuring$(this.loading$, from(this.params.executeAction())).pipe(takeUntil(this.cancel$)).subscribe(
|
||||
{
|
||||
error: e => this.transitions.error(new Error(`${this.params.action} failed: ${e.message || e}`)),
|
||||
complete: () => this.transitions.final(),
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
ngOnInit () {
|
||||
this.message = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title}...`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<div *ngIf="loading$ | async" class="center-spinner">
|
||||
<ion-spinner color="warning" name="lines"></ion-spinner>
|
||||
<ion-label class="long-message">Checking for installed services which depend on {{ params.title }}...</ion-label>
|
||||
</div>
|
||||
|
||||
<div *ngIf="!(loading$ | async) && !!dependentViolation" class="slide-content">
|
||||
<div style="margin-top: 25px;">
|
||||
|
||||
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;">
|
||||
<ion-label color="warning" style="font-size: xx-large; font-weight: bold;">
|
||||
WARNING
|
||||
</ion-label>
|
||||
</div>
|
||||
|
||||
<div class="long-message">
|
||||
{{ dependentViolation }}
|
||||
</div>
|
||||
|
||||
<div style="margin: 25px 0px;">
|
||||
<div style="border-width: 0px 0px 1px 0px; font-size: unset; text-align: left; font-weight: bold; margin-left: 13px; border-style: solid; border-color: var(--ion-color-light-tint);">
|
||||
<ion-text color="warning">Affected Services</ion-text>
|
||||
</div>
|
||||
|
||||
<ion-item
|
||||
style="--ion-item-background: margin-top: 5px"
|
||||
*ngFor="let dep of dependentBreakages | keyvalue"
|
||||
>
|
||||
<ion-thumbnail style="position: relative; height: 4vh; width: 4vh" slot="start">
|
||||
<img [src]="patch.data['package-data'][dep.key]['static-files'].icon" />
|
||||
</ion-thumbnail>
|
||||
<ion-label>
|
||||
<h5>{{ patch.data['package-data'][dep.key].manifest.title }}</h5>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { DependentsComponent } from './dependents.component'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
DependentsComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild([]),
|
||||
SharingModule,
|
||||
],
|
||||
exports: [DependentsComponent],
|
||||
})
|
||||
export class DependentsComponentModule { }
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { BehaviorSubject, from, Subject } from 'rxjs'
|
||||
import { takeUntil, tap } from 'rxjs/operators'
|
||||
import { Breakages } from 'src/app/services/api/api.types'
|
||||
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
|
||||
import { capitalizeFirstLetter, isEmptyObject } from 'src/app/util/misc.util'
|
||||
import { markAsLoadingDuring$ } from '../loadable'
|
||||
import { WizardAction } from '../wizard-types'
|
||||
|
||||
@Component({
|
||||
selector: 'dependents',
|
||||
templateUrl: './dependents.component.html',
|
||||
styleUrls: ['../install-wizard.component.scss'],
|
||||
})
|
||||
export class DependentsComponent {
|
||||
@Input() params: {
|
||||
title: string,
|
||||
action: WizardAction, //Are you sure you want to *uninstall*...,
|
||||
verb: string, // *Uninstalling* will cause problems...
|
||||
fetchBreakages: () => Promise<Breakages>
|
||||
}
|
||||
@Input() transitions: {
|
||||
cancel: () => any
|
||||
next: (prevResult?: any) => any
|
||||
final: () => any
|
||||
error: (e: Error) => any
|
||||
}
|
||||
|
||||
dependentBreakages: Breakages
|
||||
dependentViolation: string | undefined
|
||||
|
||||
loading$ = new BehaviorSubject(false)
|
||||
cancel$ = new Subject<void>()
|
||||
|
||||
constructor (
|
||||
public readonly patch: PatchDbService,
|
||||
) { }
|
||||
|
||||
load () {
|
||||
markAsLoadingDuring$(this.loading$, from(this.params.fetchBreakages()))
|
||||
.pipe(
|
||||
takeUntil(this.cancel$),
|
||||
tap(breakages => this.dependentBreakages = breakages),
|
||||
)
|
||||
.subscribe(
|
||||
{
|
||||
complete: () => {
|
||||
if (this.dependentBreakages && !isEmptyObject(this.dependentBreakages)) {
|
||||
this.dependentViolation = `${capitalizeFirstLetter(this.params.verb)} ${this.params.title} will prohibit the following services from functioning properly and may cause them to stop if they are currently running.`
|
||||
} else {
|
||||
this.transitions.next()
|
||||
}
|
||||
},
|
||||
error: (e: Error) => this.transitions.error(new Error(`Fetching dependent service information failed: ${e.message || e}`)),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<ion-header style="height: 12vh">
|
||||
<ion-toolbar>
|
||||
<ion-label class="toolbar-label text-ellipses">
|
||||
<h1 class="toolbar-title">{{ params.toolbar.title }}</h1>
|
||||
<h3 style="font-size: large; font-style: italic">{{ params.toolbar.action }} <ion-text style="font-size: medium;">{{ params.toolbar.version | displayEmver }}</ion-text></h3>
|
||||
</ion-label>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<ion-slides *ngIf="!error" id="slide-show" style="--bullet-background: white" pager="false">
|
||||
<ion-slide *ngFor="let def of params.slideDefinitions">
|
||||
<!-- We can pass [transitions]="transitions" into the component if logic within the component needs to trigger a transition (not just bottom bar) -->
|
||||
<alert #components *ngIf="def.slide.selector === 'alert'" [params]="def.slide.params" style="width: 100%;"></alert>
|
||||
<notes #components *ngIf="def.slide.selector === 'notes'" [params]="def.slide.params" style="width: 100%;"></notes>
|
||||
<dependents #components *ngIf="def.slide.selector === 'dependents'" [params]="def.slide.params" [transitions]="transitions"></dependents>
|
||||
<complete #components *ngIf="def.slide.selector === 'complete'" [params]="def.slide.params" [transitions]="transitions"></complete>
|
||||
</ion-slide>
|
||||
</ion-slides>
|
||||
|
||||
<div *ngIf="error" class="slide-content">
|
||||
<div style="margin-top: 25px;">
|
||||
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;">
|
||||
<ion-label color="danger" style="font-size: xx-large; font-weight: bold;">
|
||||
Error
|
||||
</ion-label>
|
||||
</div>
|
||||
<div class="long-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar style="padding: 8px;">
|
||||
<ng-container *ngIf="!initializing && !error">
|
||||
|
||||
<!-- cancel button if loading/not loading -->
|
||||
<ion-button slot="start" *ngIf="(currentSlide.loading$ | async) && currentBottomBar.cancel.whileLoading as cancel" (click)="transitions.cancel()" class="toolbar-button" fill="outline">
|
||||
<ion-text *ngIf="cancel.text" [class.smaller-text]="cancel.text.length > 16">{{ cancel.text }}</ion-text>
|
||||
<ion-icon *ngIf="!cancel.text" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button slot="start" *ngIf="!(currentSlide.loading$ | async) && currentBottomBar.cancel.afterLoading as cancel" (click)="transitions.cancel()" class="toolbar-button" fill="outline">
|
||||
<ion-text *ngIf="cancel.text" [class.smaller-text]="cancel.text.length > 16">{{ cancel.text }}</ion-text>
|
||||
<ion-icon *ngIf="!cancel.text" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
|
||||
<!-- next/finish buttons -->
|
||||
<ng-container *ngIf="!(currentSlide.loading$ | async)">
|
||||
<!-- next -->
|
||||
<ion-button slot="end" *ngIf="currentBottomBar.next as next" (click)="callTransition(transitions.next)" fill="outline" class="toolbar-button enter-click" [class.no-click]="transitioning">
|
||||
<ion-text [class.smaller-text]="next.length > 16">{{ next }}</ion-text>
|
||||
</ion-button>
|
||||
|
||||
<!-- finish -->
|
||||
<ion-button slot="end" *ngIf="currentBottomBar.finish as finish" (click)="callTransition(transitions.final)" fill="outline" class="toolbar-button enter-click" [class.no-click]="transitioning">
|
||||
<ion-text [class.smaller-text]="finish.length > 16">{{ finish }}</ion-text>
|
||||
</ion-button>
|
||||
</ng-container>
|
||||
|
||||
</ng-container>
|
||||
<ng-container *ngIf="error">
|
||||
<ion-button slot="start" (click)="transitions.final()" style="text-transform: capitalize; font-weight: bolder;" color="danger">Dismiss</ion-button>
|
||||
</ng-container>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { InstallWizardComponent } from './install-wizard.component'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
import { DependentsComponentModule } from './dependents/dependents.component.module'
|
||||
import { CompleteComponentModule } from './complete/complete.component.module'
|
||||
import { NotesComponentModule } from './notes/notes.component.module'
|
||||
import { AlertComponentModule } from './alert/alert.component.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
InstallWizardComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild([]),
|
||||
SharingModule,
|
||||
DependentsComponentModule,
|
||||
CompleteComponentModule,
|
||||
NotesComponentModule,
|
||||
AlertComponentModule,
|
||||
],
|
||||
exports: [InstallWizardComponent],
|
||||
})
|
||||
export class InstallWizardComponentModule { }
|
||||
@@ -0,0 +1,81 @@
|
||||
.toolbar-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
color: white;
|
||||
padding: 8px 0px 8px 15px;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
font-size: x-large;
|
||||
text-transform: capitalize;
|
||||
border-style: solid;
|
||||
border-width: 0px 0px 1px 0px;
|
||||
border-color: #404040;
|
||||
font-family: 'Montserrat';
|
||||
}
|
||||
|
||||
.center-spinner {
|
||||
min-height: 40vh;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
color:white;
|
||||
}
|
||||
|
||||
.slide-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
color:white;
|
||||
min-height: 40vh
|
||||
}
|
||||
|
||||
.status-label {
|
||||
font-size: xx-large;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.long-message {
|
||||
margin-left: 5%;
|
||||
margin-right: 5%;
|
||||
padding: 10px;
|
||||
font-size: small;
|
||||
border-width: 0px 0px 1px 0px;
|
||||
border-color: #393b40;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@media (min-width:500px) {
|
||||
.long-message {
|
||||
margin-left: 5%;
|
||||
margin-right: 5%;
|
||||
padding: 10px;
|
||||
font-size: medium;
|
||||
border-width: 0px 0px 1px 0px;
|
||||
border-color: #393b40;
|
||||
text-align: left;
|
||||
}
|
||||
}
|
||||
|
||||
.toolbar-button {
|
||||
text-transform: capitalize;
|
||||
font-weight: bolder;
|
||||
}
|
||||
|
||||
.smaller-text {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
width: 2vh;
|
||||
height: 2vh;
|
||||
border-radius: 50px;
|
||||
left: -0.75vh;
|
||||
top: -0.75vh;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Component, Input, NgZone, QueryList, ViewChild, ViewChildren } from '@angular/core'
|
||||
import { IonContent, IonSlides, ModalController } from '@ionic/angular'
|
||||
import { capitalizeFirstLetter, pauseFor } from 'src/app/util/misc.util'
|
||||
import { CompleteComponent } from './complete/complete.component'
|
||||
import { DependentsComponent } from './dependents/dependents.component'
|
||||
import { AlertComponent } from './alert/alert.component'
|
||||
import { NotesComponent } from './notes/notes.component'
|
||||
import { Loadable } from './loadable'
|
||||
import { WizardAction } from './wizard-types'
|
||||
|
||||
@Component({
|
||||
selector: 'install-wizard',
|
||||
templateUrl: './install-wizard.component.html',
|
||||
styleUrls: ['./install-wizard.component.scss'],
|
||||
})
|
||||
export class InstallWizardComponent {
|
||||
transitioning = false
|
||||
|
||||
@Input() params: {
|
||||
// defines each slide along with bottom bar
|
||||
slideDefinitions: SlideDefinition[]
|
||||
toolbar: TopbarParams
|
||||
}
|
||||
|
||||
// content container so we can scroll to top between slide transitions
|
||||
@ViewChild(IonContent) contentContainer: IonContent
|
||||
// slide container gives us hook into ion-slide, allowing for slide transitions
|
||||
@ViewChild(IonSlides) slideContainer: IonSlides
|
||||
|
||||
//a slide component gives us hook into a slide. Allows us to call load when slide comes into view
|
||||
@ViewChildren('components')
|
||||
slideComponentsQL: QueryList<Loadable>
|
||||
get slideComponents (): Loadable[] { return this.slideComponentsQL.toArray() }
|
||||
|
||||
private slideIndex = 0
|
||||
get currentSlide (): Loadable {
|
||||
return this.slideComponents[this.slideIndex]
|
||||
}
|
||||
get currentBottomBar (): SlideDefinition['bottomBar'] {
|
||||
return this.params.slideDefinitions[this.slideIndex].bottomBar
|
||||
}
|
||||
|
||||
initializing = true
|
||||
error = ''
|
||||
|
||||
constructor (
|
||||
private readonly modalController: ModalController,
|
||||
private readonly zone: NgZone,
|
||||
) { }
|
||||
|
||||
ngAfterViewInit () {
|
||||
this.currentSlide.load()
|
||||
this.slideContainer.update()
|
||||
this.slideContainer.lockSwipes(true)
|
||||
}
|
||||
|
||||
ionViewDidEnter () {
|
||||
this.initializing = false
|
||||
}
|
||||
|
||||
// process bottom bar buttons
|
||||
private transition = (info: { next: any } | { error: Error } | { cancelled: true } | { final: true }) => {
|
||||
const i = info as { next?: any, error?: Error, cancelled?: true, final?: true }
|
||||
if (i.cancelled) this.currentSlide.cancel$.next()
|
||||
if (i.final || i.cancelled) return this.modalController.dismiss(i)
|
||||
if (i.error) return this.error = capitalizeFirstLetter(i.error.message)
|
||||
|
||||
this.moveToNextSlide(i.next)
|
||||
}
|
||||
|
||||
// bottom bar button callbacks. Pass this into components if they need to trigger slide transitions independent of the bottom bar clicks
|
||||
transitions = {
|
||||
next: (prevResult: any) => this.transition({ next: prevResult || this.currentSlide.result }),
|
||||
cancel: () => this.transition({ cancelled: true }),
|
||||
final: () => this.transition({ final: true }),
|
||||
error: (e: Error) => this.transition({ error: e }),
|
||||
}
|
||||
|
||||
private async moveToNextSlide (prevResult?: any) {
|
||||
if (this.slideComponents[this.slideIndex + 1] === undefined) { return this.transition({ final: true }) }
|
||||
this.zone.run(async () => {
|
||||
this.slideComponents[this.slideIndex + 1].load(prevResult)
|
||||
await pauseFor(50) // give the load ^ opportunity to propogate into slide before sliding it into view
|
||||
this.slideIndex += 1
|
||||
await this.slideContainer.lockSwipes(false)
|
||||
await this.contentContainer.scrollToTop()
|
||||
await this.slideContainer.slideNext(500)
|
||||
await this.slideContainer.lockSwipes(true)
|
||||
})
|
||||
}
|
||||
|
||||
async callTransition (transition: Function) {
|
||||
this.transitioning = true
|
||||
await transition()
|
||||
this.transitioning = false
|
||||
}
|
||||
}
|
||||
|
||||
export interface SlideDefinition {
|
||||
slide:
|
||||
{ selector: 'dependents', params: DependentsComponent['params'] } |
|
||||
{ selector: 'complete', params: CompleteComponent['params'] } |
|
||||
{ selector: 'alert', params: AlertComponent['params'] } |
|
||||
{ selector: 'notes', params: NotesComponent['params'] }
|
||||
bottomBar: {
|
||||
cancel: {
|
||||
// indicates the existence of a cancel button, and whether to have text or an icon 'x' by default.
|
||||
afterLoading?: { text?: string },
|
||||
whileLoading?: { text?: string }
|
||||
}
|
||||
// indicates the existence of next or finish buttons (should only have one)
|
||||
next?: string
|
||||
finish?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type TopbarParams = { action: WizardAction, title: string, version: string }
|
||||
|
||||
export async function wizardModal (
|
||||
modalController: ModalController, params: InstallWizardComponent['params'],
|
||||
): Promise<{ cancelled?: true, final?: true, modal: HTMLIonModalElement }> {
|
||||
const modal = await modalController.create({
|
||||
backdropDismiss: false,
|
||||
cssClass: 'wizard-modal',
|
||||
component: InstallWizardComponent,
|
||||
componentProps: { params },
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
return modal.onWillDismiss().then(({ data }) => ({ ...data, modal }))
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { BehaviorSubject, Observable, Subject } from 'rxjs'
|
||||
import { concatMap, finalize } from 'rxjs/operators'
|
||||
import { fromSync$, emitAfter$ } from 'src/app/util/rxjs.util'
|
||||
|
||||
export interface Loadable {
|
||||
load: (prevResult?: any) => void
|
||||
result?: any // fill this variable on slide 1 to get passed into the load on slide 2. If this variable is falsey, it will skip the next slide.
|
||||
loading$: BehaviorSubject<boolean> // will be true during load function
|
||||
cancel$: Subject<void> // will cancel load function
|
||||
}
|
||||
|
||||
export function markAsLoadingDuring$<T> (trigger$: Subject<boolean>, o: Observable<T>): Observable<T> {
|
||||
let shouldBeOn = true
|
||||
const displayIfItsBeenAtLeast = 5 // ms
|
||||
return fromSync$(() => {
|
||||
emitAfter$(displayIfItsBeenAtLeast).subscribe(() => { if (shouldBeOn) trigger$.next(true) })
|
||||
}).pipe(
|
||||
concatMap(() => o),
|
||||
finalize(() => {
|
||||
trigger$.next(false)
|
||||
shouldBeOn = false
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<div class="slide-content">
|
||||
<div style="margin-top: 25px;">
|
||||
<div style="margin: 15px; display: flex; justify-content: center; align-items: center;">
|
||||
<ion-label [color]="params.titleColor">
|
||||
<h1>{{ params.title }}</h1>
|
||||
<h2>{{ params.headline }}</h2>
|
||||
</ion-label>
|
||||
</div>
|
||||
<div *ngFor="let note of params.notes | keyvalue : asIsOrder">
|
||||
<h2>{{ note.key }}</h2>
|
||||
<div class="long-message" [innerHTML]="note.value | markdown"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NotesComponent } from './notes.component'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
NotesComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild([]),
|
||||
SharingModule,
|
||||
],
|
||||
exports: [NotesComponent],
|
||||
})
|
||||
export class NotesComponentModule { }
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { BehaviorSubject, Subject } from 'rxjs'
|
||||
|
||||
@Component({
|
||||
selector: 'notes',
|
||||
templateUrl: './notes.component.html',
|
||||
styleUrls: ['../install-wizard.component.scss'],
|
||||
})
|
||||
export class NotesComponent {
|
||||
@Input() params: {
|
||||
notes: { [version: string]: string }
|
||||
title: string
|
||||
titleColor: string
|
||||
headline: string
|
||||
}
|
||||
|
||||
load () { }
|
||||
loading$ = new BehaviorSubject(false)
|
||||
cancel$ = new Subject<void>()
|
||||
|
||||
asIsOrder () {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
import { Breakages } from 'src/app/services/api/api.types'
|
||||
import { exists } from 'src/app/util/misc.util'
|
||||
import { ApiService } from '../../services/api/embassy-api.service'
|
||||
import { InstallWizardComponent, SlideDefinition, TopbarParams } from './install-wizard.component'
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class WizardBaker {
|
||||
constructor (
|
||||
private readonly embassyApi: ApiService,
|
||||
) { }
|
||||
|
||||
update (values: {
|
||||
id: string
|
||||
title: string
|
||||
version: string
|
||||
installAlert?: string
|
||||
}): InstallWizardComponent['params'] {
|
||||
const { id, title, version, installAlert } = values
|
||||
|
||||
const action = 'update'
|
||||
const toolbar: TopbarParams = { action, title, version }
|
||||
|
||||
const slideDefinitions: SlideDefinition[] = [
|
||||
installAlert ? {
|
||||
slide: {
|
||||
selector: 'alert',
|
||||
params: {
|
||||
title: 'Warning',
|
||||
message: installAlert,
|
||||
titleColor: 'warning',
|
||||
},
|
||||
},
|
||||
bottomBar: {
|
||||
cancel: {
|
||||
afterLoading: { text: 'Cancel' },
|
||||
},
|
||||
next: 'Next',
|
||||
},
|
||||
} : undefined,
|
||||
{
|
||||
slide: {
|
||||
selector: 'dependents',
|
||||
params: {
|
||||
action,
|
||||
verb: 'updating',
|
||||
title,
|
||||
fetchBreakages: () => this.embassyApi.dryUpdatePackage({ id, version }).then(breakages => breakages),
|
||||
},
|
||||
},
|
||||
bottomBar: {
|
||||
cancel: {
|
||||
afterLoading: { text: 'Cancel' },
|
||||
},
|
||||
next: 'Update Anyway',
|
||||
},
|
||||
},
|
||||
{
|
||||
slide: {
|
||||
selector: 'complete',
|
||||
params: {
|
||||
action,
|
||||
verb: 'beginning update for',
|
||||
title,
|
||||
executeAction: () => this.embassyApi.installPackage({ id, 'version-spec': version ? `=${version}` : undefined }),
|
||||
},
|
||||
},
|
||||
bottomBar: {
|
||||
cancel: { whileLoading: { } },
|
||||
finish: 'Dismiss',
|
||||
},
|
||||
},
|
||||
]
|
||||
return { toolbar, slideDefinitions: slideDefinitions.filter(exists) }
|
||||
}
|
||||
|
||||
updateOS (values: {
|
||||
version: string
|
||||
releaseNotes: { [version: string]: string }
|
||||
headline: string
|
||||
}): InstallWizardComponent['params'] {
|
||||
const { version, releaseNotes, headline } = values
|
||||
|
||||
const action = 'update'
|
||||
const title = 'EmbassyOS'
|
||||
const toolbar: TopbarParams = { action, title, version }
|
||||
|
||||
const slideDefinitions: SlideDefinition[] = [
|
||||
{
|
||||
slide : {
|
||||
selector: 'notes',
|
||||
params: {
|
||||
notes: releaseNotes,
|
||||
title: 'Release Notes',
|
||||
titleColor: 'dark',
|
||||
headline,
|
||||
},
|
||||
},
|
||||
bottomBar: {
|
||||
cancel: {
|
||||
afterLoading: { text: 'Cancel' },
|
||||
},
|
||||
next: 'Begin Update',
|
||||
},
|
||||
},
|
||||
{
|
||||
slide: {
|
||||
selector: 'complete',
|
||||
params: {
|
||||
action,
|
||||
verb: 'beginning update for',
|
||||
title,
|
||||
executeAction: () => this.embassyApi.updateServer({ }),
|
||||
},
|
||||
},
|
||||
bottomBar: {
|
||||
cancel: { whileLoading: { }},
|
||||
finish: 'Dismiss',
|
||||
},
|
||||
},
|
||||
]
|
||||
return { toolbar, slideDefinitions: slideDefinitions.filter(exists) }
|
||||
}
|
||||
|
||||
downgrade (values: {
|
||||
id: string
|
||||
title: string
|
||||
version: string
|
||||
installAlert?: string
|
||||
}): InstallWizardComponent['params'] {
|
||||
const { id, title, version, installAlert } = values
|
||||
|
||||
const action = 'downgrade'
|
||||
const toolbar: TopbarParams = { action, title, version }
|
||||
|
||||
const slideDefinitions: SlideDefinition[] = [
|
||||
installAlert ? {
|
||||
slide: {
|
||||
selector: 'alert',
|
||||
params: {
|
||||
title: 'Warning',
|
||||
message: installAlert,
|
||||
titleColor: 'warning',
|
||||
},
|
||||
},
|
||||
bottomBar: {
|
||||
cancel: {
|
||||
afterLoading: { text: 'Cancel' },
|
||||
},
|
||||
next: 'Next',
|
||||
},
|
||||
} : undefined,
|
||||
{ slide: {
|
||||
selector: 'dependents',
|
||||
params: {
|
||||
action,
|
||||
verb: 'downgrading',
|
||||
title,
|
||||
fetchBreakages: () => this.embassyApi.dryUpdatePackage({ id, version }).then(breakages => breakages),
|
||||
},
|
||||
},
|
||||
bottomBar: {
|
||||
cancel: {
|
||||
whileLoading: { },
|
||||
afterLoading: { text: 'Cancel' },
|
||||
},
|
||||
next: 'Downgrade Anyway',
|
||||
},
|
||||
},
|
||||
{ slide: {
|
||||
selector: 'complete',
|
||||
params: {
|
||||
action,
|
||||
verb: 'beginning downgrade for',
|
||||
title,
|
||||
executeAction: () => this.embassyApi.installPackage({ id, 'version-spec': version ? `=${version}` : undefined }),
|
||||
},
|
||||
},
|
||||
bottomBar: {
|
||||
cancel: { whileLoading: { } },
|
||||
finish: 'Dismiss',
|
||||
},
|
||||
},
|
||||
]
|
||||
return { toolbar, slideDefinitions: slideDefinitions.filter(exists) }
|
||||
}
|
||||
|
||||
uninstall (values: {
|
||||
id: string
|
||||
title: string
|
||||
version: string
|
||||
uninstallAlert?: string
|
||||
}): InstallWizardComponent['params'] {
|
||||
const { id, title, version, uninstallAlert } = values
|
||||
|
||||
const action = 'uninstall'
|
||||
const toolbar: TopbarParams = { action, title, version }
|
||||
|
||||
const slideDefinitions: SlideDefinition[] = [
|
||||
{
|
||||
slide: {
|
||||
selector: 'alert',
|
||||
params: {
|
||||
title: 'Warning',
|
||||
message: uninstallAlert || defaultUninstallWarning(title),
|
||||
titleColor: 'warning',
|
||||
},
|
||||
},
|
||||
bottomBar: {
|
||||
cancel: {
|
||||
afterLoading: { text: 'Cancel' },
|
||||
},
|
||||
next: 'Continue' },
|
||||
},
|
||||
{
|
||||
slide: {
|
||||
selector: 'dependents',
|
||||
params: {
|
||||
action,
|
||||
verb: 'uninstalling',
|
||||
title,
|
||||
fetchBreakages: () => this.embassyApi.dryUninstallPackage({ id }).then(breakages => breakages),
|
||||
},
|
||||
},
|
||||
bottomBar: {
|
||||
cancel: {
|
||||
whileLoading: { },
|
||||
afterLoading: { text: 'Cancel' },
|
||||
},
|
||||
next: 'Uninstall' },
|
||||
},
|
||||
{
|
||||
slide: {
|
||||
selector: 'complete',
|
||||
params: {
|
||||
action,
|
||||
verb: 'uninstalling',
|
||||
title,
|
||||
executeAction: () => this.embassyApi.uninstallPackage({ id }),
|
||||
},
|
||||
},
|
||||
bottomBar: {
|
||||
finish: 'Dismiss',
|
||||
cancel: {
|
||||
whileLoading: { },
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
return { toolbar, slideDefinitions: slideDefinitions.filter(exists) }
|
||||
}
|
||||
|
||||
stop (values: {
|
||||
id: string
|
||||
title: string
|
||||
version: string
|
||||
}): InstallWizardComponent['params'] {
|
||||
const { title, version, id } = values
|
||||
|
||||
const action = 'stop'
|
||||
const toolbar: TopbarParams = { action, title, version }
|
||||
|
||||
const slideDefinitions: SlideDefinition[] = [
|
||||
{
|
||||
slide: {
|
||||
selector: 'dependents',
|
||||
params: {
|
||||
action,
|
||||
verb: 'stopping',
|
||||
title,
|
||||
fetchBreakages: () => this.embassyApi.dryStopPackage({ id }).then(breakages => breakages),
|
||||
},
|
||||
},
|
||||
bottomBar: {
|
||||
cancel: {
|
||||
whileLoading: { },
|
||||
afterLoading: { text: 'Cancel' },
|
||||
},
|
||||
next: 'Stop Service',
|
||||
},
|
||||
},
|
||||
{
|
||||
slide: {
|
||||
selector: 'complete',
|
||||
params: {
|
||||
action,
|
||||
verb: 'stopping',
|
||||
title,
|
||||
executeAction: () => this.embassyApi.stopPackage({ id }),
|
||||
},
|
||||
},
|
||||
bottomBar: {
|
||||
finish: 'Dismiss',
|
||||
cancel: {
|
||||
whileLoading: { },
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
return { toolbar, slideDefinitions }
|
||||
}
|
||||
|
||||
configure (values: {
|
||||
pkg: PackageDataEntry
|
||||
breakages: Breakages
|
||||
}): InstallWizardComponent['params'] {
|
||||
const { breakages, pkg } = values
|
||||
const { title, version } = pkg.manifest
|
||||
const action = 'configure'
|
||||
const toolbar: TopbarParams = { action, title, version }
|
||||
|
||||
const slideDefinitions: SlideDefinition[] = [
|
||||
{
|
||||
slide: {
|
||||
selector: 'dependents',
|
||||
params: {
|
||||
action,
|
||||
verb: 'saving config for',
|
||||
title, fetchBreakages: () => Promise.resolve(breakages),
|
||||
},
|
||||
},
|
||||
bottomBar: {
|
||||
cancel: {
|
||||
afterLoading: { text: 'Cancel' },
|
||||
},
|
||||
next: 'Save Config Anyway' },
|
||||
},
|
||||
]
|
||||
return { toolbar, slideDefinitions }
|
||||
}
|
||||
}
|
||||
|
||||
const defaultUninstallWarning = (serviceName: string) => `Uninstalling ${ serviceName } will result in the deletion of its data.`
|
||||
@@ -0,0 +1,7 @@
|
||||
export type WizardAction =
|
||||
'install'
|
||||
| 'update'
|
||||
| 'downgrade'
|
||||
| 'uninstall'
|
||||
| 'stop'
|
||||
| 'configure'
|
||||
16
frontend/projects/ui/src/app/components/logs/logs.module.ts
Normal file
16
frontend/projects/ui/src/app/components/logs/logs.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { LogsPage } from './logs.page'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [LogsPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
SharingModule,
|
||||
],
|
||||
exports: [LogsPage],
|
||||
})
|
||||
export class LogsPageModule { }
|
||||
39
frontend/projects/ui/src/app/components/logs/logs.page.html
Normal file
39
frontend/projects/ui/src/app/components/logs/logs.page.html
Normal file
@@ -0,0 +1,39 @@
|
||||
<ion-content
|
||||
[scrollEvents]="true"
|
||||
(ionScroll)="scrollEvent()"
|
||||
style="height: 100%;"
|
||||
class="ion-padding"
|
||||
>
|
||||
<ion-infinite-scroll id="scroller" *ngIf="!loading && needInfinite" position="top" threshold="0" (ionInfinite)="loadData($event)">
|
||||
<ion-infinite-scroll-content loadingSpinner="lines"></ion-infinite-scroll-content>
|
||||
</ion-infinite-scroll>
|
||||
|
||||
<text-spinner *ngIf="loading" text="Loading Logs"></text-spinner>
|
||||
|
||||
<div id="container">
|
||||
<div id="template" style="white-space: pre-line; font-family: monospace;"></div>
|
||||
</div>
|
||||
<div id="button-div" *ngIf="!loading" style="width: 100%; text-align: center;">
|
||||
<ion-button *ngIf="!loadingMore" (click)="loadMore()" strong color="dark">
|
||||
Load More
|
||||
<ion-icon slot="end" name="refresh"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-spinner *ngIf="loadingMore" name="lines" color="warning"></ion-spinner>
|
||||
</div>
|
||||
|
||||
<div
|
||||
[ngStyle]="{
|
||||
'position': 'fixed',
|
||||
'bottom': '50px',
|
||||
'right': isOnBottom ? '-52px' : '30px',
|
||||
'background-color': 'var(--ion-color-medium)',
|
||||
'border-radius': '100%',
|
||||
'transition': 'right 0.4s ease-out'
|
||||
}"
|
||||
>
|
||||
<ion-button style="width: 50px; height: 50px; --padding-start: 0px; --padding-end: 0px; --border-radius: 100%;" color="dark" (click)="scrollToBottom()" strong>
|
||||
<ion-icon name="chevron-down"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
|
||||
</ion-content>
|
||||
@@ -0,0 +1,3 @@
|
||||
#container {
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
112
frontend/projects/ui/src/app/components/logs/logs.page.ts
Normal file
112
frontend/projects/ui/src/app/components/logs/logs.page.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Component, Input, ViewChild } from '@angular/core'
|
||||
import { IonContent } from '@ionic/angular'
|
||||
import { ErrorToastService } from 'src/app/services/error-toast.service'
|
||||
import { RR } from 'src/app/services/api/api.types'
|
||||
var Convert = require('ansi-to-html')
|
||||
var convert = new Convert({
|
||||
bg: 'transparent',
|
||||
})
|
||||
|
||||
@Component({
|
||||
selector: 'logs',
|
||||
templateUrl: './logs.page.html',
|
||||
styleUrls: ['./logs.page.scss'],
|
||||
})
|
||||
export class LogsPage {
|
||||
@ViewChild(IonContent) private content: IonContent
|
||||
@Input() fetchLogs: (params: { before_flag?: boolean, limit?: number, cursor?: string }) => Promise<RR.LogsRes>
|
||||
loading = true
|
||||
loadingMore = false
|
||||
logs: string
|
||||
needInfinite = true
|
||||
startCursor: string
|
||||
endCursor: string
|
||||
limit = 200
|
||||
scrollToBottomButton = false
|
||||
isOnBottom = true
|
||||
|
||||
constructor (
|
||||
private readonly errToast: ErrorToastService,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
this.getLogs()
|
||||
}
|
||||
|
||||
async fetch (isBefore: boolean = true) {
|
||||
try {
|
||||
const cursor = isBefore ? this.startCursor : this.endCursor
|
||||
const logsRes = await this.fetchLogs({
|
||||
cursor,
|
||||
before_flag: !!cursor ? isBefore : undefined,
|
||||
limit: this.limit,
|
||||
})
|
||||
|
||||
if ((isBefore || this.startCursor) && logsRes['start-cursor']) {
|
||||
this.startCursor = logsRes['start-cursor']
|
||||
}
|
||||
|
||||
if ((!isBefore || !this.endCursor) && logsRes['end-cursor']) {
|
||||
this.endCursor = logsRes['end-cursor']
|
||||
}
|
||||
this.loading = false
|
||||
|
||||
return logsRes.entries
|
||||
} catch (e) {
|
||||
this.errToast.present(e)
|
||||
}
|
||||
}
|
||||
|
||||
async getLogs () {
|
||||
try {
|
||||
// get logs
|
||||
const logs = await this.fetch()
|
||||
if (!logs.length) return
|
||||
|
||||
const container = document.getElementById('container')
|
||||
const beforeContainerHeight = container.scrollHeight
|
||||
const newLogs = document.getElementById('template').cloneNode(true) as HTMLElement
|
||||
newLogs.innerHTML = logs.map(l => `${l.timestamp} ${convert.toHtml(l.message)}`).join('\n') + (logs.length ? '\n' : '')
|
||||
container.prepend(newLogs)
|
||||
const afterContainerHeight = container.scrollHeight
|
||||
|
||||
// scroll down
|
||||
scrollBy(0, afterContainerHeight - beforeContainerHeight)
|
||||
this.content.scrollToPoint(0, afterContainerHeight - beforeContainerHeight)
|
||||
|
||||
if (logs.length < this.limit) {
|
||||
this.needInfinite = false
|
||||
}
|
||||
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
async loadMore () {
|
||||
try {
|
||||
this.loadingMore = true
|
||||
const logs = await this.fetch(false)
|
||||
if (!logs.length) return this.loadingMore = false
|
||||
|
||||
const container = document.getElementById('container')
|
||||
const newLogs = document.getElementById('template').cloneNode(true) as HTMLElement
|
||||
newLogs.innerHTML = logs.map(l => `${l.timestamp} ${convert.toHtml(l.message)}`).join('\n') + (logs.length ? '\n' : '')
|
||||
container.append(newLogs)
|
||||
this.loadingMore = false
|
||||
this.scrollEvent()
|
||||
} catch (e) { }
|
||||
}
|
||||
|
||||
scrollEvent () {
|
||||
const buttonDiv = document.getElementById('button-div')
|
||||
this.isOnBottom = buttonDiv && buttonDiv.getBoundingClientRect().top < window.innerHeight
|
||||
}
|
||||
|
||||
scrollToBottom () {
|
||||
this.content.scrollToBottom(500)
|
||||
}
|
||||
|
||||
async loadData (e: any): Promise<void> {
|
||||
await this.getLogs()
|
||||
e.target.complete()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<qr-code [value]="text" size="400"></qr-code>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { QRComponent } from './qr.component'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { QrCodeModule } from 'ng-qrcode'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
QRComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
QrCodeModule,
|
||||
],
|
||||
exports: [QRComponent],
|
||||
})
|
||||
export class QRComponentModule { }
|
||||
10
frontend/projects/ui/src/app/components/qr/qr.component.ts
Normal file
10
frontend/projects/ui/src/app/components/qr/qr.component.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
|
||||
@Component({
|
||||
selector: 'qr',
|
||||
templateUrl: './qr.component.html',
|
||||
styleUrls: ['./qr.component.scss'],
|
||||
})
|
||||
export class QRComponent {
|
||||
@Input() text: string
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<ng-container *ngIf="groups">
|
||||
<ion-item-group>
|
||||
<ng-container *ngFor="let g of groupsArr">
|
||||
<ion-item-divider>
|
||||
<ion-skeleton-text animated style="width: 120px; height: 16px;"></ion-skeleton-text>
|
||||
</ion-item-divider>
|
||||
<ion-item *ngFor="let r of rowsArr">
|
||||
<ion-label>
|
||||
<ion-skeleton-text animated style="width: 200px; height: 14px;"></ion-skeleton-text>
|
||||
</ion-label>
|
||||
<ion-note slot="end">
|
||||
<ion-skeleton-text animated style="width: 80px; height: 14px;"></ion-skeleton-text>
|
||||
</ion-note>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
</ion-item-group>
|
||||
</ng-container>
|
||||
|
||||
<ng-container *ngIf="!groups">
|
||||
<ion-item-group>
|
||||
<ion-item *ngFor="let r of rowsArr">
|
||||
<ion-label>
|
||||
<ion-skeleton-text animated style="width: 200px; height: 14px;"></ion-skeleton-text>
|
||||
</ion-label>
|
||||
<ion-note slot="end">
|
||||
<ion-skeleton-text animated style="width: 80px; height: 14px;"></ion-skeleton-text>
|
||||
</ion-note>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { SkeletonListComponent } from './skeleton-list.component'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule } from '@angular/router'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
SkeletonListComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild([]),
|
||||
],
|
||||
exports: [SkeletonListComponent],
|
||||
})
|
||||
export class SkeletonListComponentModule { }
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
|
||||
@Component({
|
||||
selector: 'skeleton-list',
|
||||
templateUrl: './skeleton-list.component.html',
|
||||
styleUrls: ['./skeleton-list.component.scss'],
|
||||
})
|
||||
export class SkeletonListComponent {
|
||||
@Input() groups: string
|
||||
@Input() rows: string = '3'
|
||||
groupsArr: number[] = []
|
||||
rowsArr: number[] = []
|
||||
|
||||
ngOnInit () {
|
||||
if (this.groups) {
|
||||
this.groupsArr = Array(Number(this.groups)).fill(0).map((_, i) => i)
|
||||
}
|
||||
this.rowsArr = Array(Number(this.rows)).fill(0).map((_, i) => i)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
<p
|
||||
[style.color]="disconnected ? 'gray' : 'var(--ion-color-' + rendering.color + ')'"
|
||||
[style.font-size]="size"
|
||||
[style.font-style]="style"
|
||||
[style.font-weight]="weight"
|
||||
>
|
||||
<span *ngIf= "!installProgress">
|
||||
{{ disconnected ? 'Unknown' : rendering.display }}
|
||||
<span *ngIf="rendering.showDots" class="loading-dots"></span>
|
||||
<span *ngIf="rendering.display === PR[PS.Stopping].display && (sigtermTimeout | durationToSeconds) > 30">This may take a while.</span>
|
||||
</span>
|
||||
<span *ngIf="installProgress">
|
||||
<span *ngIf="installProgress < 99">
|
||||
Installing
|
||||
<span class="loading-dots"></span>{{ installProgress }}%
|
||||
</span>
|
||||
<span *ngIf="installProgress >= 99">
|
||||
Finalizing install. This could take a minute
|
||||
<span class="loading-dots"></span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
</p>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { StatusComponent } from './status.component'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
StatusComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
SharingModule,
|
||||
],
|
||||
exports: [StatusComponent],
|
||||
})
|
||||
export class StatusComponentModule { }
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { PrimaryRendering, PrimaryStatus, StatusRendering } from 'src/app/services/pkg-status-rendering.service'
|
||||
|
||||
@Component({
|
||||
selector: 'status',
|
||||
templateUrl: './status.component.html',
|
||||
styleUrls: ['./status.component.scss'],
|
||||
})
|
||||
export class StatusComponent {
|
||||
PS = PrimaryStatus
|
||||
PR = PrimaryRendering
|
||||
|
||||
@Input() rendering: StatusRendering
|
||||
@Input() size?: string
|
||||
@Input() style?: string = 'regular'
|
||||
@Input() weight?: string = 'normal'
|
||||
@Input() disconnected?: boolean = false
|
||||
@Input() installProgress?: number
|
||||
@Input() sigtermTimeout?: string
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
<ion-grid style="height: 100%;">
|
||||
<ion-row class="ion-align-items-center ion-text-center" style="height: 100%;">
|
||||
<ion-col>
|
||||
<ion-spinner name="lines" color="warning"></ion-spinner>
|
||||
<p>{{ text }}</p>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { TextSpinnerComponent } from './text-spinner.component'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule } from '@angular/router'
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
TextSpinnerComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild([]),
|
||||
],
|
||||
exports: [TextSpinnerComponent],
|
||||
})
|
||||
export class TextSpinnerComponentModule { }
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
|
||||
@Component({
|
||||
selector: 'text-spinner',
|
||||
templateUrl: './text-spinner.component.html',
|
||||
styleUrls: ['./text-spinner.component.scss'],
|
||||
})
|
||||
export class TextSpinnerComponent {
|
||||
@Input() text: string
|
||||
}
|
||||
38
frontend/projects/ui/src/app/guards/auth.guard.ts
Normal file
38
frontend/projects/ui/src/app/guards/auth.guard.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { CanActivate, Router, CanActivateChild } from '@angular/router'
|
||||
import { tap } from 'rxjs/operators'
|
||||
import { AuthState, AuthService } from '../services/auth.service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class AuthGuard implements CanActivate, CanActivateChild {
|
||||
authState: AuthState
|
||||
|
||||
constructor (
|
||||
private readonly authService: AuthService,
|
||||
private readonly router: Router,
|
||||
) {
|
||||
this.authService.watch$()
|
||||
.pipe(
|
||||
tap(auth => this.authState = auth),
|
||||
).subscribe()
|
||||
}
|
||||
|
||||
canActivate (): boolean {
|
||||
return this.runAuthCheck()
|
||||
}
|
||||
|
||||
canActivateChild (): boolean {
|
||||
return this.runAuthCheck()
|
||||
}
|
||||
|
||||
private runAuthCheck (): boolean {
|
||||
if (this.authState === AuthState.VERIFIED) {
|
||||
return true
|
||||
} else {
|
||||
this.router.navigate(['/login'], { replaceUrl: true })
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
31
frontend/projects/ui/src/app/guards/unauth.guard.ts
Normal file
31
frontend/projects/ui/src/app/guards/unauth.guard.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { CanActivate, Router } from '@angular/router'
|
||||
import { tap } from 'rxjs/operators'
|
||||
import { AuthService, AuthState } from '../services/auth.service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class UnauthGuard implements CanActivate {
|
||||
authState: AuthState
|
||||
|
||||
constructor (
|
||||
private readonly authService: AuthService,
|
||||
private readonly router: Router,
|
||||
) {
|
||||
this.authService.watch$()
|
||||
.pipe(
|
||||
tap(auth => this.authState = auth),
|
||||
).subscribe()
|
||||
}
|
||||
|
||||
canActivate (): boolean {
|
||||
if (this.authState === AuthState.VERIFIED) {
|
||||
this.router.navigateByUrl('')
|
||||
return false
|
||||
} else {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { ActionSuccessPage } from './action-success.page'
|
||||
import { QrCodeModule } from 'ng-qrcode'
|
||||
|
||||
@NgModule({
|
||||
declarations: [ActionSuccessPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
QrCodeModule,
|
||||
],
|
||||
exports: [ActionSuccessPage],
|
||||
})
|
||||
export class ActionSuccessPageModule { }
|
||||
@@ -0,0 +1,30 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-button (click)="dismiss()" class="enter-click">
|
||||
<ion-icon name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Execution Complete</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>{{ actionRes.message }}</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<div *ngIf="actionRes.value" class="ion-text-center" style="padding: 64px 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>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController, ToastController } from '@ionic/angular'
|
||||
import { ActionResponse } from 'src/app/services/api/api.types'
|
||||
import { copyToClipboard } from 'src/app/util/web.util'
|
||||
|
||||
@Component({
|
||||
selector: 'action-success',
|
||||
templateUrl: './action-success.page.html',
|
||||
styleUrls: ['./action-success.page.scss'],
|
||||
})
|
||||
export class ActionSuccessPage {
|
||||
@Input() actionRes: ActionResponse
|
||||
|
||||
constructor (
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly toastCtrl: ToastController,
|
||||
) { }
|
||||
|
||||
async copy (address: string) {
|
||||
let message = ''
|
||||
await copyToClipboard(address || '')
|
||||
.then(success => { message = success ? 'copied to clipboard!' : 'failed to copy'})
|
||||
|
||||
const toast = await this.toastCtrl.create({
|
||||
header: message,
|
||||
position: 'bottom',
|
||||
duration: 1000,
|
||||
})
|
||||
await toast.present()
|
||||
}
|
||||
|
||||
async dismiss () {
|
||||
return this.modalCtrl.dismiss()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { AppConfigPage } from './app-config.page'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
import { FormObjectComponentModule } from 'src/app/components/form-object/form-object.component.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppConfigPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
SharingModule,
|
||||
FormObjectComponentModule,
|
||||
ReactiveFormsModule,
|
||||
],
|
||||
exports: [AppConfigPage],
|
||||
})
|
||||
export class AppConfigPageModule { }
|
||||
@@ -0,0 +1,91 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Config</ion-title>
|
||||
<ion-buttons *ngIf="!loadingText && !loadingError && hasConfig" slot="end" class="ion-padding-end">
|
||||
<ion-button fill="clear" (click)="resetDefaults()">
|
||||
<ion-icon slot="start" name="refresh"></ion-icon>
|
||||
Reset Defaults
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
|
||||
<!-- loading -->
|
||||
<text-spinner *ngIf="loadingText; else loaded" [text]="loadingText"></text-spinner>
|
||||
|
||||
<!-- not loading -->
|
||||
<ng-template #loaded>
|
||||
|
||||
<ion-item *ngIf="loadingError; else noError">
|
||||
<ion-label>
|
||||
<ion-text color="danger">
|
||||
{{ loadingError }}
|
||||
</ion-text>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ng-template #noError>
|
||||
<ion-item *ngIf="hasConfig && !pkg.installed.status.configured && !configForm.dirty">
|
||||
<ion-label>
|
||||
<ion-text color="success">To use the default config for {{ pkg.manifest.title }}, click "Save" below.</ion-text>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- auto-config -->
|
||||
<ion-item lines="none" *ngIf="dependentInfo" class="rec-item" style="margin-bottom: 48px;">
|
||||
<ion-label>
|
||||
<h2 style="display: flex; align-items: center;">
|
||||
<img style="width: 18px; margin: 4px;" [src]="pkg['static-files'].icon" [alt]="pkg.manifest.title"/>
|
||||
<ion-text style="margin: 5px; font-family: 'Montserrat'; font-size: 18px;">{{ pkg.manifest.title }}</ion-text>
|
||||
</h2>
|
||||
<p>
|
||||
<ion-text color="dark">
|
||||
The following modifications have been made to {{ pkg.manifest.title }} to satisfy {{ dependentInfo.title }}:
|
||||
<ul>
|
||||
<li *ngFor="let d of diff" [innerHtml]="d"></li>
|
||||
</ul>
|
||||
To accept these modifications, click "Save".
|
||||
</ion-text>
|
||||
</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- no config -->
|
||||
<ion-item *ngIf="!hasConfig">
|
||||
<ion-label>
|
||||
<p>No config options for {{ pkg.manifest.title }} {{ pkg.manifest.version }}.</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- has config -->
|
||||
<form *ngIf="hasConfig" [formGroup]="configForm" novalidate>
|
||||
<form-object
|
||||
[objectSpec]="configSpec"
|
||||
[formGroup]="configForm"
|
||||
[current]="configForm.value"
|
||||
[showEdited]="true"
|
||||
></form-object>
|
||||
</form>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-buttons *ngIf="!loadingText && !loadingError" slot="end" class="ion-padding-end">
|
||||
<ion-button *ngIf="hasConfig" fill="outline" [disabled]="saving" (click)="save()" class="enter-click" [class.no-click]="saving">
|
||||
Save
|
||||
</ion-button>
|
||||
<ion-button *ngIf="!hasConfig" fill="outline" (click)="dismiss()" class="enter-click">
|
||||
Close
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
@@ -0,0 +1,8 @@
|
||||
.notifier-item {
|
||||
margin: 12px;
|
||||
margin-top: 0px;
|
||||
border-radius: 12px;
|
||||
// kills the lines
|
||||
--border-width: 0;
|
||||
--inner-border-width: 0;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
import { Component, Input, ViewChild } from '@angular/core'
|
||||
import { AlertController, ModalController, IonContent, LoadingController, IonicSafeString } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { DependentInfo, isEmptyObject, isObject } from 'src/app/util/misc.util'
|
||||
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
|
||||
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
|
||||
import { ConfigSpec } from 'src/app/pkg-config/config-types'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
|
||||
import { ErrorToastService, getErrorMessage } from 'src/app/services/error-toast.service'
|
||||
import { FormGroup } from '@angular/forms'
|
||||
import { convertValuesRecursive, FormService } from 'src/app/services/form.service'
|
||||
import { compare, Operation, getValueByPointer } from 'fast-json-patch'
|
||||
|
||||
@Component({
|
||||
selector: 'app-config',
|
||||
templateUrl: './app-config.page.html',
|
||||
styleUrls: ['./app-config.page.scss'],
|
||||
})
|
||||
export class AppConfigPage {
|
||||
@ViewChild(IonContent) content: IonContent
|
||||
@Input() pkgId: string
|
||||
@Input() dependentInfo?: DependentInfo
|
||||
diff: string[] // only if dependent info
|
||||
pkg: PackageDataEntry
|
||||
loadingText: string | undefined
|
||||
configSpec: ConfigSpec
|
||||
configForm: FormGroup
|
||||
original: object
|
||||
hasConfig = false
|
||||
saving = false
|
||||
loadingError: string | IonicSafeString
|
||||
|
||||
constructor (
|
||||
private readonly wizardBaker: WizardBaker,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly formService: FormService,
|
||||
private readonly patch: PatchDbService,
|
||||
) { }
|
||||
|
||||
async ngOnInit () {
|
||||
this.pkg = this.patch.getData()['package-data'][this.pkgId]
|
||||
this.hasConfig = !!this.pkg.manifest.config
|
||||
|
||||
if (!this.hasConfig) return
|
||||
|
||||
try {
|
||||
let oldConfig: object
|
||||
let newConfig: object
|
||||
let spec: ConfigSpec
|
||||
let patch: Operation[]
|
||||
if (this.dependentInfo) {
|
||||
this.loadingText = `Setting properties to accommodate ${this.dependentInfo.title}`
|
||||
const { 'old-config': oc, 'new-config': nc, spec: s } = await this.embassyApi.dryConfigureDependency({ 'dependency-id': this.pkgId, 'dependent-id': this.dependentInfo.id })
|
||||
oldConfig = oc
|
||||
newConfig = nc
|
||||
spec = s
|
||||
patch = compare(oldConfig, newConfig)
|
||||
} else {
|
||||
this.loadingText = 'Loading Config'
|
||||
const { config: c, spec: s } = await this.embassyApi.getPackageConfig({ id: this.pkgId })
|
||||
oldConfig = c
|
||||
spec = s
|
||||
}
|
||||
|
||||
this.original = oldConfig
|
||||
this.configSpec = spec
|
||||
this.configForm = this.formService.createForm(spec, newConfig || oldConfig)
|
||||
this.configForm.markAllAsTouched()
|
||||
|
||||
if (patch) {
|
||||
this.diff = this.getDiff(patch)
|
||||
this.markDirty(patch)
|
||||
}
|
||||
} catch (e) {
|
||||
this.loadingError = getErrorMessage(e)
|
||||
} finally {
|
||||
this.loadingText = undefined
|
||||
}
|
||||
}
|
||||
|
||||
ngAfterViewInit () {
|
||||
this.content.scrollToPoint(undefined, 1)
|
||||
}
|
||||
|
||||
resetDefaults () {
|
||||
this.configForm = this.formService.createForm(this.configSpec)
|
||||
const patch = compare(this.original, this.configForm.value)
|
||||
this.markDirty(patch)
|
||||
}
|
||||
|
||||
async dismiss () {
|
||||
if (this.configForm?.dirty) {
|
||||
await this.presentAlertUnsaved()
|
||||
} else {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
async save () {
|
||||
convertValuesRecursive(this.configSpec, this.configForm)
|
||||
|
||||
if (this.configForm.invalid) {
|
||||
document.getElementsByClassName('validation-error')[0].parentElement.parentElement.scrollIntoView({ behavior: 'smooth' })
|
||||
return
|
||||
}
|
||||
|
||||
const loader = await this.loadingCtrl.create({
|
||||
spinner: 'lines',
|
||||
message: `Saving config...`,
|
||||
cssClass: 'loader',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
this.saving = true
|
||||
|
||||
try {
|
||||
const config = this.configForm.value
|
||||
|
||||
const breakages = await this.embassyApi.drySetPackageConfig({
|
||||
id: this.pkgId,
|
||||
config,
|
||||
})
|
||||
|
||||
if (!isEmptyObject(breakages['length'])) {
|
||||
const { cancelled } = await wizardModal(
|
||||
this.modalCtrl,
|
||||
this.wizardBaker.configure({
|
||||
pkg: this.pkg,
|
||||
breakages,
|
||||
}),
|
||||
)
|
||||
if (cancelled) return
|
||||
}
|
||||
|
||||
await this.embassyApi.setPackageConfig({
|
||||
id: this.pkgId,
|
||||
config,
|
||||
})
|
||||
this.modalCtrl.dismiss()
|
||||
} catch (e) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
this.saving = false
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private getDiff (patch: Operation[]): string[] {
|
||||
return patch.map(op => {
|
||||
let message: string
|
||||
switch (op.op) {
|
||||
case 'add':
|
||||
message = `Added ${this.getNewValue(op.value)}`
|
||||
break
|
||||
case 'remove':
|
||||
message = `Removed ${this.getOldValue(op.path)}`
|
||||
break
|
||||
case 'replace':
|
||||
message = `Changed from ${this.getOldValue(op.path)} to ${this.getNewValue(op.value)}`
|
||||
break
|
||||
default:
|
||||
message = `Unknown operation`
|
||||
}
|
||||
|
||||
let displayPath: string
|
||||
|
||||
const arrPath = op.path.substring(1)
|
||||
.split('/')
|
||||
.map(node => {
|
||||
const num = Number(node)
|
||||
return isNaN(num) ? node : num
|
||||
})
|
||||
|
||||
if (typeof arrPath[arrPath.length - 1] === 'number') {
|
||||
arrPath.pop()
|
||||
}
|
||||
|
||||
displayPath = arrPath.join(' → ')
|
||||
|
||||
return `${displayPath}: ${message}`
|
||||
})
|
||||
}
|
||||
|
||||
private getOldValue (path: any): string {
|
||||
const val = getValueByPointer(this.original, path)
|
||||
if (['string', 'number', 'boolean'].includes(typeof val)) {
|
||||
return val
|
||||
} else if (isObject(val)) {
|
||||
return 'entry'
|
||||
} else {
|
||||
return 'list'
|
||||
}
|
||||
}
|
||||
|
||||
private getNewValue (val: any): string {
|
||||
if (['string', 'number', 'boolean'].includes(typeof val)) {
|
||||
return val
|
||||
} else if (isObject(val)) {
|
||||
return 'new entry'
|
||||
} else {
|
||||
return 'new list'
|
||||
}
|
||||
}
|
||||
|
||||
private markDirty (patch: Operation[]) {
|
||||
patch.forEach(op => {
|
||||
const arrPath = op.path.substring(1)
|
||||
.split('/')
|
||||
.map(node => {
|
||||
const num = Number(node)
|
||||
return isNaN(num) ? node : num
|
||||
})
|
||||
|
||||
if (op.op !== 'remove') this.configForm.get(arrPath).markAsDirty()
|
||||
|
||||
if (typeof arrPath[arrPath.length - 1] === 'number') {
|
||||
const prevPath = arrPath.slice(0, arrPath.length - 1)
|
||||
this.configForm.get(prevPath).markAsDirty()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async presentAlertUnsaved () {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Unsaved Changes',
|
||||
message: 'You have unsaved changes. Are you sure you want to leave?',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: `Leave`,
|
||||
handler: () => {
|
||||
this.modalCtrl.dismiss()
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { AppRecoverSelectPage } from './app-recover-select.page'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppRecoverSelectPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
FormsModule,
|
||||
],
|
||||
exports: [AppRecoverSelectPage],
|
||||
})
|
||||
export class AppRecoverSelectPageModule { }
|
||||
@@ -0,0 +1,42 @@
|
||||
<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>
|
||||
|
||||
<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 : 'short' }}</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 EmbassyOS.</ion-text>
|
||||
</p>
|
||||
</ion-label>
|
||||
<ion-checkbox slot="end" [(ngModel)]="option.checked" [disabled]="option.installed || option['newer-eos']" (ionChange)="handleChange()"></ion-checkbox>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="end" class="ion-padding-end">
|
||||
<ion-button [disabled]="!hasSelection" fill="outline" (click)="restore()" class="enter-click">
|
||||
Restore Selected
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
@@ -0,0 +1,84 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { LoadingController, ModalController, IonicSafeString } from '@ionic/angular'
|
||||
import { BackupInfo, PackageBackupInfo } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { Emver } from 'src/app/services/emver.service'
|
||||
import { getErrorMessage } from 'src/app/services/error-toast.service'
|
||||
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-recover-select',
|
||||
templateUrl: './app-recover-select.page.html',
|
||||
styleUrls: ['./app-recover-select.page.scss'],
|
||||
})
|
||||
export class AppRecoverSelectPage {
|
||||
@Input() id: string
|
||||
@Input() backupInfo: BackupInfo
|
||||
@Input() password: string
|
||||
@Input() oldPassword: string
|
||||
options: (PackageBackupInfo & {
|
||||
id: string
|
||||
checked: boolean
|
||||
installed: boolean
|
||||
'newer-eos': boolean
|
||||
})[]
|
||||
hasSelection = false
|
||||
error: string | IonicSafeString
|
||||
|
||||
constructor (
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly emver: Emver,
|
||||
private readonly patch: PatchDbService,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
this.options = Object.keys(this.backupInfo['package-backups']).map(id => {
|
||||
return {
|
||||
...this.backupInfo['package-backups'][id],
|
||||
id,
|
||||
checked: false,
|
||||
installed: !!this.patch.getData()['package-data'][id],
|
||||
'newer-eos': this.emver.compare(this.backupInfo['package-backups'][id]['os-version'], this.config.version) === 1,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
dismiss () {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
|
||||
handleChange () {
|
||||
this.hasSelection = this.options.some(o => o.checked)
|
||||
}
|
||||
|
||||
async restore (): Promise<void> {
|
||||
const ids = this.options
|
||||
.filter(option => !!option.checked)
|
||||
.map(option => option.id)
|
||||
|
||||
const loader = await this.loadingCtrl.create({
|
||||
spinner: 'lines',
|
||||
message: 'Initializing...',
|
||||
cssClass: 'loader',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.embassyApi.restorePackages({
|
||||
ids,
|
||||
'target-id': this.id,
|
||||
'old-password': this.oldPassword,
|
||||
password: this.password,
|
||||
})
|
||||
this.modalCtrl.dismiss(undefined, 'success')
|
||||
} catch (e) {
|
||||
this.error = getErrorMessage(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { BackupReportPage } from './backup-report.page'
|
||||
|
||||
@NgModule({
|
||||
declarations: [BackupReportPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
],
|
||||
exports: [BackupReportPage],
|
||||
})
|
||||
export class BackupReportPageModule { }
|
||||
@@ -0,0 +1,30 @@
|
||||
<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 : 'short' }}</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>
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { BackupReport } from 'src/app/services/api/api.types'
|
||||
|
||||
@Component({
|
||||
selector: 'backup-report',
|
||||
templateUrl: './backup-report.page.html',
|
||||
styleUrls: ['./backup-report.page.scss'],
|
||||
})
|
||||
export class BackupReportPage {
|
||||
@Input() report: BackupReport
|
||||
@Input() timestamp: string
|
||||
system: {
|
||||
result: string
|
||||
icon: 'remove' | 'remove-circle-outline' | 'checkmark'
|
||||
color: 'dark' | 'danger' | 'success'
|
||||
}
|
||||
|
||||
constructor (
|
||||
private readonly modalCtrl: ModalController,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
if (!this.report.server.attempted) {
|
||||
this.system = {
|
||||
result: 'Not Attempted',
|
||||
icon: 'remove',
|
||||
color: 'dark',
|
||||
}
|
||||
} else if (this.report.server.error) {
|
||||
this.system = {
|
||||
result: `Failed: ${this.report.server.error}`,
|
||||
icon: 'remove-circle-outline',
|
||||
color: 'danger',
|
||||
}
|
||||
} else {
|
||||
this.system = {
|
||||
result: 'Succeeded',
|
||||
icon: 'checkmark',
|
||||
color: 'success',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async dismiss () {
|
||||
return this.modalCtrl.dismiss(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { EnumListPage } from './enum-list.page'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
|
||||
@NgModule({
|
||||
declarations: [EnumListPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
FormsModule,
|
||||
],
|
||||
exports: [EnumListPage],
|
||||
})
|
||||
export class EnumListPageModule { }
|
||||
@@ -0,0 +1,36 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
<ion-title>
|
||||
{{ spec.name }}
|
||||
</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button slot="end" fill="clear" (click)="toggleSelectAll()">
|
||||
{{ selectAll ? 'All' : 'None' }}
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<ion-item-group>
|
||||
<ion-item *ngFor="let option of options | keyvalue : asIsOrder">
|
||||
<ion-label>{{ spec.spec['value-names'][option.key] }}</ion-label>
|
||||
<ion-checkbox slot="end" [(ngModel)]="option.value" (click)="toggleSelected(option.key)"></ion-checkbox>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="end" class="ion-padding-end">
|
||||
<ion-button fill="outline" (click)="save()" class="enter-click">
|
||||
Done
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { ValueSpecListOf } from '../../pkg-config/config-types'
|
||||
|
||||
@Component({
|
||||
selector: 'enum-list',
|
||||
templateUrl: './enum-list.page.html',
|
||||
styleUrls: ['./enum-list.page.scss'],
|
||||
})
|
||||
export class EnumListPage {
|
||||
@Input() key: string
|
||||
@Input() spec: ValueSpecListOf<'enum'>
|
||||
@Input() current: string[]
|
||||
options: { [option: string]: boolean } = { }
|
||||
selectAll = true
|
||||
|
||||
constructor (
|
||||
private readonly modalCtrl: ModalController,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
for (let val of this.spec.spec.values) {
|
||||
this.options[val] = this.current.includes(val)
|
||||
}
|
||||
}
|
||||
|
||||
dismiss () {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
|
||||
save () {
|
||||
this.modalCtrl.dismiss(Object.keys(this.options).filter(key => this.options[key]))
|
||||
}
|
||||
|
||||
toggleSelectAll () {
|
||||
Object.keys(this.options).forEach(k => this.options[k] = this.selectAll)
|
||||
this.selectAll = !this.selectAll
|
||||
}
|
||||
|
||||
toggleSelected (key: string) {
|
||||
this.options[key] = !this.options[key]
|
||||
}
|
||||
|
||||
asIsOrder () {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { GenericFormPage } from './generic-form.page'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { FormObjectComponentModule } from 'src/app/components/form-object/form-object.component.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [GenericFormPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
FormObjectComponentModule,
|
||||
],
|
||||
exports: [GenericFormPage],
|
||||
})
|
||||
export class GenericFormPageModule { }
|
||||
@@ -0,0 +1,30 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{ title }}</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<form [formGroup]="formGroup" (ngSubmit)="handleClick(submitBtn.handler)" novalidate>
|
||||
<form-object
|
||||
[objectSpec]="spec"
|
||||
[formGroup]="formGroup"
|
||||
></form-object>
|
||||
<button hidden type="submit"></button>
|
||||
</form>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar class="footer">
|
||||
<ion-buttons slot="end">
|
||||
<ion-button class="ion-padding-end" *ngFor="let button of buttons" (click)="handleClick(button.handler)">
|
||||
{{ button.text }}
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
@@ -0,0 +1,9 @@
|
||||
button:disabled,
|
||||
button[disabled]{
|
||||
border: 1px solid #999999;
|
||||
background-color: #cccccc;
|
||||
color: #666666;
|
||||
}
|
||||
button {
|
||||
color: var(--ion-color-primary);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { FormGroup } from '@angular/forms'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { convertValuesRecursive, FormService } from 'src/app/services/form.service'
|
||||
import { ConfigSpec } from 'src/app/pkg-config/config-types'
|
||||
|
||||
export interface ActionButton {
|
||||
text: string
|
||||
handler: (value: any) => Promise<boolean>
|
||||
isSubmit?: boolean
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'generic-form',
|
||||
templateUrl: './generic-form.page.html',
|
||||
styleUrls: ['./generic-form.page.scss'],
|
||||
})
|
||||
export class GenericFormPage {
|
||||
@Input() title: string
|
||||
@Input() spec: ConfigSpec
|
||||
@Input() buttons: ActionButton[]
|
||||
@Input() initialValue: object = { }
|
||||
submitBtn: ActionButton
|
||||
formGroup: FormGroup
|
||||
|
||||
constructor (
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly formService: FormService,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
this.formGroup = this.formService.createForm(this.spec, this.initialValue)
|
||||
this.submitBtn = this.buttons.find(btn => btn.isSubmit) || {
|
||||
text: '',
|
||||
handler: () => Promise.resolve(true),
|
||||
}
|
||||
}
|
||||
|
||||
async dismiss (): Promise<void> {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
|
||||
async handleClick (handler: ActionButton['handler']): Promise<void> {
|
||||
convertValuesRecursive(this.spec, this.formGroup)
|
||||
|
||||
if (this.formGroup.invalid) {
|
||||
this.formGroup.markAllAsTouched()
|
||||
document.getElementsByClassName('validation-error')[0].parentElement.parentElement.scrollIntoView({ behavior: 'smooth' })
|
||||
return
|
||||
}
|
||||
|
||||
// @TODO make this more like generic input component dismissal
|
||||
const success = await handler(this.formGroup.value)
|
||||
if (success !== false) this.modalCtrl.dismiss()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<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="dark">
|
||||
<ion-input #mainInput [type]="options.useMask && !unmasked ? 'password' : 'text'" [(ngModel)]="value" name="value" [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]="unmasked ? '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.nullable">
|
||||
{{ options.buttonText }}
|
||||
</ion-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,20 @@
|
||||
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 { SharingModule } from 'src/app/modules/sharing.module'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
|
||||
@NgModule({
|
||||
declarations: [GenericInputComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
FormsModule,
|
||||
RouterModule.forChild([]),
|
||||
SharingModule,
|
||||
],
|
||||
exports: [GenericInputComponent],
|
||||
})
|
||||
export class GenericInputComponentModule { }
|
||||
@@ -0,0 +1,76 @@
|
||||
import { Component, Input, ViewChild } from '@angular/core'
|
||||
import { ModalController, IonicSafeString, IonInput } from '@ionic/angular'
|
||||
import { getErrorMessage } from 'src/app/services/error-toast.service'
|
||||
|
||||
@Component({
|
||||
selector: 'generic-input',
|
||||
templateUrl: './generic-input.component.html',
|
||||
styleUrls: ['./generic-input.component.scss'],
|
||||
})
|
||||
export class GenericInputComponent {
|
||||
@ViewChild('mainInput') elem: IonInput
|
||||
@Input() options: GenericInputOptions
|
||||
value: string
|
||||
unmasked = false
|
||||
error: string | IonicSafeString
|
||||
|
||||
constructor (
|
||||
private readonly modalCtrl: ModalController,
|
||||
) { }
|
||||
|
||||
ngOnInit () {
|
||||
const defaultOptions: Partial<GenericInputOptions> = {
|
||||
buttonText: 'Submit',
|
||||
placeholder: 'Enter value',
|
||||
nullable: false,
|
||||
useMask: false,
|
||||
initialValue: '',
|
||||
}
|
||||
this.options = {
|
||||
...defaultOptions,
|
||||
...this.options,
|
||||
}
|
||||
|
||||
this.value = this.options.initialValue
|
||||
}
|
||||
|
||||
ngAfterViewInit () {
|
||||
setTimeout(() => this.elem.setFocus(), 400)
|
||||
}
|
||||
|
||||
toggleMask () {
|
||||
this.unmasked = !this.unmasked
|
||||
}
|
||||
|
||||
cancel () {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
|
||||
async submit () {
|
||||
const value = this.value.trim()
|
||||
|
||||
if (!value && !this.options.nullable) return
|
||||
|
||||
try {
|
||||
await this.options.submitFn(value)
|
||||
this.modalCtrl.dismiss(undefined, 'success')
|
||||
} catch (e) {
|
||||
this.error = getErrorMessage(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface GenericInputOptions {
|
||||
// required
|
||||
title: string
|
||||
message: string
|
||||
label: string
|
||||
submitFn: (value: string) => Promise<any>
|
||||
// optional
|
||||
warning?: string
|
||||
buttonText?: string
|
||||
placeholder?: string
|
||||
nullable?: boolean
|
||||
useMask?: boolean
|
||||
initialValue?: string
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { MarkdownPage } from './markdown.page'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [MarkdownPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
SharingModule,
|
||||
],
|
||||
exports: [MarkdownPage],
|
||||
})
|
||||
export class MarkdownPageModule { }
|
||||
@@ -0,0 +1,29 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>{{ title | titlecase }}</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>
|
||||
<text-spinner *ngIf="loading; else loaded" [text]="'Loading ' + title | titlecase"></text-spinner>
|
||||
|
||||
<ng-template #loaded>
|
||||
|
||||
<ion-item *ngIf="loadingError; else noError">
|
||||
<ion-label>
|
||||
<ion-text color="danger">
|
||||
{{ loadingError }}
|
||||
</ion-text>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ng-template #noError>
|
||||
<div class="content-padding" [innerHTML]="content | markdown"></div>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,3 @@
|
||||
.content-padding {
|
||||
padding: 0 16px 16px 16px
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController, IonicSafeString } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { getErrorMessage } from 'src/app/services/error-toast.service'
|
||||
|
||||
@Component({
|
||||
selector: 'markdown',
|
||||
templateUrl: './markdown.page.html',
|
||||
styleUrls: ['./markdown.page.scss'],
|
||||
})
|
||||
export class MarkdownPage {
|
||||
@Input() contentUrl: string
|
||||
@Input() title: string
|
||||
content: string
|
||||
loading = true
|
||||
loadingError: string | IonicSafeString
|
||||
|
||||
constructor (
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly embassyApi: ApiService,
|
||||
) { }
|
||||
|
||||
async ngOnInit () {
|
||||
try {
|
||||
this.content = await this.embassyApi.getStatic(this.contentUrl)
|
||||
const links = document.links
|
||||
for (let i = 0, linksLength = links.length; i < linksLength; i++) {
|
||||
if (links[i].hostname != window.location.hostname) {
|
||||
links[i].target = '_blank'
|
||||
links[i].setAttribute('rel', 'noreferrer')
|
||||
links[i].className += ' externalLink'
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
this.loadingError = getErrorMessage(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
async dismiss () {
|
||||
return this.modalCtrl.dismiss(true)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { OSWelcomePage } from './os-welcome.page'
|
||||
import { SharingModule } from 'src/app/modules/sharing.module'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
|
||||
@NgModule({
|
||||
declarations: [OSWelcomePage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
FormsModule,
|
||||
SharingModule,
|
||||
],
|
||||
exports: [OSWelcomePage],
|
||||
})
|
||||
export class OSWelcomePageModule { }
|
||||
@@ -0,0 +1,20 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Welcome to {{ version }}!</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<div style="display: flex; flex-direction: column; justify-content: space-between; height: 100%">
|
||||
<h2>A Whole New Embassy</h2>
|
||||
<div class="main-content">
|
||||
<p>New features and more new features!</p>
|
||||
</div>
|
||||
|
||||
<div class="close-button">
|
||||
<ion-button fill="outline" color="dark" (click)="dismiss()">
|
||||
Begin
|
||||
</ion-button>
|
||||
</div>
|
||||
</div>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,12 @@
|
||||
.close-button {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
height: 100%;
|
||||
color: var(--ion-color-dark);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
|
||||
@Component({
|
||||
selector: 'os-welcome',
|
||||
templateUrl: './os-welcome.page.html',
|
||||
styleUrls: ['./os-welcome.page.scss'],
|
||||
})
|
||||
export class OSWelcomePage {
|
||||
@Input() version: string
|
||||
|
||||
constructor (
|
||||
private readonly modalCtrl: ModalController,
|
||||
) { }
|
||||
|
||||
async dismiss () {
|
||||
return this.modalCtrl.dismiss()
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user