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,7 @@
<ion-item button>
<ion-icon slot="start" [name]="action.icon" size="large"></ion-icon>
<ion-label>
<h1>{{ action.name }}</h1>
<p>{{ action.description }}</p>
</ion-label>
</ion-item>

View File

@@ -0,0 +1,33 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppActionsPage, AppActionsItemComponent } from './app-actions.page'
import { QRComponentModule } from 'src/app/components/qr/qr.component.module'
import { SharingModule } from 'src/app/modules/sharing.module'
import { GenericFormPageModule } from 'src/app/modals/generic-form/generic-form.module'
import { ActionSuccessPageModule } from 'src/app/modals/action-success/action-success.module'
const routes: Routes = [
{
path: '',
component: AppActionsPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
QRComponentModule,
SharingModule,
GenericFormPageModule,
ActionSuccessPageModule,
],
declarations: [
AppActionsPage,
AppActionsItemComponent,
],
})
export class AppActionsPageModule { }

View File

@@ -0,0 +1,36 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [defaultHref]="'/services/' + pkgId"></ion-back-button>
</ion-buttons>
<ion-title>Actions</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<ion-item-group *ngIf="pkg">
<!-- ** standard actions ** -->
<ion-item-divider>Standard Actions</ion-item-divider>
<app-actions-item
[action]="{
name: 'Uninstall',
description: 'This will uninstall the service from your Embassy and delete all data permanently.',
icon: 'trash-outline'
}"
(click)="uninstall()">
</app-actions-item>
<!-- ** specific actions ** -->
<ion-item-divider *ngIf="!(pkg.manifest.actions | empty)">Actions for {{ pkg.manifest.title }}</ion-item-divider>
<app-actions-item
*ngFor="let action of pkg.manifest.actions | keyvalue: asIsOrder"
[action]="{
name: action.value.name,
description: action.value.description,
icon: 'play-circle-outline'
}"
(click)="handleAction(action)">
</app-actions-item>
</ion-item-group>
</ion-content>

View File

@@ -0,0 +1,188 @@
import { Component, Input, ViewChild } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { AlertController, IonContent, LoadingController, ModalController, NavController } from '@ionic/angular'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { Action, PackageDataEntry, PackageMainStatus } from 'src/app/services/patch-db/data-model'
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
import { Subscription } from 'rxjs'
import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page'
import { ErrorToastService } from 'src/app/services/error-toast.service'
import { isEmptyObject } from 'src/app/util/misc.util'
import { ActionSuccessPage } from 'src/app/modals/action-success/action-success.page'
@Component({
selector: 'app-actions',
templateUrl: './app-actions.page.html',
styleUrls: ['./app-actions.page.scss'],
})
export class AppActionsPage {
@ViewChild(IonContent) content: IonContent
pkgId: string
pkg: PackageDataEntry
subs: Subscription[]
constructor (
private readonly route: ActivatedRoute,
private readonly embassyApi: ApiService,
private readonly modalCtrl: ModalController,
private readonly alertCtrl: AlertController,
private readonly errToast: ErrorToastService,
private readonly loadingCtrl: LoadingController,
private readonly wizardBaker: WizardBaker,
private readonly navCtrl: NavController,
private readonly patch: PatchDbService,
) { }
ngOnInit () {
this.pkgId = this.route.snapshot.paramMap.get('pkgId')
this.subs = [
this.patch.watch$('package-data', this.pkgId)
.subscribe(pkg => {
this.pkg = pkg
}),
]
}
ngAfterViewInit () {
this.content.scrollToPoint(undefined, 1)
}
ngOnDestroy () {
this.subs.forEach(sub => sub.unsubscribe())
}
async handleAction (action: { key: string, value: Action }) {
const status = this.pkg.installed.status
if ((action.value['allowed-statuses'] as PackageMainStatus[]).includes(status.main.status)) {
if (!isEmptyObject(action.value['input-spec'])) {
const modal = await this.modalCtrl.create({
component: GenericFormPage,
componentProps: {
title: action.value.name,
spec: action.value['input-spec'],
buttons: [
{
text: 'Execute',
handler: (value: any) => {
return this.executeAction(action.key, value)
},
isSubmit: true,
},
],
},
})
await modal.present()
} else {
const alert = await this.alertCtrl.create({
header: 'Confirm',
message: `Are you sure you want to execute action "${action.value.name}"? ${action.value.warning || ''}`,
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Execute',
handler: () => {
this.executeAction(action.key)
},
cssClass: 'enter-click',
},
],
})
await alert.present()
}
} else {
const statuses = [...action.value['allowed-statuses']]
const last = statuses.pop()
let statusesStr = statuses.join(', ')
let error = null
if (statuses.length) {
if (statuses.length > 1) { // oxford comma
statusesStr += ','
}
statusesStr += ` or ${last}`
} else if (last) {
statusesStr = `${last}`
} else {
error = `There is state for which this action may be run. This is a bug. Please file an issue with the service maintainer.`
}
const alert = await this.alertCtrl.create({
header: 'Forbidden',
message: error || `Action "${action.value.name}" can only be executed when service is ${statusesStr}`,
buttons: ['OK'],
cssClass: 'alert-error-message enter-click',
})
await alert.present()
}
}
async uninstall () {
const { id, title, version, alerts } = this.pkg.manifest
const data = await wizardModal(
this.modalCtrl,
this.wizardBaker.uninstall({
id,
title,
version,
uninstallAlert: alerts.uninstall,
}),
)
if (data.cancelled) return
return this.navCtrl.navigateRoot('/services')
}
private async executeAction (actionId: string, input?: object): Promise<boolean> {
const loader = await this.loadingCtrl.create({
spinner: 'lines',
message: 'Executing action...',
cssClass: 'loader',
})
await loader.present()
try {
const res = await this.embassyApi.executePackageAction({
id: this.pkgId,
'action-id': actionId,
input,
})
this.modalCtrl.dismiss()
const successModal = await this.modalCtrl.create({
component: ActionSuccessPage,
componentProps: {
actionRes: res,
},
})
setTimeout(() => successModal.present(), 400)
} catch (e) {
this.errToast.present(e)
return false
} finally {
loader.dismiss()
}
}
asIsOrder () {
return 0
}
}
interface LocalAction {
name: string
description: string
icon: string
}
@Component({
selector: 'app-actions-item',
templateUrl: './app-actions-item.component.html',
styleUrls: ['./app-actions.page.scss'],
})
export class AppActionsItemComponent {
@Input() action: LocalAction
}

View File

@@ -0,0 +1,54 @@
<ion-item>
<ion-icon slot="start" size="large" [name]="interface.def.ui ? 'desktop-outline' : 'terminal-outline'"></ion-icon>
<ion-label>
<h1>{{ interface.def.name }}</h1>
<h2>{{ interface.def.description }}</h2>
</ion-label>
</ion-item>
<div style="padding-left: 64px;">
<!-- has tor -->
<ion-item *ngIf="interface.addresses['tor-address'] as tor">
<ion-label>
<h2>Tor Address</h2>
<p>{{ tor }}</p>
</ion-label>
<ion-buttons slot="end">
<ion-button *ngIf="interface.def.ui" fill="clear" (click)="launch(tor)">
<ion-icon size="small" slot="icon-only" name="open-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="copy(tor)">
<ion-icon size="small" slot="icon-only" name="copy-outline"></ion-icon>
</ion-button>
</ion-buttons>
</ion-item>
<!-- no tor -->
<ion-item *ngIf="!interface.addresses['tor-address']">
<ion-label>
<h2>Tor Address</h2>
<p>Service does not use a Tor Address</p>
</ion-label>
</ion-item>
<!-- lan -->
<ion-item *ngIf="interface.addresses['lan-address'] as lan">
<ion-label>
<h2>LAN Address</h2>
<p>{{ lan }}</p>
</ion-label>
<ion-buttons slot="end">
<ion-button *ngIf="interface.def.ui" fill="clear" (click)="launch(lan)">
<ion-icon size="small" slot="icon-only" name="open-outline"></ion-icon>
</ion-button>
<ion-button fill="clear" (click)="copy(lan)">
<ion-icon size="small" slot="icon-only" name="copy-outline"></ion-icon>
</ion-button>
</ion-buttons>
</ion-item>
<!-- no lan -->
<ion-item *ngIf="!interface.addresses['lan-address']">
<ion-label>
<h2>LAN Address</h2>
<p>N/A</p>
</ion-label>
</ion-item>
</div>

View File

@@ -0,0 +1,27 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppInterfacesItemComponent, AppInterfacesPage } from './app-interfaces.page'
import { SharingModule } from 'src/app/modules/sharing.module'
const routes: Routes = [
{
path: '',
component: AppInterfacesPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
SharingModule,
],
declarations: [
AppInterfacesPage,
AppInterfacesItemComponent,
],
})
export class AppInterfacesPageModule { }

View File

@@ -0,0 +1,26 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [defaultHref]="'/services/' + pkgId"></ion-back-button>
</ion-buttons>
<ion-title>Interfaces</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<ion-item-group>
<!-- iff ui -->
<ng-container *ngIf="ui">
<ion-item-divider>User Interface</ion-item-divider>
<app-interfaces-item [interface]="ui"></app-interfaces-item>
</ng-container>
<!-- other interface -->
<ng-container *ngIf="other.length">
<ion-item-divider>Machine Interfaces</ion-item-divider>
<div *ngFor="let interface of other" style="margin-bottom: 30px;">
<app-interfaces-item [interface]="interface"></app-interfaces-item>
</div>
</ng-container>
</ion-item-group>
</ion-content>

View File

@@ -0,0 +1,100 @@
import { Component, Input, ViewChild } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { IonContent, ToastController } from '@ionic/angular'
import { getUiInterfaceKey } from 'src/app/services/config.service'
import { InstalledPackageDataEntry, InterfaceDef } from 'src/app/services/patch-db/data-model'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { copyToClipboard } from 'src/app/util/web.util'
interface LocalInterface {
def: InterfaceDef
addresses: InstalledPackageDataEntry['interface-addresses'][string]
}
@Component({
selector: 'app-interfaces',
templateUrl: './app-interfaces.page.html',
styleUrls: ['./app-interfaces.page.scss'],
})
export class AppInterfacesPage {
@ViewChild(IonContent) content: IonContent
ui: LocalInterface | null
other: LocalInterface[] = []
pkgId: string
constructor (
private readonly route: ActivatedRoute,
public readonly patch: PatchDbService,
) { }
ngOnInit () {
this.pkgId = this.route.snapshot.paramMap.get('pkgId')
const pkg = this.patch.getData()['package-data'][this.pkgId]
const interfaces = pkg.manifest.interfaces
const uiKey = getUiInterfaceKey(interfaces)
const addressesMap = pkg.installed['interface-addresses']
if (uiKey) {
const uiAddresses = addressesMap[uiKey]
this.ui = {
def: interfaces[uiKey],
addresses: {
'lan-address': uiAddresses['lan-address'] ? 'https://' + uiAddresses['lan-address'] : null,
'tor-address': uiAddresses['tor-address'] ? 'http://' + uiAddresses['tor-address'] : null,
},
}
}
this.other = Object.keys(interfaces)
.filter(key => key !== uiKey)
.map(key => {
const addresses = addressesMap[key]
return {
def: interfaces[key],
addresses: {
'lan-address': addresses['lan-address'] ? 'https://' + addresses['lan-address'] : null,
'tor-address': addresses['tor-address'] ? 'http://' + addresses['tor-address'] : null,
},
}
})
}
ngAfterViewInit () {
this.content.scrollToPoint(undefined, 1)
}
asIsOrder () {
return 0
}
}
@Component({
selector: 'app-interfaces-item',
templateUrl: './app-interfaces-item.component.html',
styleUrls: ['./app-interfaces.page.scss'],
})
export class AppInterfacesItemComponent {
@Input() interface: LocalInterface
constructor (
private readonly toastCtrl: ToastController,
) { }
launch (url: string): void {
window.open(url, '_blank', 'noreferrer')
}
async copy (address: string): Promise<void> {
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()
}
}

View File

@@ -0,0 +1,16 @@
<div class="welcome">
<h2>
Welcome to
<ion-text color="danger" class="embassy">Embassy</ion-text>
</h2>
<p class="ion-text-wrap">Get started by installing your first service.</p>
</div>
<ion-button
color="dark"
routerLink="/marketplace"
routerDirection="root"
class="marketplace"
>
<ion-icon slot="start" name="storefront-outline"></ion-icon>
Marketplace
</ion-button>

View File

@@ -0,0 +1,18 @@
:host {
display: block;
}
.welcome {
display: flex;
flex-direction: column;
justify-content: center;
height: 40vh;
}
.embassy {
font-family: "Montserrat", sans-serif;
}
.marketplace {
width: 50%;
}

View File

