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:
Aiden McClelland
2022-01-31 14:01:33 -07:00
committed by GitHub
parent 7e6c852ebd
commit 574539faec
504 changed files with 11569 additions and 78972 deletions

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

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

View File

@@ -0,0 +1,11 @@
.bold {
font-weight: bold;
}
.dim {
color: var(--ion-color-dark-shade);
}
ion-split-pane {
--side-max-width: 280px;
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">&nbsp;(New)</ion-text>
<ion-text color="warning" *ngIf="data.edited">&nbsp;(Edited)</ion-text>
<span *ngIf="(['string', 'number'] | includes : data.spec.type) && !$any(data.spec).nullable">&nbsp;*</span>
<span *ngIf="data.spec.type === 'list' && Range.from(data.spec.range).min">&nbsp;*</span>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
export type WizardAction =
'install'
| 'update'
| 'downgrade'
| 'uninstall'
| 'stop'
| 'configure'

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

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

View File

@@ -0,0 +1,3 @@
#container {
padding-bottom: 16px;
}

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

View File

@@ -0,0 +1 @@
<qr-code [value]="text" size="400"></qr-code>

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
.notifier-item {
margin: 12px;
margin-top: 0px;
border-radius: 12px;
// kills the lines
--border-width: 0;
--inner-border-width: 0;
}

View File

@@ -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(' &rarr; ')
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()
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
button:disabled,
button[disabled]{
border: 1px solid #999999;
background-color: #cccccc;
color: #666666;
}
button {
color: var(--ion-color-primary);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
.content-padding {
padding: 0 16px 16px 16px
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,70 @@
import { NgModule } from '@angular/core'
import {
EmverComparesPipe,
EmverSatisfiesPipe,
EmverDisplayPipe,
} from '../pipes/emver.pipe'
import { IncludesPipe } from '../pipes/includes.pipe'
import { TypeofPipe } from '../pipes/typeof.pipe'
import { MarkdownPipe } from '../pipes/markdown.pipe'
import {
TruncateCenterPipe,
TruncateEndPipe,
TruncateTailPipe,
} from '../pipes/truncate.pipe'
import { MaskPipe } from '../pipes/mask.pipe'
import { HasUiPipe, LaunchablePipe } from '../pipes/ui.pipe'
import { EmptyPipe } from '../pipes/empty.pipe'
import { NotificationColorPipe } from '../pipes/notification-color.pipe'
import { InstallState } from '../pipes/install-state.pipe'
import { TextSpinnerComponentModule } from '../components/text-spinner/text-spinner.component.module'
import { ConvertBytesPipe } from '../pipes/convert-bytes.pipe'
import { DurationToSecondsPipe } from '../pipes/unit-conversion.pipe'
import { InstallProgressPipe } from '../pipes/install-progress.pipe'
@NgModule({
declarations: [
EmverComparesPipe,
EmverSatisfiesPipe,
TypeofPipe,
IncludesPipe,
InstallProgressPipe,
InstallState,
MarkdownPipe,
TruncateCenterPipe,
TruncateEndPipe,
TruncateTailPipe,
MaskPipe,
EmverDisplayPipe,
HasUiPipe,
LaunchablePipe,
EmptyPipe,
NotificationColorPipe,
ConvertBytesPipe,
DurationToSecondsPipe,
],
imports: [TextSpinnerComponentModule],
exports: [
EmverComparesPipe,
EmverSatisfiesPipe,
TypeofPipe,
IncludesPipe,
MarkdownPipe,
TruncateEndPipe,
TruncateCenterPipe,
TruncateTailPipe,
MaskPipe,
EmverDisplayPipe,
HasUiPipe,
InstallProgressPipe,
InstallState,
LaunchablePipe,
EmptyPipe,
NotificationColorPipe,
ConvertBytesPipe,
DurationToSecondsPipe,
// components
TextSpinnerComponentModule,
],
})
export class SharingModule {}

Some files were not shown because too many files have changed in this diff Show More