mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-04-02 05:23:14 +00:00
feat: move all frontend projects under the same Angular workspace (#1141)
* feat: move all frontend projects under the same Angular workspace * Refactor/angular workspace (#1154) * update frontend build steps Co-authored-by: waterplea <alexander@inkin.ru> Co-authored-by: Matt Hill <matthewonthemoon@gmail.com> Co-authored-by: Lucy Cifferello <12953208+elvece@users.noreply.github.com>
This commit is contained in:
@@ -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>
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 + ')'
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
ion-item-divider {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
.metric-note {
|
||||
font-size: 16px;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -0,0 +1,3 @@
|
||||
.metric-note {
|
||||
font-size: 16px;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
.inline {
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
}
|
||||
|
||||
.icon {
|
||||
padding-right: 4px;
|
||||
}
|
||||
@@ -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[] = []
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,7 @@
|
||||
.name {
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
}
|
||||
|
||||
.less-large {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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[] = []
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,4 @@
|
||||
:host {
|
||||
display: block;
|
||||
padding: 16px;
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,9 @@
|
||||
.label {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
margin: 10px;
|
||||
min-height: 36px;
|
||||
min-width: 120px;
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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$
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
26
frontend/projects/ui/src/app/pages/login/login.module.ts
Normal file
26
frontend/projects/ui/src/app/pages/login/login.module.ts
Normal 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 { }
|
||||
37
frontend/projects/ui/src/app/pages/login/login.page.html
Normal file
37
frontend/projects/ui/src/app/pages/login/login.page.html
Normal 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>
|
||||
28
frontend/projects/ui/src/app/pages/login/login.page.scss
Normal file
28
frontend/projects/ui/src/app/pages/login/login.page.scss
Normal 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%);
|
||||
}
|
||||
64
frontend/projects/ui/src/app/pages/login/login.page.ts
Normal file
64
frontend/projects/ui/src/app/pages/login/login.page.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -0,0 +1,8 @@
|
||||
.panel {
|
||||
margin: 0px;
|
||||
padding: 0px 24px;
|
||||
}
|
||||
|
||||
.active {
|
||||
border: 5px solid #4d4d4d;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -0,0 +1,9 @@
|
||||
.notification-message {
|
||||
margin: 6px 0 8px 0;
|
||||
}
|
||||
|
||||
.view-message-tag {
|
||||
margin-bottom: 8px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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>
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 { }
|
||||
@@ -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
Reference in New Issue
Block a user