@@ -0,0 +1,9 @@
import { ChangeDetectionStrategy, Component } from "@angular/core";
@Component({
selector: "app-list-empty",
templateUrl: "app-list-empty.component.html",
styleUrls: ["app-list-empty.component.scss"],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppListEmptyComponent {}

View File

@@ -0,0 +1,10 @@
<ion-icon
*ngIf="pkg.error; else bulb"
class="warning-icon"
name="warning-outline"
size="small"
color="warning"
></ion-icon>
<ng-template #bulb>
<div class="bulb" [style.background-color]="color"></div>
</ng-template>

View File

@@ -0,0 +1,20 @@
.bulb {
position: absolute !important;
left: 9px !important;
top: 8px !important;
height: 14px;
width: 14px;
border-radius: 100%;
box-shadow: 0 0 6px 6px rgba(255, 213, 52, 0.1);
}
.warning-icon {
position: absolute !important;
left: 6px !important;
top: 6px !important;
font-size: 12px;
border-radius: 100%;
padding: 1px;
background-color: rgba(255, 213, 52, 0.1);
box-shadow: 0 0 4px 4px rgba(255, 213, 52, 0.1);
}

View File

@@ -0,0 +1,22 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { PkgInfo } from 'src/app/util/get-package-info'
@Component({
selector: 'app-list-icon',
templateUrl: 'app-list-icon.component.html',
styleUrls: ['app-list-icon.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppListIconComponent {
@Input()
pkg: PkgInfo
@Input()
connectionFailure = false
get color (): string {
return this.connectionFailure
? 'var(--ion-color-dark)'
: 'var(--ion-color-' + this.pkg.primaryRendering.color + ')'
}
}

View File

@@ -0,0 +1,32 @@
<ion-item button detail="false" [routerLink]="['/services', manifest.id]">
<app-list-icon
slot="start"
[pkg]="pkg"
[connectionFailure]="connectionFailure"
></app-list-icon>
<ion-thumbnail slot="start">
<img alt="" [src]="pkg.entry['static-files'].icon" />
</ion-thumbnail>
<ion-label>
<h2>{{ manifest.title }}</h2>
<p>{{ manifest.version | displayEmver }}</p>
<status
[disconnected]="connectionFailure"
[rendering]="pkg.primaryRendering"
[installProgress]="pkg.installProgress?.totalProgress"
weight="bold"
size="small"
[sigtermTimeout]="manifest.main['sigterm-timeout']"
></status>
</ion-label>
<ion-button
*ngIf="manifest.interfaces | hasUi"
slot="end"
fill="clear"
color="primary"
(click)="launchUi()"
[disabled]="!(pkg.entry.state | isLaunchable: status:manifest.interfaces)"
>
<ion-icon slot="icon-only" name="open-outline"></ion-icon>
</ion-button>
</ion-item>

View File

@@ -0,0 +1,40 @@
import {
ChangeDetectionStrategy,
Component,
Inject,
Input,
} from '@angular/core'
import {
PackageMainStatus,
PackageDataEntry,
Manifest,
} from 'src/app/services/patch-db/data-model'
import { PkgInfo } from 'src/app/util/get-package-info'
import { UiLauncherService } from 'src/app/services/ui-launcher.service'
@Component({
selector: 'app-list-pkg',
templateUrl: 'app-list-pkg.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppListPkgComponent {
@Input()
pkg: PkgInfo
@Input()
connectionFailure = false
constructor(private readonly launcherService: UiLauncherService) {}
get status(): PackageMainStatus {
return this.pkg.entry.installed?.status.main.status
}
get manifest(): Manifest {
return this.pkg.entry.manifest
}
launchUi(): void {
this.launcherService.launch(this.pkg.entry)
}
}

View File

@@ -0,0 +1,22 @@
<ion-item>
<ion-thumbnail slot="start">
<img alt="" [src]="rec.icon" />
</ion-thumbnail>
<ion-label>
<h2>{{ rec.title }}</h2>
<p>{{ rec.version | displayEmver }}</p>
</ion-label>
<ion-spinner *ngIf="loading$ | async; else actions"></ion-spinner>
<ng-template #actions>
<div slot="end">
<ion-button fill="clear" color="danger" (click)="deleteRecovered(rec)">
<ion-icon slot="icon-only" name="trash-outline"></ion-icon>
<!-- Remove -->
</ion-button>
<ion-button fill="clear" color="success" (click)="install$.next(rec)">
<ion-icon slot="icon-only" name="download-outline"></ion-icon>
<!-- Install -->
</ion-button>
</div>
</ng-template>
</ion-item>

View File

@@ -0,0 +1,98 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
Output,
} from '@angular/core'
import { AlertController } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ErrorToastService } from 'src/app/services/error-toast.service'
import { from, merge, OperatorFunction, pipe, Subject } from 'rxjs'
import { catchError, mapTo, startWith, switchMap, tap } from 'rxjs/operators'
import { RecoveredInfo } from 'src/app/util/parse-data-model'
@Component({
selector: 'app-list-rec',
templateUrl: 'app-list-rec.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppListRecComponent {
// Asynchronous actions initiators
readonly install$ = new Subject<RecoveredInfo>()
readonly delete$ = new Subject<RecoveredInfo>()
@Input()
rec: RecoveredInfo
@Output()
readonly deleted = new EventEmitter<void>()
// Installing package
readonly installing$ = this.install$.pipe(
switchMap(({ id, version }) =>
// Mapping each installation to API request
from(this.api.installPackage({ id, 'version-spec': `=${version}` })).pipe(
// Mapping operation to true/false loading indication
loading(this.errToast),
),
),
)
// Deleting package
readonly deleting$ = this.delete$.pipe(
switchMap(({ id }) =>
// Mapping each deletion to API request
from(this.api.deleteRecoveredPackage({ id })).pipe(
// Notifying parent component that package is removed from recovered items
tap(() => this.deleted.emit()),
// Mapping operation to true/false loading indication
loading(this.errToast)),
),
)
// Merging both true/false loading indicators to a single stream
readonly loading$ = merge(this.installing$, this.deleting$)
constructor (
private readonly api: ApiService,
private readonly errToast: ErrorToastService,
private readonly alertCtrl: AlertController,
) { }
async deleteRecovered (pkg: RecoveredInfo): Promise<void> {
const alert = await this.alertCtrl.create({
header: 'Delete Data',
message: `This action will permanently delete all data associated with ${pkg.title}.`,
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Delete',
handler: () => {
// Initiate deleting of 'pkg'
this.delete$.next(pkg)
},
cssClass: 'enter-click',
},
],
})
await alert.present()
}
}
// Custom RxJS operator to turn asynchronous operation into a true/false loading indicator
function loading (
errToast: ErrorToastService,
): OperatorFunction<unknown, boolean> {
return pipe(
// Show notification on error
catchError((e) => from(errToast.present(e))),
// Map any result to false to stop loading inidicator
mapTo(false),
// Start operation with true
startWith(true),
)
}

View File

@@ -0,0 +1,56 @@
<!-- header -->
<ion-item-divider>
{{ reordering ? "Reorder" : "Installed Services" }}
<ion-button *ngIf="pkgs.length > 1" slot="end" fill="clear" (click)="toggle()">
<ion-icon
slot="start"
[name]="reordering ? 'checkmark' : 'swap-vertical'"
></ion-icon>
{{ reordering ? "Done" : "Reorder" }}
</ion-button>
</ion-item-divider>
<!-- reordering -->
<ion-list *ngIf="reordering; else grid">
<ion-reorder-group disabled="false" (ionItemReorder)="reorder($any($event))">
<ion-reorder *ngFor="let item of pkgs">
<ion-item color="light" *ngIf="item | packageInfo | async as pkg" class="item">
<app-list-icon
slot="start"
[pkg]="pkg"
[connectionFailure]="connectionFailure$ | async"
></app-list-icon>
<ion-thumbnail slot="start">
<img alt="" [src]="pkg.entry['static-files'].icon" />
</ion-thumbnail>
<ion-label>
<h2>{{ pkg.entry.manifest.title }}</h2>
<p>{{ pkg.entry.manifest.version | displayEmver }}</p>
<status
[disconnected]="connectionFailure$ | async"
[rendering]="pkg.primaryRendering"
[installProgress]="pkg.installProgress?.totalProgress"
weight="bold"
size="small"
[sigtermTimeout]="pkg.entry.manifest.main['sigterm-timeout']"
></status>
</ion-label>
<ion-icon slot="end" name="reorder-three" color="dark"></ion-icon>
</ion-item>
</ion-reorder>
</ion-reorder-group>
</ion-list>
<!-- not reordering -->
<ng-template #grid>
<ion-grid>
<ion-row>
<ion-col *ngFor="let pkg of pkgs" sizeXs="12" sizeXl="6">
<app-list-pkg
[pkg]="pkg | packageInfo | async"
[connectionFailure]="connectionFailure$ | async"
></app-list-pkg>
</ion-col>
</ion-row>
</ion-grid>
</ng-template>

View File

@@ -0,0 +1,50 @@
import {
ChangeDetectionStrategy,
Component,
EventEmitter,
Input,
Output,
} from '@angular/core'
import { ItemReorderEventDetail } from '@ionic/core'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { map } from 'rxjs/operators'
import {
ConnectionFailure,
ConnectionService,
} from 'src/app/services/connection.service'
@Component({
selector: 'app-list-reorder',
templateUrl: 'app-list-reorder.component.html',
styleUrls: ['app-list-reorder.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppListReorderComponent {
@Input()
reordering = false
@Input()
pkgs: readonly PackageDataEntry[] = []
@Output()
readonly reorderingChange = new EventEmitter<boolean>()
@Output()
readonly pkgsChange = new EventEmitter<readonly PackageDataEntry[]>()
readonly connectionFailure$ = this.connectionService
.watchFailure$()
.pipe(map((failure) => failure !== ConnectionFailure.None))
constructor (private readonly connectionService: ConnectionService) { }
toggle () {
this.reordering = !this.reordering
this.reorderingChange.emit(this.reordering)
}
reorder ({ detail }: CustomEvent<ItemReorderEventDetail>): void {
this.pkgs = detail.complete([...this.pkgs])
this.pkgsChange.emit(this.pkgs)
}
}

View File

@@ -0,0 +1,42 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppListPage } from './app-list.page'
import { StatusComponentModule } from 'src/app/components/status/status.component.module'
import { SharingModule } from 'src/app/modules/sharing.module'
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
import { AppListIconComponent } from './app-list-icon/app-list-icon.component'
import { AppListEmptyComponent } from './app-list-empty/app-list-empty.component'
import { AppListPkgComponent } from './app-list-pkg/app-list-pkg.component'
import { AppListRecComponent } from './app-list-rec/app-list-rec.component'
import { AppListReorderComponent } from './app-list-reorder/app-list-reorder.component'
import { PackageInfoPipe } from './package-info.pipe'
const routes: Routes = [
{
path: '',
component: AppListPage,
},
]
@NgModule({
imports: [
CommonModule,
StatusComponentModule,
SharingModule,
IonicModule,
RouterModule.forChild(routes),
BadgeMenuComponentModule,
],
declarations: [
AppListPage,
AppListIconComponent,
AppListEmptyComponent,
AppListPkgComponent,
AppListRecComponent,
AppListReorderComponent,
PackageInfoPipe,
],
})
export class AppListPageModule { }

View File

@@ -0,0 +1,44 @@
<ion-header>
<ion-toolbar>
<ion-title>Services</ion-title>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<!-- loading -->
<text-spinner
*ngIf="!patch.loaded else data"
text="Connecting to Embassy"
></text-spinner>
<!-- not loading -->
<ng-template #data>
<app-list-empty
*ngIf="empty; else list"
class="ion-text-center ion-padding"
></app-list-empty>
<ng-template #list>
<app-list-reorder
*ngIf="pkgs.length"
[(pkgs)]="pkgs"
[reordering]="reordering"
(reorderingChange)="onReordering($event)"
></app-list-reorder>
<ng-container *ngIf="recoveredPkgs.length && !reordering">
<ion-item-group>
<ion-item-divider>Recovered Services</ion-item-divider>
<app-list-rec
*ngFor="let rec of recoveredPkgs"
[rec]="rec"
(deleted)="deleteRecovered(rec)"
></app-list-rec>
</ion-item-group>
</ng-container>
</ng-template>
</ng-template>
</ion-content>

View File

@@ -0,0 +1,3 @@
ion-item-divider {
margin-bottom: 16px;
}

View File

@@ -0,0 +1,95 @@
import { Component } from '@angular/core'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { Observable } from 'rxjs'
import { filter, map, switchMapTo, take, takeUntil, tap } from 'rxjs/operators'
import { isEmptyObject, exists } from 'src/app/util/misc.util'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { parseDataModel, RecoveredInfo } from 'src/app/util/parse-data-model'
import { DestroyService } from 'src/app/services/destroy.service'
@Component({
selector: 'app-list',
templateUrl: './app-list.page.html',
styleUrls: ['./app-list.page.scss'],
providers: [DestroyService],
})
export class AppListPage {
pkgs: readonly PackageDataEntry[] = []
recoveredPkgs: readonly RecoveredInfo[] = []
order: readonly string[] = []
reordering = false
constructor (
private readonly api: ApiService,
private readonly destroy$: DestroyService,
public readonly patch: PatchDbService,
) { }
get empty (): boolean {
return !this.pkgs.length && isEmptyObject(this.recoveredPkgs)
}
ngOnInit () {
this.patch
.watch$()
.pipe(
filter((data) => exists(data) && !isEmptyObject(data)),
take(1),
map(parseDataModel),
tap(({ order, pkgs, recoveredPkgs }) => {
this.pkgs = pkgs
this.recoveredPkgs = recoveredPkgs
this.order = order
// set order in UI DB if there were unknown packages
if (order.length < pkgs.length) {
this.setOrder()
}
}),
switchMapTo(this.watchNewlyRecovered()),
takeUntil(this.destroy$),
)
.subscribe()
}
onReordering (reordering: boolean): void {
if (!reordering) {
this.setOrder()
}
this.reordering = reordering
}
deleteRecovered (rec: RecoveredInfo): void {
this.recoveredPkgs = this.recoveredPkgs.filter((item) => item !== rec)
}
private watchNewlyRecovered (): Observable<unknown> {
return this.patch.watch$('package-data').pipe(
filter((pkgs) => !!pkgs && Object.keys(pkgs).length !== this.pkgs.length),
tap((pkgs) => {
const ids = Object.keys(pkgs)
const newIds = ids.filter(
(id) => !this.pkgs.find((pkg) => pkg.manifest.id === id),
)
// remove uninstalled
const filtered = this.pkgs.filter((pkg) =>
ids.includes(pkg.manifest.id),
)
// add new entry to beginning of array
const added = newIds.map((id) => pkgs[id])
this.pkgs = [...added, ...filtered]
this.recoveredPkgs = this.recoveredPkgs.filter((rec) => !pkgs[rec.id])
}),
)
}
private setOrder (): void {
this.order = this.pkgs.map((pkg) => pkg.manifest.id)
this.api.setDbValue({ pointer: '/pkg-order', value: this.order })
}
}

View File

@@ -0,0 +1,21 @@
import { Pipe, PipeTransform } from "@angular/core";
import { Observable } from "rxjs";
import { filter, map, startWith } from "rxjs/operators";
import { PackageDataEntry } from "../../../services/patch-db/data-model";
import { getPackageInfo, PkgInfo } from "../../../util/get-package-info";
import { PatchDbService } from "../../../services/patch-db/patch-db.service";
@Pipe({
name: "packageInfo",
})
export class PackageInfoPipe implements PipeTransform {
constructor(private readonly patch: PatchDbService) {}
transform(pkg: PackageDataEntry): Observable<PkgInfo> {
return this.patch.watch$("package-data", pkg.manifest.id).pipe(
filter((v) => !!v),
map(getPackageInfo),
startWith(getPackageInfo(pkg))
);
}
}

View File

@@ -0,0 +1,26 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppLogsPage } from './app-logs.page'
import { SharingModule } from 'src/app/modules/sharing.module'
import { LogsPageModule } from 'src/app/components/logs/logs.module'
const routes: Routes = [
{
path: '',
component: AppLogsPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
SharingModule,
LogsPageModule,
],
declarations: [AppLogsPage],
})
export class AppLogsPageModule { }

View File

@@ -0,0 +1,12 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [defaultHref]="'/services/' + pkgId"></ion-back-button>
</ion-buttons>
<ion-title>Logs</ion-title>
</ion-toolbar>
</ion-header>
<div style="height: 100%">
<logs [fetchLogs]="fetchFetchLogs()"></logs>
</div>

View File

@@ -0,0 +1,35 @@
import { Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Component({
selector: 'app-logs',
templateUrl: './app-logs.page.html',
styleUrls: ['./app-logs.page.scss'],
})
export class AppLogsPage {
pkgId: string
loading = true
needInfinite = true
before: string
constructor (
private readonly route: ActivatedRoute,
private readonly embassyApi: ApiService,
) { }
ngOnInit () {
this.pkgId = this.route.snapshot.paramMap.get('pkgId')
}
fetchFetchLogs () {
return async (params: { before_flag?: boolean, limit?: number, cursor?: string }) => {
return this.embassyApi.getPackageLogs({
id: this.pkgId,
before_flag: params.before_flag,
cursor: params.cursor,
limit: params.limit,
})
}
}
}

View File

@@ -0,0 +1,26 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppMetricsPage } from './app-metrics.page'
import { SharingModule } from 'src/app/modules/sharing.module'
import { SkeletonListComponentModule } from 'src/app/components/skeleton-list/skeleton-list.component.module'
const routes: Routes = [
{
path: '',
component: AppMetricsPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
SharingModule,
SkeletonListComponentModule,
],
declarations: [AppMetricsPage],
})
export class AppMetricsPageModule { }

View File

@@ -0,0 +1,22 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [defaultHref]="'/services/' + pkgId"></ion-back-button>
</ion-buttons>
<ion-title>Monitor</ion-title>
<ion-title slot="end"><ion-spinner name="dots" class="fader"></ion-spinner></ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<skeleton-list *ngIf="loading" rows="3"></skeleton-list>
<ion-item-group *ngIf="!loading">
<ion-item *ngFor="let metric of metrics | keyvalue : asIsOrder">
<ion-label>{{ metric.key }}</ion-label>
<ion-note *ngIf="metric.value" slot="end" class="metric-note">
<ion-text style="color: white;">{{ metric.value.value }} {{ metric.value.unit }}</ion-text>
</ion-note>
</ion-item>
</ion-item-group>
</ion-content>

View File

@@ -0,0 +1,3 @@
.metric-note {
font-size: 16px;
}

View File

@@ -0,0 +1,72 @@
import { Component, ViewChild } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { IonContent } from '@ionic/angular'
import { Subscription } from 'rxjs'
import { Metric } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ErrorToastService } from 'src/app/services/error-toast.service'
import { MainStatus } from 'src/app/services/patch-db/data-model'
import { pauseFor } from 'src/app/util/misc.util'
@Component({
selector: 'app-metrics',
templateUrl: './app-metrics.page.html',
styleUrls: ['./app-metrics.page.scss'],
})
export class AppMetricsPage {
loading = true
pkgId: string
mainStatus: MainStatus
going = false
metrics: Metric
subs: Subscription[] = []
@ViewChild(IonContent) content: IonContent
constructor (
private readonly route: ActivatedRoute,
private readonly errToast: ErrorToastService,
private readonly embassyApi: ApiService,
) { }
ngOnInit () {
this.pkgId = this.route.snapshot.paramMap.get('pkgId')
this.startDaemon()
}
ngAfterViewInit () {
this.content.scrollToPoint(undefined, 1)
}
ngOnDestroy () {
this.stopDaemon()
}
async startDaemon (): Promise<void> {
this.going = true
while (this.going) {
const startTime = Date.now()
await this.getMetrics()
await pauseFor(Math.max(4000 - (Date.now() - startTime), 0))
}
}
stopDaemon () {
this.going = false
}
async getMetrics (): Promise<void> {
try {
this.metrics = await this.embassyApi.getPkgMetrics({ id: this.pkgId})
} catch (e) {
this.errToast.present(e)
this.stopDaemon()
} finally {
this.loading = false
}
}
asIsOrder (a: any, b: any) {
return 0
}
}

View File

@@ -0,0 +1,26 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppPropertiesPage } from './app-properties.page'
import { QRComponentModule } from 'src/app/components/qr/qr.component.module'
import { SharingModule } from 'src/app/modules/sharing.module'
const routes: Routes = [
{
path: '',
component: AppPropertiesPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
QRComponentModule,
SharingModule,
],
declarations: [AppPropertiesPage],
})
export class AppPropertiesPageModule { }

View File

@@ -0,0 +1,70 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [defaultHref]="'/services/' + pkgId"></ion-back-button>
</ion-buttons>
<ion-title>Properties</ion-title>
<ion-buttons *ngIf="!loading" slot="end">
<ion-button (click)="refresh()">
<ion-icon slot="start" name="refresh"></ion-icon>
Refresh
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<text-spinner *ngIf="loading; else loaded" text="Loading Properties"></text-spinner>
<ng-template #loaded>
<!-- not running -->
<ion-item *ngIf="!running" class="ion-margin-bottom">
<ion-label>
<p><ion-text color="warning">Service not running. Information on this page could be inaccurate.</ion-text></p>
</ion-label>
</ion-item>
<!-- no properties -->
<ion-item *ngIf="properties | empty">
<ion-label>
<p>No properties.</p>
</ion-label>
</ion-item>
<!-- properties -->
<ion-item-group *ngIf="!(properties | empty)">
<div *ngFor="let prop of node | keyvalue: asIsOrder">
<!-- object -->
<ion-item button detail="true" *ngIf="prop.value.type === 'object'" (click)="goToNested(prop.key)">
<ion-button *ngIf="prop.value.description" fill="clear" slot="start" (click)="presentDescription(prop, $event)">
<ion-icon slot="icon-only" name="help-circle-outline"></ion-icon>
</ion-button>
<ion-label>
<h2>{{ prop.key }}</h2>
</ion-label>
</ion-item>
<!-- not object -->
<ion-item *ngIf="prop.value.type === 'string'">
<ion-button *ngIf="prop.value.description" fill="clear" slot="start" (click)="presentDescription(prop, $event)">
<ion-icon slot="icon-only" name="help-circle-outline"></ion-icon>
</ion-button>
<ion-label>
<h2>{{ prop.key }}</h2>
<p>{{ prop.value.masked && !unmasked[prop.key] ? (prop.value.value | mask ) : prop.value.value }}</p>
</ion-label>
<div slot="end" *ngIf="prop.value.copyable || prop.value.qr">
<ion-button *ngIf="prop.value.masked" fill="clear" (click)="toggleMask(prop.key)">
<ion-icon slot="icon-only" [name]="unmasked[prop.key] ? 'eye-off-outline' : 'eye-outline'" [color]="unmasked[prop.key] ? 'danger' : 'dark'" size="small"></ion-icon>
</ion-button>
<ion-button *ngIf="prop.value.qr" fill="clear" (click)="showQR(prop.value.value)">
<ion-icon slot="icon-only" name="qr-code-outline" size="small"></ion-icon>
</ion-button>
<ion-button *ngIf="prop.value.copyable" fill="clear" (click)="copy(prop.value.value)">
<ion-icon slot="icon-only" name="copy-outline" size="small"></ion-icon>
</ion-button>
</div>
</ion-item>
</div>
</ion-item-group>
</ng-template>
</ion-content>

View File

@@ -0,0 +1,3 @@
.metric-note {
font-size: 16px;
}

View File

@@ -0,0 +1,133 @@
import { Component, ViewChild } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { Subscription } from 'rxjs'
import { copyToClipboard } from 'src/app/util/web.util'
import { AlertController, IonContent, ModalController, NavController, ToastController } from '@ionic/angular'
import { PackageProperties } from 'src/app/util/properties.util'
import { QRComponent } from 'src/app/components/qr/qr.component'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { PackageMainStatus } from 'src/app/services/patch-db/data-model'
import { ErrorToastService } from 'src/app/services/error-toast.service'
import { getValueByPointer } from 'fast-json-patch'
@Component({
selector: 'app-properties',
templateUrl: './app-properties.page.html',
styleUrls: ['./app-properties.page.scss'],
})
export class AppPropertiesPage {
loading = true
pkgId: string
pointer: string
properties: PackageProperties
node: PackageProperties
unmasked: { [key: string]: boolean } = { }
running = true
@ViewChild(IonContent) content: IonContent
subs: Subscription[] = []
constructor (
private readonly route: ActivatedRoute,
private readonly embassyApi: ApiService,
private readonly errToast: ErrorToastService,
private readonly alertCtrl: AlertController,
private readonly toastCtrl: ToastController,
private readonly modalCtrl: ModalController,
private readonly navCtrl: NavController,
private readonly patch: PatchDbService,
) { }
async ngOnInit () {
this.pkgId = this.route.snapshot.paramMap.get('pkgId')
await this.getProperties()
this.subs = [
this.route.queryParams
.subscribe(queryParams => {
if (queryParams['pointer'] === this.pointer) return
this.pointer = queryParams['pointer']
this.node = getValueByPointer(this.properties, this.pointer || '')
}),
this.patch.watch$('package-data', this.pkgId, 'installed', 'status', 'main', 'status')
.subscribe(status => {
this.running = status === PackageMainStatus.Running
}),
]
}
ngAfterViewInit () {
this.content.scrollToPoint(undefined, 1)
}
ngOnDestroy () {
this.subs.forEach(sub => sub.unsubscribe())
}
async refresh () {
await this.getProperties()
}
async presentDescription (property: { key: string, value: PackageProperties[''] }, e: Event) {
e.stopPropagation()
const alert = await this.alertCtrl.create({
header: property.key,
message: property.value.description,
})
await alert.present()
}
async goToNested (key: string): Promise<any> {
this.navCtrl.navigateForward(`/services/${this.pkgId}/properties`, {
queryParams: {
pointer: `${this.pointer || ''}/${key}/value`,
},
})
}
async copy (text: string): Promise<void> {
let message = ''
await copyToClipboard(text).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 showQR (text: string): Promise<void> {
const modal = await this.modalCtrl.create({
component: QRComponent,
componentProps: {
text,
},
cssClass: 'qr-modal',
})
await modal.present()
}
toggleMask (key: string) {
this.unmasked[key] = !this.unmasked[key]
}
private async getProperties (): Promise<void> {
this.loading = true
try {
this.properties = await this.embassyApi.getPackageProperties({ id: this.pkgId })
this.node = getValueByPointer(this.properties, this.pointer || '')
} catch (e) {
this.errToast.present(e)
} finally {
this.loading = false
}
}
asIsOrder (a: any, b: any) {
return 0
}
}

View File

@@ -0,0 +1,54 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppShowPage } from './app-show.page'
import { StatusComponentModule } from 'src/app/components/status/status.component.module'
import { SharingModule } from 'src/app/modules/sharing.module'
import { InstallWizardComponentModule } from 'src/app/components/install-wizard/install-wizard.component.module'
import { AppConfigPageModule } from 'src/app/modals/app-config/app-config.module'
import { AppShowHeaderComponent } from './components/app-show-header/app-show-header.component'
import { AppShowProgressComponent } from './components/app-show-progress/app-show-progress.component'
import { AppShowStatusComponent } from './components/app-show-status/app-show-status.component'
import { AppShowDependenciesComponent } from './components/app-show-dependencies/app-show-dependencies.component'
import { AppShowMenuComponent } from './components/app-show-menu/app-show-menu.component'
import { AppShowHealthChecksComponent } from './components/app-show-health-checks/app-show-health-checks.component'
import { HealthColorPipe } from './pipes/health-color.pipe'
import { ToHealthChecksPipe } from './pipes/to-health-checks.pipe'
import { ToButtonsPipe } from './pipes/to-buttons.pipe'
import { ToDependenciesPipe } from './pipes/to-dependencies.pipe'
import { ToStatusPipe } from './pipes/to-status.pipe'
const routes: Routes = [
{
path: '',
component: AppShowPage,
},
]
@NgModule({
declarations: [
AppShowPage,
HealthColorPipe,
ToHealthChecksPipe,
ToButtonsPipe,
ToDependenciesPipe,
ToStatusPipe,
AppShowHeaderComponent,
AppShowProgressComponent,
AppShowStatusComponent,
AppShowDependenciesComponent,
AppShowMenuComponent,
AppShowHealthChecksComponent,
],
imports: [
CommonModule,
StatusComponentModule,
IonicModule,
RouterModule.forChild(routes),
InstallWizardComponentModule,
AppConfigPageModule,
SharingModule,
],
})
export class AppShowPageModule {}

View File

@@ -0,0 +1,40 @@
<ng-container *ngIf="pkg$ | async as pkg">
<app-show-header [pkg]="pkg"></app-show-header>
<ion-content *ngIf="pkg | toDependencies | async as dependencies">
<ion-item-group *ngIf="pkg | toStatus as status">
<!-- ** status ** -->
<app-show-status
[pkg]="pkg"
[connectionFailure]="connectionFailure$ | async"
[dependencies]="dependencies"
[status]="status"
></app-show-status>
<!-- ** installed && !backing-up ** -->
<ng-container *ngIf="isInstalled(pkg, status)">
<!-- ** health checks ** -->
<app-show-health-checks
*ngIf="isRunning(status)"
[pkg]="pkg"
[connectionFailure]="connectionFailure$ | async"
></app-show-health-checks>
<!-- ** dependencies ** -->
<app-show-dependencies
*ngIf="dependencies.length"
[dependencies]="dependencies"
></app-show-dependencies>
<!-- ** menu ** -->
<app-show-menu [buttons]="pkg | toButtons"></app-show-menu>
</ng-container>
</ion-item-group>
<!-- ** installing, updating, restoring ** -->
<ion-content *ngIf="showProgress(pkg)">
<app-show-progress
*ngIf="pkg | installState as installProgress"
[pkg]="pkg"
[installProgress]="installProgress"
></app-show-progress>
</ion-content>
</ion-content>
</ng-container>

View File

@@ -0,0 +1,73 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { NavController } from '@ionic/angular'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import {
PackageDataEntry,
PackageState,
} from 'src/app/services/patch-db/data-model'
import {
PackageStatus,
PrimaryStatus,
} from 'src/app/services/pkg-status-rendering.service'
import {
ConnectionFailure,
ConnectionService,
} from 'src/app/services/connection.service'
import { map, startWith } from 'rxjs/operators'
import { ActivatedRoute } from '@angular/router'
const STATES = [
PackageState.Installing,
PackageState.Updating,
PackageState.Restoring,
]
@Component({
selector: 'app-show',
templateUrl: './app-show.page.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShowPage {
private readonly pkgId = this.route.snapshot.paramMap.get('pkgId')
readonly pkg$ = this.patch.watch$('package-data', this.pkgId).pipe(
map(pkg => {
// if package disappears, navigate to list page
if (!pkg) {
this.navCtrl.navigateRoot('/services')
}
return { ...pkg }
}),
startWith(this.patch.getData()['package-data'][this.pkgId]),
)
readonly connectionFailure$ = this.connectionService
.watchFailure$()
.pipe(map(failure => failure !== ConnectionFailure.None))
constructor(
private readonly route: ActivatedRoute,
private readonly navCtrl: NavController,
private readonly patch: PatchDbService,
private readonly connectionService: ConnectionService,
) {}
isInstalled(
{ state }: PackageDataEntry,
{ primary }: PackageStatus,
): boolean {
return (
state === PackageState.Installed && primary !== PrimaryStatus.BackingUp
)
}
isRunning({ primary }: PackageStatus): boolean {
return primary === PrimaryStatus.Running
}
showProgress({ state }: PackageDataEntry): boolean {
return STATES.includes(state)
}
}

View File

@@ -0,0 +1,29 @@
<ion-item-divider>Dependencies</ion-item-divider>
<!-- dependencies are a subset of the pkg.manifest.dependencies that are currently required as determined by the service config -->
<ion-item button *ngFor="let dep of dependencies" (click)="dep.action()">
<ion-thumbnail slot="start">
<img [src]="dep.icon" alt="" />
</ion-thumbnail>
<ion-label>
<h2 class="inline">
<ion-icon
*ngIf="!!dep.errorText"
class="icon"
slot="start"
name="warning-outline"
color="warning"
></ion-icon>
{{ dep.title }}
</h2>
<p>{{ dep.version | displayEmver }}</p>
<p>
<ion-text [color]="dep.errorText ? 'warning' : 'success'">
{{ dep.errorText || 'satisfied' }}
</ion-text>
</p>
</ion-label>
<ion-button *ngIf="dep.actionText" slot="end" fill="clear">
{{ dep.actionText }}
<ion-icon slot="end" name="arrow-forward"></ion-icon>
</ion-button>
</ion-item>

View File

@@ -0,0 +1,7 @@
.inline {
font-family: 'Montserrat', sans-serif;
}
.icon {
padding-right: 4px;
}

View File

@@ -0,0 +1,13 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { DependencyInfo } from '../../pipes/to-dependencies.pipe'
@Component({
selector: 'app-show-dependencies',
templateUrl: './app-show-dependencies.component.html',
styleUrls: ['./app-show-dependencies.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShowDependenciesComponent {
@Input()
dependencies: DependencyInfo[] = []
}

View File

@@ -0,0 +1,18 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="services"></ion-back-button>
</ion-buttons>
<ion-item lines="none" color="light">
<ion-avatar slot="start">
<img [src]="pkg['static-files'].icon" alt="" />
</ion-avatar>
<ion-label>
<h1 class="name" [class.less-large]="pkg.manifest.title.length > 20">
{{ pkg.manifest.title }}
</h1>
<h2>{{ pkg.manifest.version | displayEmver }}</h2>
</ion-label>
</ion-item>
</ion-toolbar>
</ion-header>

View File

@@ -0,0 +1,7 @@
.name {
font-family: 'Montserrat', sans-serif;
}
.less-large {
font-size: 18px !important;
}

View File

@@ -0,0 +1,13 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
@Component({
selector: 'app-show-header',
templateUrl: './app-show-header.component.html',
styleUrls: ['./app-show-header.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShowHeaderComponent {
@Input()
pkg: PackageDataEntry
}

View File

@@ -0,0 +1,80 @@
<ng-container
*ngIf="pkg | toHealthChecks | async | keyvalue: asIsOrder as checks"
>
<ng-container *ngIf="checks.length">
<ion-item-divider>Health Checks</ion-item-divider>
<ng-container *ngIf="connectionFailure; else connected">
<ion-item *ngFor="let health of checks">
<ion-avatar slot="start">
<ion-skeleton-text class="avatar"></ion-skeleton-text>
</ion-avatar>
<ion-label>
<ion-skeleton-text class="label"></ion-skeleton-text>
<ion-skeleton-text class="description"></ion-skeleton-text>
</ion-label>
</ion-item>
</ng-container>
<ng-template #connected>
<ion-item
*ngFor="let health of checks"
button
(click)="presentAlertDescription(health.key)"
>
<ng-container *ngIf="health.value?.result as result; else noResult">
<ion-spinner
*ngIf="isLoading(result)"
class="icon-spinner"
color="primary"
slot="start"
></ion-spinner>
<ion-icon
*ngIf="result === HealthResult.Success"
slot="start"
name="checkmark"
color="success"
></ion-icon>
<ion-icon
*ngIf="result === HealthResult.Failure"
slot="start"
name="warning-outline"
color="warning"
></ion-icon>
<ion-icon
*ngIf="result === HealthResult.Disabled"
slot="start"
name="remove"
color="dark"
></ion-icon>
<ion-label>
<h2 class="bold">
{{ pkg.manifest['health-checks'][health.key].name }}
</h2>
<ion-text [color]="result | healthColor">
<p>
<span *ngIf="isReady(result)">{{ result | titlecase }}</span>
<span *ngIf="result === HealthResult.Starting">...</span>
<span *ngIf="result === HealthResult.Failure">
{{ $any(health.value).error }}
</span>
<span *ngIf="result === HealthResult.Loading">
{{ $any(health.value).message }}
</span>
</p>
</ion-text>
</ion-label>
</ng-container>
<ng-template #noResult>
<ion-spinner
class="icon-spinner"
color="dark"
slot="start"
></ion-spinner>
<ion-label>
<h2 class="bold">{{ pkg.manifest['health-checks'][health.key].name }}</h2>
<p>Awaiting result...</p>
</ion-label>
</ng-template>
</ion-item>
</ng-template>
</ng-container>
</ng-container>

View File

@@ -0,0 +1,24 @@
.icon-spinner {
height: 20px;
width: 20px;
}
.avatar {
width: 20px;
height: 20px;
border-radius: 0;
}
.label {
width: 100px;
margin-bottom: 10px;
}
.description {
width: 150px;
margin-bottom: 10px;
}
.bold {
font-weight: bold;
}

View File

@@ -0,0 +1,56 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { AlertController } from '@ionic/angular'
import {
HealthResult,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
@Component({
selector: 'app-show-health-checks',
templateUrl: './app-show-health-checks.component.html',
styleUrls: ['./app-show-health-checks.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShowHealthChecksComponent {
@Input()
pkg: PackageDataEntry
@Input()
connectionFailure = false
HealthResult = HealthResult
constructor(private readonly alertCtrl: AlertController) {}
isLoading(result: HealthResult): boolean {
return result === HealthResult.Starting || result === HealthResult.Loading
}
isReady(result: HealthResult): boolean {
return result !== HealthResult.Failure && result !== HealthResult.Loading
}
async presentAlertDescription(id: string) {
const health = this.pkg.manifest['health-checks'][id]
const alert = await this.alertCtrl.create({
header: 'Health Check',
subHeader: health.name,
message: health.description,
buttons: [
{
text: `OK`,
handler: () => {
alert.dismiss()
},
cssClass: 'enter-click',
},
],
})
await alert.present()
}
asIsOrder() {
return 0
}
}

View File

@@ -0,0 +1,13 @@
<ion-item-divider>Menu</ion-item-divider>
<ion-item
*ngFor="let button of buttons"
button
detail
(click)="button.action()"
>
<ion-icon slot="start" [name]="button.icon"></ion-icon>
<ion-label>
<h2>{{ button.title }}</h2>
<p *ngIf="button.description">{{ button.description }}</p>
</ion-label>
</ion-item>

View File

@@ -0,0 +1,12 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { Button } from '../../pipes/to-buttons.pipe'
@Component({
selector: 'app-show-menu',
templateUrl: './app-show-menu.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShowMenuComponent {
@Input()
buttons: Button[] = []
}

View File

@@ -0,0 +1,20 @@
<p>Downloading: {{ installProgress.downloadProgress }}%</p>
<ion-progress-bar
[color]="getColor('download-complete')"
[value]="installProgress.downloadProgress / 100"
[buffer]="!installProgress.downloadProgress ? 0 : 1"
></ion-progress-bar>
<p>Validating: {{ installProgress.validateProgress }}%</p>
<ion-progress-bar
[color]="getColor('validation-complete')"
[value]="installProgress.validateProgress / 100"
[buffer]="validationBuffer"
></ion-progress-bar>
<p>Unpacking: {{ installProgress.unpackProgress }}%</p>
<ion-progress-bar
[color]="getColor('unpack-complete')"
[value]="installProgress.unpackProgress / 100"
[buffer]="unpackingBuffer"
></ion-progress-bar>

View File

@@ -0,0 +1,4 @@
:host {
display: block;
padding: 16px;
}

View File

@@ -0,0 +1,38 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import {
InstallProgress,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { ProgressData } from 'src/app/util/package-loading-progress'
@Component({
selector: 'app-show-progress',
templateUrl: './app-show-progress.component.html',
styleUrls: ['./app-show-progress.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShowProgressComponent {
@Input()
pkg: PackageDataEntry
@Input()
installProgress: ProgressData
get unpackingBuffer(): number {
return this.installProgress.validateProgress === 100 &&
!this.installProgress.unpackProgress
? 0
: 1
}
get validationBuffer(): number {
return this.installProgress.downloadProgress === 100 &&
!this.installProgress.validateProgress
? 0
: 1
}
getColor(action: keyof InstallProgress): string {
return this.pkg['install-progress'][action] ? 'success' : 'secondary'
}
}

View File

@@ -0,0 +1,51 @@
<ion-item-divider>Status</ion-item-divider>
<ion-item>
<ion-label class="label">
<status
size="x-large"
weight="500"
[disconnected]="connectionFailure"
[installProgress]="(pkg | installState)?.totalProgress"
[rendering]="PR[status.primary]"
[sigtermTimeout]="pkg.manifest.main['sigterm-timeout']"
></status>
</ion-label>
<ng-container *ngIf="isInstalled">
<ion-button
*ngIf="interfaces | hasUi"
slot="end"
class="action-button"
[disabled]="!(pkg.state | isLaunchable: pkgStatus.main.status:interfaces)"
(click)="launchUi()"
>
Launch UI
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-button>
<ion-button
*ngIf="!pkgStatus.configured"
slot="end"
class="action-button"
(click)="presentModalConfig()"
>
Configure
</ion-button>
<ion-button
*ngIf="isRunning"
slot="end"
class="action-button"
color="danger"
(click)="stop()"
>
Stop
</ion-button>
<ion-button
*ngIf="isStopped"
slot="end"
class="action-button"
color="success"
(click)="tryStart()"
>
Start
</ion-button>
</ng-container>
</ion-item>

View File

@@ -0,0 +1,9 @@
.label {
overflow: visible;
}
.action-button {
margin: 10px;
min-height: 36px;
min-width: 120px;
}

View File

@@ -0,0 +1,183 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { UiLauncherService } from 'src/app/services/ui-launcher.service'
import {
InterfaceDef,
PackageDataEntry,
PackageState,
Status,
} from 'src/app/services/patch-db/data-model'
import {
PackageStatus,
PrimaryRendering,
PrimaryStatus,
} from 'src/app/services/pkg-status-rendering.service'
import { isEmptyObject } from 'src/app/util/misc.util'
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
import {
AlertController,
LoadingController,
ModalController,
} from '@ionic/angular'
import { ErrorToastService } from 'src/app/services/error-toast.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { ModalService } from 'src/app/services/modal.service'
import { DependencyInfo } from '../../pipes/to-dependencies.pipe'
@Component({
selector: 'app-show-status',
templateUrl: './app-show-status.component.html',
styleUrls: ['./app-show-status.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShowStatusComponent {
@Input()
pkg: PackageDataEntry
@Input()
connectionFailure = false
@Input()
status: PackageStatus
@Input()
dependencies: DependencyInfo[] = []
PR = PrimaryRendering
constructor(
private readonly alertCtrl: AlertController,
private readonly errToast: ErrorToastService,
private readonly loadingCtrl: LoadingController,
private readonly modalCtrl: ModalController,
private readonly embassyApi: ApiService,
private readonly wizardBaker: WizardBaker,
private readonly patch: PatchDbService,
private readonly launcherService: UiLauncherService,
private readonly modalService: ModalService,
) {}
get interfaces(): Record<string, InterfaceDef> {
return this.pkg.manifest.interfaces
}
get pkgStatus(): Status {
return this.pkg.installed.status
}
get isInstalled(): boolean {
return this.pkg.state === PackageState.Installed && !this.connectionFailure
}
get isRunning(): boolean {
return this.status.primary === PrimaryStatus.Running
}
get isStopped(): boolean {
return (
this.status.primary === PrimaryStatus.Stopped && this.pkgStatus.configured
)
}
launchUi(): void {
this.launcherService.launch(this.pkg)
}
async presentModalConfig(): Promise<void> {
return this.modalService.presentModalConfig({ pkgId: this.pkg.manifest.id })
}
async tryStart(): Promise<void> {
if (this.dependencies.some(d => !!d.errorText)) {
const depErrMsg = `${this.pkg.manifest.title} has unmet dependencies. It will not work as expected.`
const proceed = await this.presentAlertStart(depErrMsg)
if (!proceed) return
}
const alertMsg = this.pkg.manifest.alerts.start
if (!!alertMsg) {
const proceed = await this.presentAlertStart(alertMsg)
if (!proceed) return
}
this.start()
}
async stop(): Promise<void> {
const { id, title, version } = this.pkg.manifest
if (isEmptyObject(this.pkg.installed['current-dependents'])) {
const loader = await this.loadingCtrl.create({
message: `Stopping...`,
spinner: 'lines',
cssClass: 'loader',
})
await loader.present()
try {
await this.embassyApi.stopPackage({ id })
} catch (e) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
} else {
wizardModal(
this.modalCtrl,
this.wizardBaker.stop({
id,
title,
version,
}),
)
}
}
private async start(): Promise<void> {
const loader = await this.loadingCtrl.create({
message: `Starting...`,
spinner: 'lines',
cssClass: 'loader',
})
await loader.present()
try {
await this.embassyApi.startPackage({ id: this.pkg.manifest.id })
} catch (e) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
private async presentAlertStart(message: string): Promise<boolean> {
return new Promise(async resolve => {
const alert = await this.alertCtrl.create({
header: 'Warning',
message,
buttons: [
{
text: 'Cancel',
role: 'cancel',
handler: () => {
resolve(false)
},
},
{
text: 'Continue',
handler: () => {
resolve(true)
},
cssClass: 'enter-click',
},
],
})
await alert.present()
})
}
}

View File

@@ -0,0 +1,21 @@
import { Pipe, PipeTransform } from '@angular/core'
import { HealthResult } from 'src/app/services/patch-db/data-model'
@Pipe({
name: 'healthColor',
})
export class HealthColorPipe implements PipeTransform {
transform(val: HealthResult): string {
switch (val) {
case HealthResult.Success:
return 'success'
case HealthResult.Failure:
return 'warning'
case HealthResult.Disabled:
return 'dark'
case HealthResult.Starting:
case HealthResult.Loading:
return 'primary'
}
}
}

View File

@@ -0,0 +1,125 @@
import { Inject, Pipe, PipeTransform } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { DOCUMENT } from '@angular/common'
import { AlertController, ModalController, NavController } from '@ionic/angular'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { MarkdownPage } from 'src/app/modals/markdown/markdown.page'
import { ModalService } from 'src/app/services/modal.service'
export interface Button {
title: string
description: string
icon: string
action: Function
}
@Pipe({
name: 'toButtons',
})
export class ToButtonsPipe implements PipeTransform {
constructor(
@Inject(DOCUMENT) private readonly document: Document,
private readonly alertCtrl: AlertController,
private readonly route: ActivatedRoute,
private readonly navCtrl: NavController,
private readonly modalCtrl: ModalController,
private readonly modalService: ModalService,
) {}
transform(pkg: PackageDataEntry): Button[] {
const pkgTitle = pkg.manifest.title
return [
// instructions
{
action: () => this.presentModalInstructions(pkg),
title: 'Instructions',
description: `Understand how to use ${pkgTitle}`,
icon: 'list-outline',
},
// config
{
action: async () =>
this.modalService.presentModalConfig({ pkgId: pkg.manifest.id }),
title: 'Config',
description: `Customize ${pkgTitle}`,
icon: 'construct-outline',
},
// properties
{
action: () =>
this.navCtrl.navigateForward(['properties'], {
relativeTo: this.route,
}),
title: 'Properties',
description:
'Runtime information, credentials, and other values of interest',
icon: 'briefcase-outline',
},
// actions
{
action: () =>
this.navCtrl.navigateForward(['actions'], { relativeTo: this.route }),
title: 'Actions',
description: `Uninstall and other commands specific to ${pkgTitle}`,
icon: 'flash-outline',
},
// interfaces
{
action: () =>
this.navCtrl.navigateForward(['interfaces'], {
relativeTo: this.route,
}),
title: 'Interfaces',
description: 'User and machine access points',
icon: 'desktop-outline',
},
// logs
{
action: () =>
this.navCtrl.navigateForward(['logs'], { relativeTo: this.route }),
title: 'Logs',
description: 'Raw, unfiltered service logs',
icon: 'receipt-outline',
},
// view in marketplace
{
action: () => this.navCtrl.navigateForward([`marketplace/${pkg.manifest.id}`]),
title: 'Marketplace',
description: 'View service in marketplace',
icon: 'storefront-outline',
},
{
action: () => this.donate(pkg),
title: 'Donate',
description: `Support ${pkgTitle}`,
icon: 'logo-bitcoin',
},
]
}
private async presentModalInstructions(pkg: PackageDataEntry) {
const modal = await this.modalCtrl.create({
componentProps: {
title: 'Instructions',
contentUrl: pkg['static-files']['instructions'],
},
component: MarkdownPage,
})
await modal.present()
}
private async donate({ manifest }: PackageDataEntry): Promise<void> {
const url = manifest['donation-url']
if (url) {
this.document.defaultView.open(url, '_blank', 'noreferrer')
} else {
const alert = await this.alertCtrl.create({
header: 'Not Accepting Donations',
message: `The developers of ${manifest.title} have not provided a donation URL. Please contact them directly if you insist on giving them money.`,
})
await alert.present()
}
}
}

View File

@@ -0,0 +1,169 @@
import { Pipe, PipeTransform } from '@angular/core'
import { NavigationExtras } from '@angular/router'
import { NavController } from '@ionic/angular'
import { combineLatest, Observable } from 'rxjs'
import { filter, map, startWith } from 'rxjs/operators'
import { DependentInfo, exists } from 'src/app/util/misc.util'
import {
DependencyError,
DependencyErrorType,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { ModalService } from 'src/app/services/modal.service'
export interface DependencyInfo {
id: string
title: string
icon: string
version: string
errorText: string
actionText: string
action: () => any
}
@Pipe({
name: 'toDependencies',
})
export class ToDependenciesPipe implements PipeTransform {
constructor(
private readonly patch: PatchDbService,
private readonly navCtrl: NavController,
private readonly modalService: ModalService,
) {}
transform(pkg: PackageDataEntry): Observable<DependencyInfo[]> {
return combineLatest([
this.patch.watch$(
'package-data',
pkg.manifest.id,
'installed',
'current-dependencies',
),
this.patch.watch$(
'package-data',
pkg.manifest.id,
'installed',
'status',
'dependency-errors',
),
]).pipe(
filter(deps => deps.every(exists) && !!pkg.installed),
map(([currentDeps, depErrors]) =>
Object.keys(currentDeps)
.filter(id => !!pkg.manifest.dependencies[id])
.map(id => this.setDepValues(pkg, id, depErrors)),
),
startWith([]),
)
}
private setDepValues(
pkg: PackageDataEntry,
id: string,
errors: { [id: string]: DependencyError },
): DependencyInfo {
let errorText = ''
let actionText = 'View'
let action: () => any = () =>
this.navCtrl.navigateForward(`/services/${id}`)
const error = errors[id]
if (error) {
// health checks failed
if (
[
DependencyErrorType.InterfaceHealthChecksFailed,
DependencyErrorType.HealthChecksFailed,
].includes(error.type)
) {
errorText = 'Health check failed'
// not installed
} else if (error.type === DependencyErrorType.NotInstalled) {
errorText = 'Not installed'
actionText = 'Install'
action = () => this.fixDep(pkg, 'install', id)
// incorrect version
} else if (error.type === DependencyErrorType.IncorrectVersion) {
errorText = 'Incorrect version'
actionText = 'Update'
action = () => this.fixDep(pkg, 'update', id)
// not running
} else if (error.type === DependencyErrorType.NotRunning) {
errorText = 'Not running'
actionText = 'Start'
// config unsatisfied
} else if (error.type === DependencyErrorType.ConfigUnsatisfied) {
errorText = 'Config not satisfied'
actionText = 'Auto config'
action = () => this.fixDep(pkg, 'configure', id)
} else if (error.type === DependencyErrorType.Transitive) {
errorText = 'Dependency has a dependency issue'
}
errorText = `${errorText}. ${pkg.manifest.title} will not work as expected.`
}
const depInfo = pkg.installed['dependency-info'][id]
return {
id,
version: pkg.manifest.dependencies[id].version,
title: depInfo.manifest.title,
icon: depInfo.icon,
errorText,
actionText,
action,
}
}
async fixDep(
pkg: PackageDataEntry,
action: 'install' | 'update' | 'configure',
id: string,
): Promise<void> {
switch (action) {
case 'install':
case 'update':
return this.installDep(pkg, id)
case 'configure':
return this.configureDep(pkg, id)
}
}
private async installDep(
pkg: PackageDataEntry,
depId: string,
): Promise<void> {
const version = pkg.manifest.dependencies[depId].version
const dependentInfo: DependentInfo = {
id: pkg.manifest.id,
title: pkg.manifest.title,
version,
}
const navigationExtras: NavigationExtras = {
state: { dependentInfo },
}
await this.navCtrl.navigateForward(
`/marketplace/${depId}`,
navigationExtras,
)
}
private async configureDep(
pkg: PackageDataEntry,
dependencyId: string,
): Promise<void> {
const dependentInfo: DependentInfo = {
id: pkg.manifest.id,
title: pkg.manifest.title,
}
await this.modalService.presentModalConfig({
pkgId: dependencyId,
dependentInfo,
})
}
}

View File

@@ -0,0 +1,42 @@
import { Inject, Pipe, PipeTransform } from '@angular/core'
import {
HealthCheckResult,
PackageDataEntry,
PackageMainStatus,
} from 'src/app/services/patch-db/data-model'
import { exists, isEmptyObject } from 'src/app/util/misc.util'
import { filter, map, startWith } from 'rxjs/operators'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { Observable } from 'rxjs'
@Pipe({
name: 'toHealthChecks',
})
export class ToHealthChecksPipe implements PipeTransform {
constructor(private readonly patch: PatchDbService) {}
transform(
pkg: PackageDataEntry,
): Observable<Record<string, HealthCheckResult | null>> | null {
const healthChecks = Object.keys(pkg.manifest['health-checks']).reduce(
(obj, key) => ({ ...obj, [key]: null }),
{},
)
const healthChecks$ = this.patch
.watch$('package-data', pkg.manifest.id, 'installed', 'status', 'main')
.pipe(
filter(obj => exists(obj)),
map(main => {
// Question: is this ok or do we have to use Object.keys
// to maintain order and the keys initially present in pkg?
return main.status === PackageMainStatus.Running && !isEmptyObject(main.health)
? main.health
: healthChecks
}),
startWith(healthChecks),
)
return isEmptyObject(healthChecks) ? null : healthChecks$
}
}

View File

@@ -0,0 +1,15 @@
import { Pipe, PipeTransform } from '@angular/core'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import {
PackageStatus,
renderPkgStatus,
} from 'src/app/services/pkg-status-rendering.service'
@Pipe({
name: 'toStatus',
})
export class ToStatusPipe implements PipeTransform {
transform(pkg: PackageDataEntry): PackageStatus {
return renderPkgStatus(pkg)
}
}

View File

@@ -0,0 +1,44 @@
import { NgModule } from '@angular/core'
import { Routes, RouterModule } from '@angular/router'
const routes: Routes = [
{
path: '',
redirectTo: 'list',
pathMatch: 'full',
},
{
path: 'list',
loadChildren: () => import('./app-list/app-list.module').then(m => m.AppListPageModule),
},
{
path: ':pkgId',
loadChildren: () => import('./app-show/app-show.module').then(m => m.AppShowPageModule),
},
{
path: ':pkgId/actions',
loadChildren: () => import('./app-actions/app-actions.module').then(m => m.AppActionsPageModule),
},
{
path: ':pkgId/interfaces',
loadChildren: () => import('./app-interfaces/app-interfaces.module').then(m => m.AppInterfacesPageModule),
},
{
path: ':pkgId/logs',
loadChildren: () => import('./app-logs/app-logs.module').then(m => m.AppLogsPageModule),
},
{
path: ':pkgId/metrics',
loadChildren: () => import('./app-metrics/app-metrics.module').then(m => m.AppMetricsPageModule),
},
{
path: ':pkgId/properties',
loadChildren: () => import('./app-properties/app-properties.module').then(m => m.AppPropertiesPageModule),
},
]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class AppsRoutingModule { }

View File

@@ -0,0 +1,26 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { CommonModule } from '@angular/common'
import { FormsModule } from '@angular/forms'
import { IonicModule } from '@ionic/angular'
import { LoginPage } from './login.page'
import { SharingModule } from 'src/app/modules/sharing.module'
const routes: Routes = [
{
path: '',
component: LoginPage,
},
]
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
RouterModule.forChild(routes),
SharingModule,
],
declarations: [LoginPage],
})
export class LoginPageModule { }

View File

@@ -0,0 +1,37 @@
<ion-content>
<ion-grid style="height: 100%; max-width: 540px;">
<ion-row class="ion-align-items-center" style="height: 90%;">
<ion-col class="ion-text-center">
<div style="padding-bottom: 16px;">
<img src="assets/img/logo.png" style="max-width: 240px;" />
</div>
<ion-card color="dark">
<ion-card-header class="ion-text-center" style="padding-bottom: 8px;">
<ion-card-title>Log in to Embassy</ion-card-title>
</ion-card-header>
<ion-card-content class="ion-margin">
<form (submit)="submit()" style="margin-bottom: 12px;">
<ion-item-group>
<p class="input-label">Password</p>
<ion-item color="dark">
<ion-icon slot="start" name="key-outline" style="margin-right: 16px;"></ion-icon>
<ion-input [type]="unmasked ? 'text' : 'password'" name="password" [(ngModel)]="password" (ionChange)="error = ''"></ion-input>
<ion-button 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>
<p style="text-align: left; padding-top: 4px"><ion-text color="danger">{{ error }}</ion-text></p>
</ion-item-group>
<ion-button class="login-button" type="submit" expand="block">
<span style="font-size: larger; font-weight: bold;">Log In</span>
</ion-button>
</form>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</ion-grid>
</ion-content>

View File

@@ -0,0 +1,28 @@
ion-card-title {
margin: 24px 0;
font-family: 'Montserrat';
font-size: x-large;
--color: var(--ion-color-light);
}
ion-item {
--border-radius: 6px;
--border-style: solid;
--border-width: 1px;
--border-color: var(--ion-color-light);
}
.input-label {
text-align: left;
padding-bottom: 2px;
font-size: small;
font-weight: bold;
}
.login-button {
margin-inline-start: 0;
margin-inline-end: 0;
margin-top: 24px;
height: 48px;
--background: linear-gradient(45deg, var(--ion-color-light) 16%, var(--ion-color-dark) 150%);
}

View File

@@ -0,0 +1,64 @@
import { Component } from '@angular/core'
import { LoadingController, getPlatforms } from '@ionic/angular'
import { Subscription } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { AuthService } from 'src/app/services/auth.service'
@Component({
selector: 'login',
templateUrl: './login.page.html',
styleUrls: ['./login.page.scss'],
})
export class LoginPage {
password = ''
unmasked = false
error = ''
loader: HTMLIonLoadingElement
patchConnectionSub: Subscription
constructor (
private readonly authService: AuthService,
private readonly loadingCtrl: LoadingController,
private readonly api: ApiService,
) { }
ngOnDestroy () {
if (this.loader) {
this.loader.dismiss()
this.loader = undefined
}
if (this.patchConnectionSub) {
this.patchConnectionSub.unsubscribe()
}
}
toggleMask () {
this.unmasked = !this.unmasked
}
async submit () {
this.error = ''
this.loader = await this.loadingCtrl.create({
message: 'Logging in',
spinner: 'lines',
cssClass: 'loader',
})
await this.loader.present()
try {
document.cookie = ''
await this.api.login({
password: this.password,
metadata: { platforms: getPlatforms() },
})
this.authService.setVerified()
this.password = ''
} catch (e) {
this.error = e.code === 34 ? 'Invalid Password' : e.message
} finally {
this.loader.dismiss()
}
}
}

View File

@@ -0,0 +1,24 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppReleaseNotes } from './app-release-notes.page'
import { SharingModule } from 'src/app/modules/sharing.module'
const routes: Routes = [
{
path: '',
component: AppReleaseNotes,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
SharingModule,
],
declarations: [AppReleaseNotes],
})
export class ReleaseNotesModule { }

View File

@@ -0,0 +1,35 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [defaultHref]="'/marketplace/' + pkgId"></ion-back-button>
</ion-buttons>
<ion-title>Release Notes</ion-title>
</ion-toolbar>
</ion-header>
<ion-content>
<text-spinner *ngIf="loading; else loaded" text="Loading Release Notes"></text-spinner>
<ng-template #loaded>
<div style="margin: 0px;" *ngFor="let note of marketplaceService.releaseNotes[pkgId] | keyvalue : asIsOrder">
<ion-button
(click)="setSelected(note.key)"
expand="full" color="light"
style="height: 50px; margin: 1px;"
[class]="selected === note.key ? 'ion-activated' : ''"
>
<p style="position: absolute; left: 10px;">{{ note.key | displayEmver }}</p>
</ion-button>
<ion-card
[id]="note.key"
[ngStyle]="{
'max-height': selected === note.key ? getDocSize(note.key) : '0px',
'transition': 'max-height 0.2s ease-out'
}"
class="panel"
color="light" >
<ion-text id='release-notes' [innerHTML]="note.value | markdown"></ion-text>
</ion-card>
</div>
</ng-template>
</ion-content>

View File

@@ -0,0 +1,8 @@
.panel {
margin: 0px;
padding: 0px 24px;
}
.active {
border: 5px solid #4d4d4d;
}

View File

@@ -0,0 +1,62 @@
import { Component, ViewChild } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { IonContent } from '@ionic/angular'
import { ErrorToastService } from 'src/app/services/error-toast.service'
import { MarketplaceService } from '../marketplace.service'
@Component({
selector: 'app-release-notes',
templateUrl: './app-release-notes.page.html',
styleUrls: ['./app-release-notes.page.scss'],
})
export class AppReleaseNotes {
@ViewChild(IonContent) content: IonContent
selected: string
pkgId: string
loading = true
constructor (
private readonly route: ActivatedRoute,
public marketplaceService: MarketplaceService,
public errToast: ErrorToastService,
) { }
async ngOnInit () {
this.pkgId = this.route.snapshot.paramMap.get('pkgId')
try {
const promises = []
if (!this.marketplaceService.releaseNotes[this.pkgId]) {
promises.push(this.marketplaceService.getReleaseNotes(this.pkgId))
}
if (!this.marketplaceService.pkgs.length) {
promises.push(this.marketplaceService.load())
}
await Promise.all(promises)
} catch (e) {
this.errToast.present(e)
} finally {
this.loading = false
}
}
ngAfterViewInit () {
this.content.scrollToPoint(undefined, 1)
}
setSelected (selected: string) {
if (this.selected === selected) {
this.selected = null
} else {
this.selected = selected
}
}
getDocSize (selected: string) {
const element = document.getElementById(selected)
return `${element.scrollHeight}px`
}
asIsOrder (a: any, b: any) {
return 0
}
}

View File

@@ -0,0 +1,30 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { MarketplaceListPage } from './marketplace-list.page'
import { SharingModule } from '../../../modules/sharing.module'
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
import { StatusComponentModule } from 'src/app/components/status/status.component.module'
import { FormsModule } from '@angular/forms'
const routes: Routes = [
{
path: '',
component: MarketplaceListPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
FormsModule,
RouterModule.forChild(routes),
StatusComponentModule,
SharingModule,
BadgeMenuComponentModule,
],
declarations: [MarketplaceListPage],
})
export class MarketplaceListPageModule { }

View File

@@ -0,0 +1,142 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<!-- loading -->
<text-spinner
*ngIf="!patch.loaded else data"
text="Connecting to Embassy"
></text-spinner>
<!-- not loading -->
<ng-template #data>
<h1 style="font-family: 'Montserrat'; font-size: 42px; margin: 32px 0;" class="ion-text-center">Embassy Marketplace</h1>
<ion-grid style="padding-bottom: 32px;">
<ion-row>
<ion-col sizeSm="8" offset-sm="2">
<ion-toolbar color="transparent">
<ion-searchbar
enterkeyhint="search"
color="dark"
debounce="250"
[(ngModel)]="query"
(ionChange)="search()"
></ion-searchbar>
</ion-toolbar>
</ion-col>
</ion-row>
</ion-grid>
<!-- loading -->
<ng-container *ngIf="loading; else pageLoaded">
<div class="scrollable ion-text-center">
<ion-button *ngFor="let cat of ['', '', '', '', '', '', '']" fill="clear">
<ion-skeleton-text animated style="width: 80px; border-radius: 0;"></ion-skeleton-text>
</ion-button>
</div>
<div class="divider" style="margin: 24px 0;"></div>
</ng-container>
<!-- loaded -->
<ng-template #pageLoaded>
<div class="scrollable ion-text-center">
<ion-button
*ngFor="let cat of categories"
fill="clear"
[class]="cat === category ? 'selected' : 'dim'"
(click)="switchCategory(cat)"
>
{{ cat }}
</ion-button>
</div>
<div class="divider" style="margin: 24px;"></div>
</ng-template>
<!-- loading -->
<ng-container *ngIf="loading; else pkgsLoaded">
<ion-grid>
<ion-row>
<ion-col *ngFor="let pkg of ['', '', '', '']" sizeXs="12" sizeSm="12" sizeMd="6">
<ion-item>
<ion-thumbnail slot="start">
<ion-skeleton-text style="border-radius: 100%;" animated></ion-skeleton-text>
</ion-thumbnail>
<ion-label>
<ion-skeleton-text animated style="width: 150px; height: 18px; margin-bottom: 8px;"></ion-skeleton-text>
<ion-skeleton-text animated style="width: 400px;"></ion-skeleton-text>
<ion-skeleton-text animated style="width: 100px;"></ion-skeleton-text>
</ion-label>
</ion-item>
</ion-col>
</ion-row>
</ion-grid>
</ng-container>
<!-- packages loaded -->
<ng-template #pkgsLoaded>
<div
class="ion-padding"
*ngIf="!pkgs.length && category ==='updates'"
style="text-align: center;"
>
<h1>All services are up to date!</h1>
</div>
<ion-grid>
<ion-row>
<ion-col *ngIf="marketplaceService.eosUpdateAvailable && category === 'featured'" sizeXs="12" sizeSm="12" sizeMd="6">
<ion-item button class="eos-item" (click)="updateEos()">
<ion-thumbnail slot="start">
<img src="assets/img/icon.png" />
</ion-thumbnail>
<ion-label>
<h3>Now Available...</h3>
<h2>Embassy OS {{ marketplaceService.eos.version }}</h2>
<p>{{ marketplaceService.eos.headline }}</p>
</ion-label>
</ion-item>
</ion-col>
<ion-col *ngFor="let pkg of pkgs" sizeXs="12" sizeSm="12" sizeMd="6">
<ion-item [routerLink]="['/marketplace', pkg.manifest.id]">
<ion-thumbnail slot="start">
<img [src]="'/marketplace' + pkg.icon" />
</ion-thumbnail>
<ion-label>
<h2 style="font-family: 'Montserrat'; font-weight: bold;">{{ pkg.manifest.title }}</h2>
<h3>{{ pkg.manifest.description.short }}</h3>
<ng-container *ngIf="localPkgs[pkg.manifest.id] as localPkg; else none">
<p *ngIf="localPkg.state === PackageState.Installed">
<ion-text *ngIf="(pkg.manifest.version | compareEmver : localPkg.manifest.version) === 0" color="success">Installed</ion-text>
<ion-text *ngIf="(pkg.manifest.version | compareEmver : localPkg.manifest.version) === 1" color="warning">Update Available</ion-text>
</p>
<p *ngIf="[PackageState.Installing, PackageState.Updating] | includes : localPkg.state">
<ion-text color="primary" *ngIf="(localPkg['install-progress'] | installProgress) as progress">
Installing
<span class="loading-dots"></span>{{ progress }}
</ion-text>
</p>
<p *ngIf="localPkg.state === PackageState.Removing">
<ion-text color="danger">
Removing
<span class="loading-dots"></span>
</ion-text>
</p>
</ng-container>
<ng-template #none>
<p>Not Installed</p>
</ng-template>
</ion-label>
</ion-item>
</ion-col>
</ion-row>
</ion-grid>
</ng-template>
</ng-template>
</ion-content>

View File

@@ -0,0 +1,30 @@
.scrollable {
overflow: auto;
white-space: nowrap;
// background-color: var(--ion-color-light);
height: 60px;
/* Hide scrollbar for Chrome, Safari and Opera */
::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
.eos-item {
--border-style: none;
--background: linear-gradient(45deg, var(--ion-color-dark) -380%, var(--ion-color-medium) 100%)
}
.selected {
font-weight: bold;
font-size: 17px;
}
.dim {
font-weight: 300;
color: var(--ion-color-dark-shade);
}

View File

@@ -0,0 +1,189 @@
import { Component, ViewChild } from '@angular/core'
import { MarketplacePkg } from 'src/app/services/api/api.types'
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
import { AlertController, IonContent, ModalController } from '@ionic/angular'
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
import { PackageDataEntry, PackageState } from 'src/app/services/patch-db/data-model'
import { Subscription } from 'rxjs'
import { ErrorToastService } from 'src/app/services/error-toast.service'
import { MarketplaceService } from '../marketplace.service'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import Fuse from 'fuse.js/dist/fuse.min.js'
import { exists, isEmptyObject } from 'src/app/util/misc.util'
import { Router } from '@angular/router'
import { filter, first } from 'rxjs/operators'
const defaultOps = {
isCaseSensitive: false,
includeScore: true,
shouldSort: true,
includeMatches: false,
findAllMatches: false,
minMatchCharLength: 1,
location: 0,
threshold: 0.6,
distance: 100,
useExtendedSearch: false,
ignoreLocation: false,
ignoreFieldNorm: false,
keys: [
'manifest.id',
'manifest.title',
'manifest.description.short',
'manifest.description.long',
],
}
@Component({
selector: 'marketplace-list',
templateUrl: './marketplace-list.page.html',
styleUrls: ['./marketplace-list.page.scss'],
})
export class MarketplaceListPage {
PackageState = PackageState
@ViewChild(IonContent) content: IonContent
pkgs: MarketplacePkg[] = []
hasRecoveredPackage: boolean
categories: string[]
localPkgs: { [id: string]: PackageDataEntry } = { }
category = 'featured'
query: string
loading = true
subs: Subscription[] = []
constructor (
private readonly modalCtrl: ModalController,
private readonly errToast: ErrorToastService,
private readonly wizardBaker: WizardBaker,
private readonly alertCtrl: AlertController,
private readonly router: Router,
public readonly patch: PatchDbService,
public readonly marketplaceService: MarketplaceService,
) { }
async ngOnInit () {
this.subs = [
this.patch.watch$('package-data')
.pipe(
filter((data) => exists(data) && !isEmptyObject(data)),
).subscribe(pkgs => {
this.localPkgs = pkgs
Object.values(this.localPkgs).forEach(pkg => {
pkg['install-progress'] = { ...pkg['install-progress'] }
})
}),
this.patch.watch$('recovered-packages').subscribe(rps => {
this.hasRecoveredPackage = !isEmptyObject(rps)
}),
]
this.patch.watch$('server-info')
.pipe(
filter((data) => exists(data) && !isEmptyObject(data)),
first(),
).subscribe(async _ => {
try {
if (!this.marketplaceService.pkgs.length) {
await this.marketplaceService.load()
}
// category should start as first item in array
// remove here then add at beginning
const filterdCategories = this.marketplaceService.data.categories.filter(cat => this.category !== cat)
this.categories = [this.category, 'updates'].concat(filterdCategories).concat(['all'])
this.filterPkgs()
} catch (e) {
this.errToast.present(e)
} finally {
this.loading = false
}
})
}
ngAfterViewInit () {
this.content.scrollToPoint(undefined, 1)
}
ngOnDestroy () {
this.subs.forEach(sub => sub.unsubscribe())
}
async search (): Promise<void> {
if (this.query) {
this.category = undefined
}
await this.filterPkgs()
}
async switchCategory (category: string): Promise<void> {
this.category = category
this.query = undefined
this.filterPkgs()
}
async updateEos (): Promise<void> {
if (this.hasRecoveredPackage) {
const alert = await this.alertCtrl.create({
header: 'Cannot Update',
message: 'You cannot update EmbassyOS when you have unresolved recovered services.',
buttons: [
{
text: 'OK',
role: 'cancel',
},
{
text: 'Resolve',
handler: () => {
this.router.navigate(['/services/list'], { replaceUrl: true })
},
cssClass: 'enter-click',
},
],
})
await alert.present()
return
}
const { version, headline, 'release-notes': releaseNotes } = this.marketplaceService.eos
await wizardModal(
this.modalCtrl,
this.wizardBaker.updateOS({
version,
headline,
releaseNotes,
}),
)
}
private async filterPkgs (): Promise<void> {
if (this.category === 'updates') {
this.pkgs = this.marketplaceService.pkgs.filter(pkg => {
const { id, version } = pkg.manifest
return this.localPkgs[id] && version !== this.localPkgs[id].manifest.version
})
} else if (this.query) {
const fuse = new Fuse(this.marketplaceService.pkgs, defaultOps)
this.pkgs = fuse.search(this.query).map(p => p.item)
} else {
const pkgsToSort = this.marketplaceService.pkgs.filter(p => {
return this.category === 'all' || p.categories.includes(this.category)
})
const opts = {
...defaultOps,
threshold: 1,
}
const fuse = new Fuse(pkgsToSort, { ...defaultOps, threshold: 1 })
this.pkgs = fuse.search(this.category !== 'all' ? this.category || '' : 'bit').map(p => p.item)
}
}
}

View File

@@ -0,0 +1,28 @@
import { NgModule } from '@angular/core'
import { Routes, RouterModule } from '@angular/router'
const routes: Routes = [
{
path: '',
redirectTo: 'browse',
pathMatch: 'full',
},
{
path: 'browse',
loadChildren: () => import('./marketplace-list/marketplace-list.module').then(m => m.MarketplaceListPageModule),
},
{
path: ':pkgId',
loadChildren: () => import('./marketplace-show/marketplace-show.module').then(m => m.MarketplaceShowPageModule),
},
{
path: ':pkgId/notes',
loadChildren: () => import('./app-release-notes/app-release-notes.module').then(m => m.ReleaseNotesModule),
},
]
@NgModule({
imports: [RouterModule.forChild(routes)],
exports: [RouterModule],
})
export class MarketplaceRoutingModule { }

View File

@@ -0,0 +1,28 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { MarketplaceShowPage } from './marketplace-show.page'
import { SharingModule } from 'src/app/modules/sharing.module'
import { StatusComponentModule } from 'src/app/components/status/status.component.module'
import { InstallWizardComponentModule } from 'src/app/components/install-wizard/install-wizard.component.module'
const routes: Routes = [
{
path: '',
component: MarketplaceShowPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
StatusComponentModule,
RouterModule.forChild(routes),
SharingModule,
InstallWizardComponentModule,
],
declarations: [MarketplaceShowPage],
})
export class MarketplaceShowPageModule { }

View File

@@ -0,0 +1,204 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="marketplace"></ion-back-button>
</ion-buttons>
<ion-title>Marketplace Listing</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<text-spinner *ngIf="loading; else loaded" text="Loading Package"></text-spinner>
<ng-template #loaded>
<ion-grid>
<ion-row>
<ion-col sizeXs="12" sizeSm="12" sizeMd="9" sizeLg="9" sizeXl="9">
<div class="header">
<img [src]="'/marketplace' + pkg.icon" />
<div class="header-text">
<h1 class="header-title">{{ pkg.manifest.title }}</h1>
<p class="header-version">{{ pkg.manifest.version | displayEmver }}</p>
<div class="header-status">
<!-- no localPkg -->
<p *ngIf="!localPkg; else local">Not Installed</p>
<!-- localPkg -->
<ng-template #local>
<!-- installed -->
<p *ngIf="localPkg.state === PackageState.Installed">
<ion-text *ngIf="(pkg.manifest.version | compareEmver : localPkg.manifest.version) === 0" color="success">Installed</ion-text>
<ion-text *ngIf="(pkg.manifest.version | compareEmver : localPkg.manifest.version) === 1" color="warning">Update Available</ion-text>
</p>
<!-- installing, updating -->
<p *ngIf="[PackageState.Installing, PackageState.Updating] | includes : localPkg.state">
<ion-text color="primary" *ngIf="(localPkg['install-progress'] | installProgress) as progress">
Installing
<span class="loading-dots"></span>{{ progress }}
</ion-text>
</p>
<!-- removing -->
<p *ngIf="localPkg.state === PackageState.Removing">
<ion-text color="danger">
Removing
<span class="loading-dots"></span>
</ion-text>
</p>
</ng-template>
</div>
</div>
</div>
</ion-col>
<ion-col sizeXl="3" sizeLg="3" sizeMd="3" sizeSm="12" sizeXs="12" class="ion-align-self-center">
<!-- no localPkg -->
<ion-button *ngIf="!localPkg" expand="block" (click)="tryInstall()">
Install
</ion-button>
<!-- localPkg -->
<ng-container *ngIf="localPkg">
<!-- not installing, updating, or removing -->
<ng-container *ngIf="localPkg.state === PackageState.Installed">
<ion-button *ngIf="(localPkg.manifest.version | compareEmver : pkg.manifest.version) === -1" expand="block" (click)="presentModal('update')">
Update
</ion-button>
<ion-button *ngIf="(localPkg.manifest.version | compareEmver : pkg.manifest.version) === 1" expand="block" color="warning" (click)="presentModal('downgrade')">
Downgrade
</ion-button>
</ng-container>
</ng-container>
</ion-col>
</ion-row>
<ion-row *ngIf="localPkg">
<ion-col sizeXl="3" sizeLg="3" sizeMd="3" sizeSm="12" sizeXs="12" class="ion-align-self-center">
<ion-button expand="block" fill="outline" color="primary" [routerLink]="['/services', pkg.manifest.id]">
View Service
</ion-button>
</ion-col>
</ion-row>
</ion-grid>
<!-- auto-config -->
<ion-item lines="none" *ngIf="dependentInfo" class="rec-item">
<ion-label>
<h2 style="display: flex; align-items: center;">
<ion-text style="margin: 5px; font-family: 'Montserrat'; font-size: 18px;">{{ pkg.manifest.title }}</ion-text>
</h2>
<p>
<ion-text color="dark">
{{ dependentInfo.title }} requires an install of {{ pkg.manifest.title }} satisfying {{ dependentInfo.version }}.
<br />
<br />
<span *ngIf="pkg.manifest.version | satisfiesEmver: dependentInfo.version" class="recommendation-text">{{ pkg.manifest.title }} version {{ pkg.manifest.version | displayEmver }} is compatible.</span>
<span *ngIf="!(pkg.manifest.version | satisfiesEmver: dependentInfo.version)" class="recommendation-text recommendation-error">{{ pkg.manifest.title }} version {{ pkg.manifest.version | displayEmver }} is NOT compatible.</span>
</ion-text>
</p>
</ion-label>
</ion-item>
<ion-item-group>
<!-- release notes -->
<ion-item-divider>
New in {{ pkg.manifest.version | displayEmver }}
<ion-button [routerLink]="['notes']" style="position: absolute; right: 10px;" fill="clear" color="dark">
All Release Notes
<ion-icon slot="end" name="arrow-forward-outline"></ion-icon>
</ion-button>
</ion-item-divider>
<ion-item lines="none" color="transparent">
<ion-label>
<div id='release-notes' [innerHTML]="pkg.manifest['release-notes'] | markdown"></div>
</ion-label>
</ion-item>
<!-- description -->
<ion-item-divider>Description</ion-item-divider>
<ion-item lines="none" color="transparent">
<ion-label>
<div id="release-notes" class="release-notes">{{ pkg.manifest.description.long }}</div>
</ion-label>
</ion-item>
<!-- dependencies -->
<ng-container *ngIf="!(pkg.manifest.dependencies | empty)">
<ion-item-divider>Dependencies</ion-item-divider>
<ion-grid>
<ion-row>
<ion-col *ngFor="let dep of pkg.manifest.dependencies | keyvalue" sizeSm="12" sizeMd="6">
<ion-item [routerLink]="['/marketplace', dep.key]">
<ion-thumbnail slot="start">
<img [src]="'/marketplace' + pkg['dependency-metadata'][dep.key].icon" />
</ion-thumbnail>
<ion-label>
<h2>
{{ pkg['dependency-metadata'][dep.key].title }}
<span *ngIf="dep.value.requirement.type === 'required'"> (required)</span>
<span *ngIf="dep.value.requirement.type === 'opt-out'"> (required by default)</span>
<span *ngIf="dep.value.requirement.type === 'opt-in'"> (optional)</span>
</h2>
<p style="font-size: small">{{ dep.value.version | displayEmver }}</p>
<p>{{ dep.value.description }}</p>
</ion-label>
</ion-item>
</ion-col>
</ion-row>
</ion-grid>
</ng-container>
</ion-item-group>
<ion-item-divider>Additional Info</ion-item-divider>
<ion-card>
<ion-grid>
<ion-row>
<ion-col sizeSm="12" sizeMd="6">
<ion-item-group>
<ion-item button detail="false" (click)="presentAlertVersions()">
<ion-label>
<h2>Other Versions</h2>
<p>Click to view other versions</p>
</ion-label>
<ion-icon slot="end" name="chevron-forward-outline"></ion-icon>
</ion-item>
<ion-item button detail="false" (click)="presentModalMd('license')">
<ion-label>
<h2>License</h2>
<p>{{ pkg.manifest.license }}</p>
</ion-label>
<ion-icon slot="end" name="chevron-forward-outline"></ion-icon>
</ion-item>
<ion-item button detail="false" (click)="presentModalMd('instructions')">
<ion-label>
<h2>Instructions</h2>
<p>Click to view instructions</p>
</ion-label>
<ion-icon slot="end" name="chevron-forward-outline"></ion-icon>
</ion-item>
</ion-item-group>
</ion-col>
<ion-col sizeSm="12" sizeMd="6">
<ion-item-group>
<ion-item [href]="pkg.manifest['upstream-repo']" target="_blank" rel="noreferrer" detail="false">
<ion-label>
<h2>Source Repository</h2>
<p>{{ pkg.manifest['upstream-repo'] }}</p>
</ion-label>
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-item>
<ion-item [href]="pkg.manifest['wrapper-repo']" target="_blank" rel="noreferrer" detail="false">
<ion-label>
<h2>Wrapper Repository</h2>
<p>{{ pkg.manifest['wrapper-repo'] }}</p>
</ion-label>
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-item>
<ion-item [href]="pkg.manifest['support-site']" target="_blank" rel="noreferrer" detail="false">
<ion-label>
<h2>Support Site</h2>
<p>{{ pkg.manifest['support-site'] }}</p>
</ion-label>
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-item>
</ion-item-group>
</ion-col>
</ion-row>
</ion-grid>
</ion-card>
</ng-template>
</ion-content>

View File

@@ -0,0 +1,41 @@
.header {
font-family: 'Montserrat';
padding: 2%;
img {
min-width: 15%;
max-width: 18%;
}
.header-text {
margin-left: 5%;
display: inline-block;
vertical-align: top;
.header-title {
margin: 0 0 0 -2px;
font-size: calc(20px + 3vw)
}
.header-version {
padding: 4px 0 12px 0;
margin: 0;
font-size: calc(10px + 1vw)
}
.header-status {
p {
margin: 0;
font-size: calc(16px + 1vw)
}
}
}
}
.recommendation-text {
font-style: italic;
}
.recommendation-error {
color: var(--ion-color-danger);
}
#release-notes {
overflow: auto;
max-height: 120px;
}

View File

@@ -0,0 +1,201 @@
import { Component, ViewChild } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { AlertController, IonContent, LoadingController, ModalController, NavController } from '@ionic/angular'
import { wizardModal } from 'src/app/components/install-wizard/install-wizard.component'
import { WizardBaker } from 'src/app/components/install-wizard/prebaked-wizards'
import { Emver } from 'src/app/services/emver.service'
import { displayEmver } from 'src/app/pipes/emver.pipe'
import { DependentInfo, pauseFor } from 'src/app/util/misc.util'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { ErrorToastService } from 'src/app/services/error-toast.service'
import { PackageDataEntry, PackageState } from 'src/app/services/patch-db/data-model'
import { MarketplaceService } from '../marketplace.service'
import { Subscription } from 'rxjs'
import { MarkdownPage } from 'src/app/modals/markdown/markdown.page'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { MarketplacePkg } from 'src/app/services/api/api.types'
@Component({
selector: 'marketplace-show',
templateUrl: './marketplace-show.page.html',
styleUrls: ['./marketplace-show.page.scss'],
})
export class MarketplaceShowPage {
@ViewChild(IonContent) content: IonContent
loading = true
pkgId: string
pkg: MarketplacePkg
localPkg: PackageDataEntry
PackageState = PackageState
dependentInfo: DependentInfo
subs: Subscription[] = []
constructor (
private readonly route: ActivatedRoute,
private readonly alertCtrl: AlertController,
private readonly modalCtrl: ModalController,
private readonly loadingCtrl: LoadingController,
private readonly errToast: ErrorToastService,
private readonly wizardBaker: WizardBaker,
private readonly navCtrl: NavController,
private readonly emver: Emver,
private readonly patch: PatchDbService,
private readonly embassyApi: ApiService,
private readonly marketplaceService: MarketplaceService,
) { }
async ngOnInit () {
this.pkgId = this.route.snapshot.paramMap.get('pkgId')
this.dependentInfo = history.state && history.state.dependentInfo as DependentInfo
this.subs = [
this.patch.watch$('package-data', this.pkgId)
.subscribe(pkg => {
if (!pkg) return
this.localPkg = pkg
this.localPkg['install-progress'] = { ...this.localPkg['install-progress'] }
}),
]
try {
if (!this.marketplaceService.pkgs.length) {
await this.marketplaceService.load()
}
this.pkg = this.marketplaceService.pkgs.find(pkg => pkg.manifest.id === this.pkgId)
if (!this.pkg) {
throw new Error(`Service with ID "${this.pkgId}" not found.`)
}
} catch (e) {
this.errToast.present(e)
} finally {
this.loading = false
}
}
ngAfterViewInit () {
this.content.scrollToPoint(undefined, 1)
}
ngOnDestroy () {
this.subs.forEach(sub => sub.unsubscribe())
}
async presentAlertVersions () {
const alert = await this.alertCtrl.create({
header: 'Versions',
inputs: this.pkg.versions.sort((a, b) => -1 * this.emver.compare(a, b)).map(v => {
return {
name: v, // for CSS
type: 'radio',
label: displayEmver(v), // appearance on screen
value: v, // literal SEM version value
checked: this.pkg.manifest.version === v,
}
}),
buttons: [
{
text: 'Cancel',
role: 'cancel',
}, {
text: 'Ok',
handler: (version: string) => {
this.getPkg(version)
},
cssClass: 'enter-click',
},
],
})
await alert.present()
}
async presentModalMd (title: string) {
const modal = await this.modalCtrl.create({
componentProps: {
title,
contentUrl: `/marketplace${this.pkg[title]}`,
},
component: MarkdownPage,
})
await modal.present()
}
async tryInstall () {
const { id, title, version, alerts } = this.pkg.manifest
if (!alerts.install) {
await this.install(id, version)
} else {
const alert = await this.alertCtrl.create({
header: title,
subHeader: version,
message: alerts.install,
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Install',
handler: () => {
this.install(id, version)
},
},
],
})
await alert.present()
}
}
async presentModal (action: 'update' | 'downgrade') {
const { id, title, version, dependencies, alerts } = this.pkg.manifest
const value = {
id,
title,
version,
serviceRequirements: dependencies,
installAlert: alerts.install,
}
const { cancelled } = await wizardModal(
this.modalCtrl,
action === 'update' ?
this.wizardBaker.update(value) :
this.wizardBaker.downgrade(value),
)
if (cancelled) return
await pauseFor(250)
this.navCtrl.back()
}
private async getPkg (version?: string): Promise<void> {
this.loading = true
try {
this.pkg = await this.marketplaceService.getPkg(this.pkgId, version)
} catch (e) {
this.errToast.present(e)
} finally {
await pauseFor(100)
this.loading = false
}
}
private async install (id: string, version?: string): Promise<void> {
const loader = await this.loadingCtrl.create({
spinner: 'lines',
message: 'Beginning Installation',
cssClass: 'loader',
})
loader.present()
try {
await this.embassyApi.installPackage({ id, 'version-spec': version ? `=${version}` : undefined })
} catch (e) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
}

View File

@@ -0,0 +1,85 @@
import { Injectable } from '@angular/core'
import { MarketplaceData, MarketplaceEOS, MarketplacePkg } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { Emver } from 'src/app/services/emver.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
@Injectable({
providedIn: 'root',
})
export class MarketplaceService {
data: MarketplaceData
eos: MarketplaceEOS
pkgs: MarketplacePkg[] = []
releaseNotes: { [id: string]: {
[version: string]: string
} } = { }
constructor (
private readonly api: ApiService,
private readonly emver: Emver,
private readonly patch: PatchDbService,
) { }
get eosUpdateAvailable () {
return this.emver.compare(this.eos.version, this.patch.data['server-info'].version) === 1
}
async load (): Promise<void> {
const [data, eos, pkgs] = await Promise.all([
this.api.getMarketplaceData({ }),
this.api.getEos({
'eos-version-compat': this.patch.getData()['server-info']['eos-version-compat'],
}),
this.getPkgs(1, 100),
])
this.data = data
this.eos = eos
this.pkgs = pkgs
}
async getUpdates (localPkgs: { [id: string]: PackageDataEntry }) : Promise<MarketplacePkg[]> {
const idAndCurrentVersions = Object.keys(localPkgs).map(key => ({ id: key, version: localPkgs[key].manifest.version }))
const latestPkgs = await this.api.getMarketplacePkgs({
ids: idAndCurrentVersions,
'eos-version-compat': this.patch.getData()['server-info']['eos-version-compat'],
})
return latestPkgs.filter(latestPkg => {
const latestVersion = latestPkg.manifest.version
const curVersion = localPkgs[latestPkg.manifest.id]?.manifest.version
return !!curVersion && this.emver.compare(latestVersion, curVersion) === 1
})
}
async getPkg (id: string, version = '*'): Promise<MarketplacePkg> {
const pkgs = await this.api.getMarketplacePkgs({
ids: [{ id, version }],
'eos-version-compat': this.patch.getData()['server-info']['eos-version-compat'],
})
const pkg = pkgs.find(pkg => pkg.manifest.id == id)
if (!pkg) {
throw new Error(`No results for ${id}${version ? ' ' + version : ''}`)
} else {
return pkg
}
}
async getReleaseNotes (id: string): Promise<void> {
this.releaseNotes[id] = await this.api.getReleaseNotes({ id })
}
private async getPkgs (page: number, perPage: number) : Promise<MarketplacePkg[]> {
const pkgs = await this.api.getMarketplacePkgs({
page: String(page),
'per-page': String(perPage),
'eos-version-compat': this.patch.getData()['server-info']['eos-version-compat'],
})
return pkgs
}
}

View File

@@ -0,0 +1,28 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { RouterModule, Routes } from '@angular/router'
import { NotificationsPage } from './notifications.page'
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
import { SharingModule } from 'src/app/modules/sharing.module'
import { BackupReportPageModule } from 'src/app/modals/backup-report/backup-report.module'
const routes: Routes = [
{
path: '',
component: NotificationsPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
BadgeMenuComponentModule,
SharingModule,
BackupReportPageModule,
],
declarations: [NotificationsPage],
})
export class NotificationsPageModule { }

View File

@@ -0,0 +1,98 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start" *ngIf="fromToast">
<ion-back-button></ion-back-button>
</ion-buttons>
<ion-title>Notifications</ion-title>
<ion-buttons slot="end">
<badge-menu-button></badge-menu-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content>
<!-- loading -->
<ion-item-group *ngIf="loading">
<ion-item-divider>
<ion-button slot="end" fill="clear">
<ion-skeleton-text style="width: 90px; height: 14px; border-radius: 0;" animated></ion-skeleton-text>
</ion-button>
</ion-item-divider>
<ion-item *ngFor="let entry of ['', '', '', '']">
<ion-label>
<ion-skeleton-text animated style="width: 15%; height: 20px; margin-bottom: 12px;"></ion-skeleton-text>
<ion-skeleton-text animated style="width: 50%; margin-bottom: 18px;"></ion-skeleton-text>
<ion-skeleton-text animated style="width: 20%;"></ion-skeleton-text>
</ion-label>
<ion-button slot="end" fill="clear">
<ion-skeleton-text animated style="width: 20px; height: 20px; border-radius: 0"></ion-skeleton-text>
</ion-button>
</ion-item>
</ion-item-group>
<!-- not loading -->
<ng-container *ngIf="!loading">
<!-- no notifications -->
<ion-item-group *ngIf="!notifications.length">
<div
style="
text-align: center;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);"
>
<ion-icon style="font-size: 84px; color: #595959" name="mail-outline"></ion-icon>
<h4 style="color: #595959; margin-top: 0px">Inbox Empty</h4>
</div>
</ion-item-group>
<!-- has notifications -->
<ng-container *ngIf="notifications.length">
<ion-item-group style="margin-bottom: 16px;">
<ion-item-divider>
<ion-button slot="end" fill="clear" (click)="presentAlertDeleteAll()">
Delete All
</ion-button>
</ion-item-divider>
<ion-item *ngFor="let not of notifications; let i = index">
<ion-label>
<h2>
<b>
<span *ngIf="not['package-id']">{{ not['package-id'] }} - </span>
<ion-text [color]="not | notificationColor">{{ not.title }}</ion-text>
</b>
</h2>
<h2 class="notification-message">
{{ not.message | truncateTail: 1000 }}
</h2>
<p class="view-message-tag">
<a class="view-message-tag" *ngIf="not.message.length > 1000" color="dark" (click)="viewFullMessage(not.title, not.message)">
View Full Message
</a>
</p>
<p>
{{ not['created-at'] | date: 'short' }}
</p>
</ion-label>
<ion-button *ngIf="not.code === 1" slot="end" fill="clear" color="dark" (click)="viewBackupReport(not)">
View Report
</ion-button>
<ion-button *ngIf="not['package-id']" slot="end" fill="clear" color="dark" [routerLink]="['/services', not['package-id']]">
View Service
</ion-button>
<ion-button slot="end" fill="clear" (click)="delete(not.id, i)">
<ion-icon slot="icon-only" name="close"></ion-icon>
</ion-button>
</ion-item>
</ion-item-group>
<ion-infinite-scroll [disabled]="!needInfinite" (ionInfinite)="doInfinite($event)">
<ion-infinite-scroll-content loadingSpinner="lines"></ion-infinite-scroll-content>
</ion-infinite-scroll>
</ng-container>
</ng-container>
</ion-content>

View File

@@ -0,0 +1,9 @@
.notification-message {
margin: 6px 0 8px 0;
}
.view-message-tag {
margin-bottom: 8px;
font-size: 16px;
cursor: pointer;
}

View File

@@ -0,0 +1,145 @@
import { Component } from '@angular/core'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ServerNotification, ServerNotifications } from 'src/app/services/api/api.types'
import { AlertController, LoadingController, ModalController } from '@ionic/angular'
import { ActivatedRoute } from '@angular/router'
import { ErrorToastService } from 'src/app/services/error-toast.service'
import { BackupReportPage } from 'src/app/modals/backup-report/backup-report.page'
@Component({
selector: 'notifications',
templateUrl: 'notifications.page.html',
styleUrls: ['notifications.page.scss'],
})
export class NotificationsPage {
loading = true
notifications: ServerNotifications = []
beforeCursor: number
needInfinite = false
fromToast = false
readonly perPage = 40
constructor (
private readonly embassyApi: ApiService,
private readonly alertCtrl: AlertController,
private readonly loadingCtrl: LoadingController,
private readonly modalCtrl: ModalController,
private readonly errToast: ErrorToastService,
private readonly route: ActivatedRoute,
) { }
async ngOnInit () {
this.fromToast = !!this.route.snapshot.queryParamMap.get('toast')
this.notifications = await this.getNotifications()
this.loading = false
}
async doInfinite (e: any) {
const notifications = await this.getNotifications()
this.notifications = this.notifications.concat(notifications)
e.target.complete()
}
async getNotifications (): Promise<ServerNotifications> {
let notifications: ServerNotifications = []
try {
notifications = await this.embassyApi.getNotifications({ before: this.beforeCursor, limit: this.perPage })
this.beforeCursor = notifications[notifications.length - 1]?.id
this.needInfinite = notifications.length >= this.perPage
} catch (e) {
this.errToast.present(e)
} finally {
return notifications
}
}
async delete (id: number, index: number): Promise<void> {
const loader = await this.loadingCtrl.create({
spinner: 'lines',
message: 'Deleting...',
cssClass: 'loader',
})
await loader.present()
try {
await this.embassyApi.deleteNotification({ id })
this.notifications.splice(index, 1)
this.beforeCursor = this.notifications[this.notifications.length - 1]?.id
} catch (e) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
async presentAlertDeleteAll () {
const alert = await this.alertCtrl.create({
backdropDismiss: false,
header: 'Delete All?',
message: 'Are you sure you want to delete all notifications?',
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Delete',
cssClass: 'enter-click',
handler: () => {
this.deleteAll()
},
},
],
})
await alert.present()
}
async viewBackupReport (notification: ServerNotification<1>) {
const modal = await this.modalCtrl.create({
component: BackupReportPage,
componentProps: {
report: notification.data,
timestamp: notification['created-at'],
},
})
await modal.present()
}
async viewFullMessage (title: string, message: string) {
const alert = await this.alertCtrl.create({
header: title,
message: message,
cssClass: 'wider-alert',
buttons: [
{
text: `OK`,
handler: () => {
alert.dismiss()
},
cssClass: 'enter-click',
},
],
})
await alert.present()
}
private async deleteAll (): Promise<void> {
const loader = await this.loadingCtrl.create({
spinner: 'lines',
message: 'Deleting...',
cssClass: 'loader',
})
await loader.present()
try {
await this.embassyApi.deleteAllNotifications({ before: this.notifications[0].id })
this.notifications = []
this.beforeCursor = undefined
} catch (e) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
}

View File

@@ -0,0 +1,24 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { LANPage } from './lan.page'
import { SharingModule } from 'src/app/modules/sharing.module'
const routes: Routes = [
{
path: '',
component: LANPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
SharingModule,
],
declarations: [LANPage],
})
export class LANPageModule { }

View File

@@ -0,0 +1,43 @@
<ion-header>
<ion-toolbar>
<ion-title>LAN Settings</ion-title>
<ion-buttons slot="start">
<ion-back-button defaultHref="embassy"></ion-back-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<ion-item-group>
<!-- about -->
<ion-item class="ion-padding-bottom">
<ion-label>
<h2>
Connecting to your Embassy over LAN provides a lightning fast experience and is a reliable fallback in case Tor is having problems. To connect to your Embassy's .local address, you must:
<ol>
<li>Be connected to the same Local Area Network (LAN) as your Embassy.</li>
<li>Download and trust your Embassy's SSL Certificate Authority (below).</li>
</ol>
View the full <a href="https://docs.start9.com/user-manual/general/lan-setup" target="_blank" rel="noreferrer">instructions</a>.
</h2>
<ng-container *ngIf="lanDisabled">
<br />
<ion-text color="warning" [innerHtml]="lanDisabled"></ion-text>
</ng-container>
</ion-label>
</ion-item>
<ion-item button (click)="installCert()" [disabled]="lanDisabled">
<ion-icon slot="start" name="download-outline" size="large"></ion-icon>
<ion-label>
<h1>Download Root CA</h1>
<p>Download and trust your Embassy's Root Certificate Authority to establish a secure, https connection over LAN.</p>
</ion-label>
</ion-item>
</ion-item-group>
<!-- hidden element for downloading cert -->
<a id="install-cert" href="/public/eos/local.crt" download="Embassy Local CA.crt"></a>
</ion-content>

View File

@@ -0,0 +1,26 @@
import { Component } from '@angular/core'
import { ConfigService } from 'src/app/services/config.service'
@Component({
selector: 'lan',
templateUrl: './lan.page.html',
styleUrls: ['./lan.page.scss'],
})
export class LANPage {
lanAddress: string
lanDisabled: string
constructor (
private readonly config: ConfigService,
) { }
ngOnInit () {
if (!this.config.isTor()) {
this.lanDisabled = 'For security reasons, you must setup LAN over a Tor connection. Please navigate to your Embassy Tor Address and try again.'
}
}
installCert (): void {
document.getElementById('install-cert').click()
}
}

View File

@@ -0,0 +1,26 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { PreferencesPage } from './preferences.page'
import { Routes, RouterModule } from '@angular/router'
import { SharingModule } from 'src/app/modules/sharing.module'
const routes: Routes = [
{
path: '',
component: PreferencesPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
SharingModule,
],
declarations: [
PreferencesPage,
],
})
export class PreferencesPageModule { }

View File

@@ -0,0 +1,41 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="embassy"></ion-back-button>
</ion-buttons>
<ion-title>Preferences</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<ion-item-group *ngIf="patch.data['server-info'] as server">
<ion-item-divider>General</ion-item-divider>
<ion-item button (click)="presentModalName()">
<ion-label>{{ fields['name'].name }}</ion-label>
<ion-note slot="end">{{ patch.data.ui.name || defaultName }}</ion-note>
</ion-item>
<ion-item button (click)="serverConfig.presentAlert('share-stats', server['share-stats'])">
<ion-label>Auto Report Bugs</ion-label>
<ion-note slot="end">{{ server['share-stats'] ? 'Enabled' : 'Disabled' }}</ion-note>
</ion-item>
<!-- <ion-item button (click)="presentModalValueEdit('password')">
<ion-label>Change Password</ion-label>
<ion-note slot="end">********</ion-note>
</ion-item> -->
<ion-item-divider>Marketplace</ion-item-divider>
<ion-item button (click)="serverConfig.presentAlert('auto-check-updates', patch.data.ui['auto-check-updates'])">
<ion-label>Auto Check for Updates</ion-label>
<ion-note slot="end">{{ patch.data.ui['auto-check-updates'] ? 'Enabled' : 'Disabled' }}</ion-note>
</ion-item>
<!-- <ion-item button (click)="presentModalValueEdit('packageMarketplace', server['package-marketplace'])">
<ion-label>Package Marketplace</ion-label>
<ion-note slot="end">{{ server['package-marketplace'] }}</ion-note>
</ion-item> -->
</ion-item-group>
</ion-content>

View File

@@ -0,0 +1,82 @@
import { Component, ViewChild } from '@angular/core'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import { IonContent, LoadingController, ModalController } from '@ionic/angular'
import { GenericInputComponent, GenericInputOptions } from 'src/app/modals/generic-input/generic-input.component'
import { ConfigSpec } from 'src/app/pkg-config/config-types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ServerConfigService } from 'src/app/services/server-config.service'
@Component({
selector: 'preferences',
templateUrl: './preferences.page.html',
styleUrls: ['./preferences.page.scss'],
})
export class PreferencesPage {
@ViewChild(IonContent) content: IonContent
fields = fields
defaultName: string
constructor (
private readonly loadingCtrl: LoadingController,
private readonly modalCtrl: ModalController,
private readonly api: ApiService,
public readonly serverConfig: ServerConfigService,
public readonly patch: PatchDbService,
) { }
ngOnInit () {
this.defaultName = `Embassy-${this.patch.getData()['server-info'].id}`
}
ngAfterViewInit () {
this.content.scrollToPoint(undefined, 1)
}
async presentModalName (): Promise<void> {
const options: GenericInputOptions = {
title: 'Edit Device Name',
message: 'This is for your reference only.',
label: 'Device Name',
useMask: false,
placeholder: this.defaultName,
nullable: true,
initialValue: this.patch.getData().ui.name,
buttonText: 'Save',
submitFn: (value: string) => this.setDbValue('name', value || this.defaultName),
}
const modal = await this.modalCtrl.create({
componentProps: { options },
cssClass: 'alertlike-modal',
presentingElement: await this.modalCtrl.getTop(),
component: GenericInputComponent,
})
await modal.present()
}
private async setDbValue (key: string, value: string): Promise<void> {
const loader = await this.loadingCtrl.create({
spinner: 'lines',
message: 'Saving...',
cssClass: 'loader',
})
await loader.present()
try {
await this.api.setDbValue({ pointer: `/${key}`, value })
} finally {
loader.dismiss()
}
}
}
const fields: ConfigSpec = {
'name': {
name: 'Device Name',
type: 'string',
nullable: false,
masked: false,
copyable: false,
},
}

View File

@@ -0,0 +1,5 @@
<backup-drives-header title="Restore From Backup"></backup-drives-header>
<ion-content class="ion-padding">
<backup-drives type="restore" (onSelect)="presentModalPassword($event)"></backup-drives>
</ion-content>

View File

@@ -0,0 +1,30 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { RestorePage } from './restore.component'
import { SharingModule } from 'src/app/modules/sharing.module'
import { BackupDrivesComponentModule } from 'src/app/components/backup-drives/backup-drives.component.module'
import { AppRecoverSelectPageModule } from 'src/app/modals/app-recover-select/app-recover-select.module'
const routes: Routes = [
{
path: '',
component: RestorePage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule.forChild(routes),
SharingModule,
BackupDrivesComponentModule,
AppRecoverSelectPageModule,
],
declarations: [
RestorePage,
],
})
export class RestorePageModule { }

View File

@@ -0,0 +1,107 @@
import { Component } from '@angular/core'
import { ModalController, NavController } from '@ionic/angular'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { GenericInputComponent, GenericInputOptions } from 'src/app/modals/generic-input/generic-input.component'
import { MappedBackupTarget } from 'src/app/util/misc.util'
import { BackupInfo, CifsBackupTarget, DiskBackupTarget } from 'src/app/services/api/api.types'
import { AppRecoverSelectPage } from 'src/app/modals/app-recover-select/app-recover-select.page'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
import * as argon2 from '@start9labs/argon2'
@Component({
selector: 'restore',
templateUrl: './restore.component.html',
styleUrls: ['./restore.component.scss'],
})
export class RestorePage {
constructor (
private readonly modalCtrl: ModalController,
private readonly navCtrl: NavController,
private readonly embassyApi: ApiService,
private readonly patch: PatchDbService,
) { }
async presentModalPassword (target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>): Promise<void> {
const options: GenericInputOptions = {
title: 'Master Password Required',
message: 'Enter your master password. On the next screen, you will select the individual services you want to restore.',
label: 'Master Password',
placeholder: 'Enter master password',
useMask: true,
buttonText: 'Next',
submitFn: (password: string) => this.decryptDrive(target, password),
}
const modal = await this.modalCtrl.create({
componentProps: { options },
cssClass: 'alertlike-modal',
presentingElement: await this.modalCtrl.getTop(),
component: GenericInputComponent,
})
await modal.present()
}
private async decryptDrive (target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>, password: string): Promise<void> {
const passwordHash = this.patch.getData()['server-info']['password-hash']
argon2.verify(passwordHash, password)
try {
argon2.verify(target.entry['embassy-os']['password-hash'], password)
await this.restoreFromBackup(target, password)
} catch (e) {
setTimeout(() => this.presentModalOldPassword(target, password), 500)
}
}
private async presentModalOldPassword (target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>, password: string): Promise<void> {
const options: GenericInputOptions = {
title: 'Original Password Needed',
message: 'This backup was created with a different password. Enter the ORIGINAL password that was used to encrypt this backup.',
label: 'Original Password',
placeholder: 'Enter original password',
useMask: true,
buttonText: 'Restore From Backup',
submitFn: (oldPassword: string) => this.restoreFromBackup(target, password, oldPassword),
}
const m = await this.modalCtrl.create({
component: GenericInputComponent,
componentProps: { options },
presentingElement: await this.modalCtrl.getTop(),
cssClass: 'alertlike-modal',
})
await m.present()
}
private async restoreFromBackup (target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>, password: string, oldPassword?: string): Promise<void> {
const backupInfo = await this.embassyApi.getBackupInfo({
'target-id': target.id,
password,
})
this.presentModalSelect(target.id, backupInfo, password, oldPassword)
}
private async presentModalSelect (id: string, backupInfo: BackupInfo, password: string, oldPassword?: string): Promise<void> {
const modal = await this.modalCtrl.create({
componentProps: {
id,
backupInfo,
password,
oldPassword,
},
presentingElement: await this.modalCtrl.getTop(),
component: AppRecoverSelectPage,
})
modal.onWillDismiss().then(res => {
if (res.role === 'success') {
this.navCtrl.navigateRoot('/services')
}
})
await modal.present()
}
}

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