mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 20:14:49 +00:00
rename frontend to web and update contributing guide (#2509)
* rename frontend to web and update contributing guide * rename this time * fix build * restructure rust code * update documentation * update descriptions * Update CONTRIBUTING.md Co-authored-by: J H <2364004+Blu-J@users.noreply.github.com> --------- Co-authored-by: Aiden McClelland <me@drbonez.dev> Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com> Co-authored-by: J H <2364004+Blu-J@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,30 @@
|
||||
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 { SharedPipesModule } from '@start9labs/shared'
|
||||
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,
|
||||
SharedPipesModule,
|
||||
GenericFormPageModule,
|
||||
ActionSuccessPageModule,
|
||||
],
|
||||
declarations: [AppActionsPage, AppActionsItemComponent],
|
||||
})
|
||||
export class AppActionsPageModule {}
|
||||
@@ -0,0 +1,37 @@
|
||||
<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 with-widgets">
|
||||
<ion-item-group *ngIf="pkg$ | async as pkg">
|
||||
<!-- ** standard actions ** -->
|
||||
<ion-item-divider>Standard Actions</ion-item-divider>
|
||||
<app-actions-item
|
||||
[action]="{
|
||||
name: 'Uninstall',
|
||||
description: 'This will uninstall the service from StartOS and delete all data permanently.',
|
||||
icon: 'trash-outline'
|
||||
}"
|
||||
(click)="tryUninstall(pkg)"
|
||||
></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(pkg, action)"
|
||||
></app-actions-item>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,226 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import {
|
||||
AlertController,
|
||||
LoadingController,
|
||||
ModalController,
|
||||
NavController,
|
||||
} from '@ionic/angular'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import {
|
||||
Action,
|
||||
DataModel,
|
||||
PackageDataEntry,
|
||||
PackageMainStatus,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page'
|
||||
import { isEmptyObject, ErrorToastService, getPkgId } from '@start9labs/shared'
|
||||
import { ActionSuccessPage } from 'src/app/modals/action-success/action-success.page'
|
||||
import { hasCurrentDeps } from 'src/app/util/has-deps'
|
||||
|
||||
@Component({
|
||||
selector: 'app-actions',
|
||||
templateUrl: './app-actions.page.html',
|
||||
styleUrls: ['./app-actions.page.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AppActionsPage {
|
||||
readonly pkgId = getPkgId(this.route)
|
||||
readonly pkg$ = this.patch.watch$('package-data', this.pkgId)
|
||||
|
||||
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 navCtrl: NavController,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
async handleAction(
|
||||
pkg: PackageDataEntry,
|
||||
action: { key: string; value: Action },
|
||||
) {
|
||||
const status = pkg.installed?.status
|
||||
if (
|
||||
status &&
|
||||
(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 = ''
|
||||
if (statuses.length) {
|
||||
if (statuses.length > 1) {
|
||||
// oxford comma
|
||||
statusesStr += ','
|
||||
}
|
||||
statusesStr += ` or ${last}`
|
||||
} else if (last) {
|
||||
statusesStr = `${last}`
|
||||
} else {
|
||||
error = `There is no status 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 tryUninstall(pkg: PackageDataEntry): Promise<void> {
|
||||
const { title, alerts } = pkg.manifest
|
||||
|
||||
let message =
|
||||
alerts.uninstall ||
|
||||
`Uninstalling ${title} will permanently delete its data`
|
||||
|
||||
if (hasCurrentDeps(pkg)) {
|
||||
message = `${message}. Services that depend on ${title} will no longer work properly and may crash`
|
||||
}
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Warning',
|
||||
message,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Uninstall',
|
||||
handler: () => {
|
||||
this.uninstall()
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
cssClass: 'alert-warning-message',
|
||||
})
|
||||
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
private async uninstall() {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: `Beginning uninstall...`,
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.embassyApi.uninstallPackage({ id: this.pkgId })
|
||||
this.embassyApi
|
||||
.setDbValue<boolean>(['ack-instructions', this.pkgId], false)
|
||||
.catch(e => console.error('Failed to mark instructions as unseen', e))
|
||||
this.navCtrl.navigateRoot('/services')
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private async executeAction(
|
||||
actionId: string,
|
||||
input?: object,
|
||||
): Promise<boolean> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Executing action...',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
const res = await this.embassyApi.executePackageAction({
|
||||
id: this.pkgId,
|
||||
'action-id': actionId,
|
||||
input,
|
||||
})
|
||||
|
||||
const successModal = await this.modalCtrl.create({
|
||||
component: ActionSuccessPage,
|
||||
componentProps: {
|
||||
actionRes: res,
|
||||
},
|
||||
})
|
||||
|
||||
setTimeout(() => successModal.present(), 500)
|
||||
return true // needed to dismiss original modal/alert
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
return false // don't dismiss original modal/alert
|
||||
} 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'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AppActionsItemComponent {
|
||||
@Input() action!: LocalAction
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
<ion-item *ngIf="interface">
|
||||
<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 *ngIf="interface" 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)="showQR(tor)">
|
||||
<ion-icon
|
||||
size="small"
|
||||
slot="icon-only"
|
||||
name="qr-code-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)="showQR(lan)">
|
||||
<ion-icon
|
||||
size="small"
|
||||
slot="icon-only"
|
||||
name="qr-code-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,28 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Routes, RouterModule } from '@angular/router'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
|
||||
import {
|
||||
AppInterfacesItemComponent,
|
||||
AppInterfacesPage,
|
||||
} from './app-interfaces.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AppInterfacesPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
SharedPipesModule,
|
||||
],
|
||||
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 with-widgets">
|
||||
<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,3 @@
|
||||
p {
|
||||
font-family: 'Courier New';
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Component, Inject, Input } from '@angular/core'
|
||||
import { WINDOW } from '@ng-web-apis/common'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { ModalController, ToastController } from '@ionic/angular'
|
||||
import { copyToClipboard, getPkgId } from '@start9labs/shared'
|
||||
import { getUiInterfaceKey } from 'src/app/services/config.service'
|
||||
import {
|
||||
DataModel,
|
||||
InstalledPackageDataEntry,
|
||||
InterfaceDef,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { QRComponent } from 'src/app/components/qr/qr.component'
|
||||
import { getPackage } from '../../../util/get-package-data'
|
||||
|
||||
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 {
|
||||
ui?: LocalInterface
|
||||
other: LocalInterface[] = []
|
||||
readonly pkgId = getPkgId(this.route)
|
||||
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
const pkg = await getPackage(this.patch, this.pkgId)
|
||||
if (!pkg) return
|
||||
|
||||
const interfaces = pkg.manifest.interfaces
|
||||
const uiKey = getUiInterfaceKey(interfaces)
|
||||
|
||||
if (!pkg.installed) return
|
||||
|
||||
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']
|
||||
: '',
|
||||
// leave http for services
|
||||
'tor-address': uiAddresses['tor-address']
|
||||
? 'http://' + uiAddresses['tor-address']
|
||||
: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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']
|
||||
: '',
|
||||
'tor-address': addresses['tor-address']
|
||||
? // leave http for services
|
||||
'http://' + addresses['tor-address']
|
||||
: '',
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@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,
|
||||
private readonly modalCtrl: ModalController,
|
||||
@Inject(WINDOW) private readonly windowRef: Window,
|
||||
) {}
|
||||
|
||||
launch(url: string): void {
|
||||
this.windowRef.open(url, '_blank', 'noreferrer')
|
||||
}
|
||||
|
||||
async showQR(text: string): Promise<void> {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: QRComponent,
|
||||
componentProps: {
|
||||
text,
|
||||
},
|
||||
cssClass: 'qr-modal',
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
async copy(address: string): Promise<void> {
|
||||
let message = ''
|
||||
await copyToClipboard(address || '').then(success => {
|
||||
message = success
|
||||
? 'Copied to clipboard!'
|
||||
: 'Failed to copy to clipboard.'
|
||||
})
|
||||
|
||||
const toast = await this.toastCtrl.create({
|
||||
header: message,
|
||||
position: 'bottom',
|
||||
duration: 1000,
|
||||
})
|
||||
await toast.present()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<ng-container *ngIf="connected$ | async; else disconnected">
|
||||
<ion-icon
|
||||
*ngIf="pkg.error; else noError"
|
||||
class="warning-icon"
|
||||
name="warning-outline"
|
||||
size="small"
|
||||
color="warning"
|
||||
></ion-icon>
|
||||
<ng-template #noError>
|
||||
<ion-spinner
|
||||
*ngIf="pkg.transitioning; else bulb"
|
||||
class="spinner"
|
||||
size="small"
|
||||
color="primary"
|
||||
></ion-spinner>
|
||||
<ng-template #bulb>
|
||||
<div
|
||||
class="bulb"
|
||||
[style.background-color]="
|
||||
'var(--ion-color-' + pkg.primaryRendering.color + ')'
|
||||
"
|
||||
[style.color]="'var(--ion-color-' + pkg.primaryRendering.color + ')'"
|
||||
></div>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #disconnected>
|
||||
<div class="bulb" [style.background-color]="'var(--ion-color-dark)'"></div>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,22 @@
|
||||
.bulb {
|
||||
position: absolute !important;
|
||||
top: 9px !important;
|
||||
height: 14px;
|
||||
width: 14px;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
.warning-icon {
|
||||
position: absolute !important;
|
||||
top: 8px !important;
|
||||
left: 11px !important;
|
||||
font-size: 12px;
|
||||
border-radius: 100%;
|
||||
padding: 1px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
position: absolute !important;
|
||||
top: 6px !important;
|
||||
width: 18px;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { ConnectionService } from 'src/app/services/connection.service'
|
||||
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
|
||||
|
||||
readonly connected$ = this.connectionService.connected$
|
||||
|
||||
constructor(private readonly connectionService: ConnectionService) {}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<ion-item
|
||||
button
|
||||
*ngIf="pkg.entry.manifest as manifest"
|
||||
detail="false"
|
||||
class="service-card"
|
||||
[routerLink]="['/services', manifest.id]"
|
||||
>
|
||||
<app-list-icon slot="start" [pkg]="pkg"></app-list-icon>
|
||||
<ion-thumbnail slot="start">
|
||||
<img alt="" [src]="pkg.entry['static-files'].icon" />
|
||||
</ion-thumbnail>
|
||||
<ion-label>
|
||||
<h2 ticker>{{ manifest.title }}</h2>
|
||||
<p>{{ manifest.version | displayEmver }}</p>
|
||||
<status
|
||||
[rendering]="pkg.primaryRendering"
|
||||
[installProgress]="pkg.entry['install-progress']"
|
||||
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($event)"
|
||||
[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,28 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { PackageMainStatus } 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
|
||||
|
||||
constructor(private readonly launcherService: UiLauncherService) {}
|
||||
|
||||
get status(): PackageMainStatus {
|
||||
return (
|
||||
this.pkg.entry.installed?.status.main.status || PackageMainStatus.Stopped
|
||||
)
|
||||
}
|
||||
|
||||
launchUi(e: Event): void {
|
||||
e.stopPropagation()
|
||||
e.preventDefault()
|
||||
this.launcherService.launch(this.pkg.entry)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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 {
|
||||
EmverPipesModule,
|
||||
ResponsiveColModule,
|
||||
TextSpinnerComponentModule,
|
||||
TickerModule,
|
||||
} from '@start9labs/shared'
|
||||
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
|
||||
import { StatusComponentModule } from 'src/app/components/status/status.component.module'
|
||||
import { LaunchablePipeModule } from 'src/app/pipes/launchable/launchable.module'
|
||||
import { UiPipeModule } from 'src/app/pipes/ui/ui.module'
|
||||
import { AppListIconComponent } from './app-list-icon/app-list-icon.component'
|
||||
import { AppListPkgComponent } from './app-list-pkg/app-list-pkg.component'
|
||||
import { PackageInfoPipe } from './package-info.pipe'
|
||||
import { WidgetListComponentModule } from 'src/app/components/widget-list/widget-list.component.module'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AppListPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
StatusComponentModule,
|
||||
EmverPipesModule,
|
||||
TextSpinnerComponentModule,
|
||||
LaunchablePipeModule,
|
||||
UiPipeModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
BadgeMenuComponentModule,
|
||||
WidgetListComponentModule,
|
||||
ResponsiveColModule,
|
||||
TickerModule,
|
||||
],
|
||||
declarations: [
|
||||
AppListPage,
|
||||
AppListIconComponent,
|
||||
AppListPkgComponent,
|
||||
PackageInfoPipe,
|
||||
],
|
||||
})
|
||||
export class AppListPageModule {}
|
||||
@@ -0,0 +1,43 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Installed Services</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<badge-menu-button></badge-menu-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding with-widgets">
|
||||
<!-- loaded -->
|
||||
<ng-container *ngIf="pkgs$ | async as pkgs; else loading">
|
||||
<ng-container *ngIf="!pkgs.length; else list">
|
||||
<div class="welcome-header">
|
||||
<h1>Welcome to StartOS</h1>
|
||||
</div>
|
||||
<widget-list></widget-list>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #list>
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col
|
||||
*ngFor="let pkg of pkgs"
|
||||
responsiveCol
|
||||
sizeSm="12"
|
||||
sizeMd="6"
|
||||
>
|
||||
<app-list-pkg
|
||||
*ngIf="pkg.manifest.id | packageInfo | async as info"
|
||||
[pkg]="info"
|
||||
></app-list-pkg>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
|
||||
<!-- loading -->
|
||||
<ng-template #loading>
|
||||
<text-spinner text="Connecting to server"></text-spinner>
|
||||
</ng-template>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,9 @@
|
||||
.welcome-header {
|
||||
padding-bottom: 1rem;
|
||||
text-align: center;
|
||||
|
||||
h1 {
|
||||
font-weight: bold;
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { filter, map, pairwise, startWith } from 'rxjs/operators'
|
||||
|
||||
@Component({
|
||||
selector: 'app-list',
|
||||
templateUrl: './app-list.page.html',
|
||||
styleUrls: ['./app-list.page.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AppListPage {
|
||||
readonly pkgs$ = this.patch.watch$('package-data').pipe(
|
||||
map(pkgs => Object.values(pkgs)),
|
||||
startWith([]),
|
||||
pairwise(),
|
||||
filter(([prev, next]) => {
|
||||
const length = next.length
|
||||
return !length || prev.length !== length
|
||||
}),
|
||||
map(([_, pkgs]) =>
|
||||
pkgs.sort((a, b) =>
|
||||
b.manifest.title.toLowerCase() > a.manifest.title.toLowerCase()
|
||||
? -1
|
||||
: 1,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
constructor(private readonly patch: PatchDB<DataModel>) {}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { Observable, combineLatest, firstValueFrom } from 'rxjs'
|
||||
import { filter, map } from 'rxjs/operators'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { getPackageInfo, PkgInfo } from '../../../util/get-package-info'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { DepErrorService } from 'src/app/services/dep-error.service'
|
||||
|
||||
@Pipe({
|
||||
name: 'packageInfo',
|
||||
})
|
||||
export class PackageInfoPipe implements PipeTransform {
|
||||
constructor(
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly depErrorService: DepErrorService,
|
||||
) {}
|
||||
|
||||
transform(pkgId: string): Observable<PkgInfo> {
|
||||
return combineLatest([
|
||||
this.patch.watch$('package-data', pkgId).pipe(filter(Boolean)),
|
||||
this.depErrorService.getPkgDepErrors$(pkgId),
|
||||
]).pipe(map(([pkg, depErrors]) => getPackageInfo(pkg, depErrors)))
|
||||
}
|
||||
}
|
||||
@@ -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 { AppLogsPage } from './app-logs.page'
|
||||
import { LogsComponentModule } from 'src/app/components/logs/logs.component.module'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AppLogsPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
LogsComponentModule,
|
||||
],
|
||||
declarations: [AppLogsPage],
|
||||
})
|
||||
export class AppLogsPageModule {}
|
||||
@@ -0,0 +1,8 @@
|
||||
<logs
|
||||
[fetchLogs]="fetchLogs()"
|
||||
[followLogs]="followLogs()"
|
||||
[defaultBack]="'/services/' + pkgId"
|
||||
[context]="pkgId"
|
||||
pageTitle="Service Logs"
|
||||
class="ion-page"
|
||||
></logs>
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { getPkgId } from '@start9labs/shared'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { RR } from 'src/app/services/api/api.types'
|
||||
|
||||
@Component({
|
||||
selector: 'app-logs',
|
||||
templateUrl: './app-logs.page.html',
|
||||
styleUrls: ['./app-logs.page.scss'],
|
||||
})
|
||||
export class AppLogsPage {
|
||||
readonly pkgId = getPkgId(this.route)
|
||||
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly embassyApi: ApiService,
|
||||
) {}
|
||||
|
||||
followLogs() {
|
||||
return async (params: RR.FollowServerLogsReq) => {
|
||||
return this.embassyApi.followPackageLogs({
|
||||
id: this.pkgId,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fetchLogs() {
|
||||
return async (params: RR.GetServerLogsReq) => {
|
||||
return this.embassyApi.getPackageLogs({
|
||||
id: this.pkgId,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 { SharedPipesModule } from '@start9labs/shared'
|
||||
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),
|
||||
SharedPipesModule,
|
||||
SkeletonListComponentModule,
|
||||
],
|
||||
declarations: [AppMetricsPage],
|
||||
})
|
||||
export class AppMetricsPageModule {}
|
||||
@@ -0,0 +1,25 @@
|
||||
<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 with-widgets">
|
||||
<skeleton-list *ngIf="loading"></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,59 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { Metric } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { pauseFor, ErrorToastService, getPkgId } from '@start9labs/shared'
|
||||
|
||||
@Component({
|
||||
selector: 'app-metrics',
|
||||
templateUrl: './app-metrics.page.html',
|
||||
styleUrls: ['./app-metrics.page.scss'],
|
||||
})
|
||||
export class AppMetricsPage {
|
||||
loading = true
|
||||
readonly pkgId = getPkgId(this.route)
|
||||
going = false
|
||||
metrics?: Metric
|
||||
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly embassyApi: ApiService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.startDaemon()
|
||||
}
|
||||
|
||||
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: any) {
|
||||
this.errToast.present(e)
|
||||
this.stopDaemon()
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
asIsOrder(a: any, b: any) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
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 { MaskPipeModule } from 'src/app/pipes/mask/mask.module'
|
||||
import {
|
||||
SharedPipesModule,
|
||||
TextSpinnerComponentModule,
|
||||
} from '@start9labs/shared'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AppPropertiesPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
QRComponentModule,
|
||||
SharedPipesModule,
|
||||
TextSpinnerComponentModule,
|
||||
MaskPipeModule,
|
||||
],
|
||||
declarations: [AppPropertiesPage],
|
||||
})
|
||||
export class AppPropertiesPageModule {}
|
||||
@@ -0,0 +1,119 @@
|
||||
<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 with-widgets">
|
||||
<text-spinner
|
||||
*ngIf="loading; else loaded"
|
||||
text="Loading Properties"
|
||||
></text-spinner>
|
||||
|
||||
<ng-template #loaded>
|
||||
<!-- not running -->
|
||||
<ion-item *ngIf="stopped$ | async" class="ion-margin-bottom">
|
||||
<ion-label>
|
||||
<p>
|
||||
<ion-text color="warning">
|
||||
Service is stopped. 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 class="courier-new">
|
||||
{{ prop.value.masked && !unmasked[prop.key] ? (prop.value.value |
|
||||
mask : 64) : 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'"
|
||||
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,154 @@
|
||||
import { Component, ViewChild } from '@angular/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import {
|
||||
AlertController,
|
||||
IonBackButtonDelegate,
|
||||
ModalController,
|
||||
NavController,
|
||||
ToastController,
|
||||
} from '@ionic/angular'
|
||||
import { PackageProperties } from 'src/app/util/properties.util'
|
||||
import { QRComponent } from 'src/app/components/qr/qr.component'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import {
|
||||
DataModel,
|
||||
PackageMainStatus,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import {
|
||||
ErrorToastService,
|
||||
getPkgId,
|
||||
copyToClipboard,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiDestroyService } from '@taiga-ui/cdk'
|
||||
import { getValueByPointer } from 'fast-json-patch'
|
||||
import { map, takeUntil } from 'rxjs/operators'
|
||||
|
||||
@Component({
|
||||
selector: 'app-properties',
|
||||
templateUrl: './app-properties.page.html',
|
||||
styleUrls: ['./app-properties.page.scss'],
|
||||
providers: [TuiDestroyService],
|
||||
})
|
||||
export class AppPropertiesPage {
|
||||
loading = true
|
||||
readonly pkgId = getPkgId(this.route)
|
||||
|
||||
pointer = ''
|
||||
node: PackageProperties = {}
|
||||
|
||||
properties: PackageProperties = {}
|
||||
unmasked: { [key: string]: boolean } = {}
|
||||
|
||||
stopped$ = this.patch
|
||||
.watch$('package-data', this.pkgId, 'installed', 'status', 'main', 'status')
|
||||
.pipe(map(status => status === PackageMainStatus.Stopped))
|
||||
|
||||
@ViewChild(IonBackButtonDelegate, { static: false })
|
||||
backButton?: IonBackButtonDelegate
|
||||
|
||||
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: PatchDB<DataModel>,
|
||||
private readonly destroy$: TuiDestroyService,
|
||||
) {}
|
||||
|
||||
ionViewDidEnter() {
|
||||
if (!this.backButton) return
|
||||
this.backButton.onClick = () => {
|
||||
history.back()
|
||||
}
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.getProperties()
|
||||
|
||||
this.route.queryParams
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(queryParams => {
|
||||
if (queryParams['pointer'] === this.pointer) return
|
||||
this.pointer = queryParams['pointer'] || ''
|
||||
this.node = getValueByPointer(this.properties, this.pointer)
|
||||
})
|
||||
}
|
||||
|
||||
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 || undefined,
|
||||
})
|
||||
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 to clipboard.'
|
||||
})
|
||||
|
||||
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: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
asIsOrder(a: any, b: any) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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 { EmverPipesModule, ResponsiveColModule } from '@start9labs/shared'
|
||||
import { StatusComponentModule } from 'src/app/components/status/status.component.module'
|
||||
import { AppConfigPageModule } from 'src/app/modals/app-config/app-config.module'
|
||||
import { LaunchablePipeModule } from 'src/app/pipes/launchable/launchable.module'
|
||||
import { UiPipeModule } from 'src/app/pipes/ui/ui.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 { AppShowAdditionalComponent } from './components/app-show-additional/app-show-additional.component'
|
||||
import { HealthColorPipe } from './pipes/health-color.pipe'
|
||||
import { ToHealthChecksPipe } from './pipes/to-health-checks.pipe'
|
||||
import { ToButtonsPipe } from './pipes/to-buttons.pipe'
|
||||
import { ProgressDataPipe } from './pipes/progress-data.pipe'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AppShowPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
declarations: [
|
||||
AppShowPage,
|
||||
HealthColorPipe,
|
||||
ProgressDataPipe,
|
||||
ToHealthChecksPipe,
|
||||
ToButtonsPipe,
|
||||
AppShowHeaderComponent,
|
||||
AppShowProgressComponent,
|
||||
AppShowStatusComponent,
|
||||
AppShowDependenciesComponent,
|
||||
AppShowMenuComponent,
|
||||
AppShowHealthChecksComponent,
|
||||
AppShowAdditionalComponent,
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
StatusComponentModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
AppConfigPageModule,
|
||||
EmverPipesModule,
|
||||
LaunchablePipeModule,
|
||||
UiPipeModule,
|
||||
ResponsiveColModule,
|
||||
],
|
||||
})
|
||||
export class AppShowPageModule {}
|
||||
@@ -0,0 +1,41 @@
|
||||
<ng-container *ngIf="pkgPlus$ | async as pkgPlus">
|
||||
<!-- header -->
|
||||
<app-show-header [pkg]="pkgPlus.pkg"></app-show-header>
|
||||
|
||||
<!-- content -->
|
||||
<ion-content *ngIf="pkgPlus.pkg as pkg" class="ion-padding with-widgets">
|
||||
<!-- ** installing, updating, restoring ** -->
|
||||
<ng-container *ngIf="showProgress(pkg); else installed">
|
||||
<app-show-progress
|
||||
*ngIf="pkg | progressData as progressData"
|
||||
[pkg]="pkg"
|
||||
[progressData]="progressData"
|
||||
></app-show-progress>
|
||||
</ng-container>
|
||||
|
||||
<!-- Installed -->
|
||||
<ng-template #installed>
|
||||
<ion-item-group *ngIf="pkgPlus.status as status">
|
||||
<!-- ** status ** -->
|
||||
<app-show-status [pkg]="pkg" [status]="status"></app-show-status>
|
||||
<!-- ** installed && !backing-up ** -->
|
||||
<ng-container *ngIf="isInstalled(pkg) && !isBackingUp(status)">
|
||||
<!-- ** health checks ** -->
|
||||
<app-show-health-checks
|
||||
*ngIf="isRunning(status)"
|
||||
[pkg]="pkg"
|
||||
></app-show-health-checks>
|
||||
<!-- ** dependencies ** -->
|
||||
<app-show-dependencies
|
||||
*ngIf="pkgPlus.dependencies.length"
|
||||
[dependencies]="pkgPlus.dependencies"
|
||||
></app-show-dependencies>
|
||||
<!-- ** menu ** -->
|
||||
<app-show-menu [buttons]="pkg | toButtons"></app-show-menu>
|
||||
<!-- ** additional ** -->
|
||||
<app-show-additional [pkg]="pkg"></app-show-additional>
|
||||
</ng-container>
|
||||
</ion-item-group>
|
||||
</ng-template>
|
||||
</ion-content>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,224 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import {
|
||||
DataModel,
|
||||
InstalledPackageDataEntry,
|
||||
Manifest,
|
||||
PackageDataEntry,
|
||||
PackageState,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import {
|
||||
PackageStatus,
|
||||
PrimaryStatus,
|
||||
renderPkgStatus,
|
||||
} from 'src/app/services/pkg-status-rendering.service'
|
||||
import { map, tap } from 'rxjs/operators'
|
||||
import { ActivatedRoute, NavigationExtras } from '@angular/router'
|
||||
import { getPkgId } from '@start9labs/shared'
|
||||
import { ModalService } from 'src/app/services/modal.service'
|
||||
import { DependentInfo } from 'src/app/types/dependent-info'
|
||||
import {
|
||||
DepErrorService,
|
||||
DependencyErrorType,
|
||||
PkgDependencyErrors,
|
||||
} from 'src/app/services/dep-error.service'
|
||||
import { combineLatest } from 'rxjs'
|
||||
|
||||
export interface DependencyInfo {
|
||||
id: string
|
||||
title: string
|
||||
icon: string
|
||||
version: string
|
||||
errorText: string
|
||||
actionText: string
|
||||
action: () => any
|
||||
}
|
||||
|
||||
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 = getPkgId(this.route)
|
||||
|
||||
readonly pkgPlus$ = combineLatest([
|
||||
this.patch.watch$('package-data', this.pkgId),
|
||||
this.depErrorService.getPkgDepErrors$(this.pkgId),
|
||||
]).pipe(
|
||||
tap(([pkg, _]) => {
|
||||
// if package disappears, navigate to list page
|
||||
if (!pkg) this.navCtrl.navigateRoot('/services')
|
||||
}),
|
||||
map(([pkg, depErrors]) => {
|
||||
return {
|
||||
pkg,
|
||||
dependencies: this.getDepInfo(pkg, depErrors),
|
||||
status: renderPkgStatus(pkg, depErrors),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly modalService: ModalService,
|
||||
private readonly depErrorService: DepErrorService,
|
||||
) {}
|
||||
|
||||
isInstalled({ state }: PackageDataEntry): boolean {
|
||||
return state === PackageState.Installed
|
||||
}
|
||||
|
||||
isRunning({ primary }: PackageStatus): boolean {
|
||||
return primary === PrimaryStatus.Running
|
||||
}
|
||||
|
||||
isBackingUp({ primary }: PackageStatus): boolean {
|
||||
return primary === PrimaryStatus.BackingUp
|
||||
}
|
||||
|
||||
showProgress({ state }: PackageDataEntry): boolean {
|
||||
return STATES.includes(state)
|
||||
}
|
||||
|
||||
private getDepInfo(
|
||||
pkg: PackageDataEntry,
|
||||
depErrors: PkgDependencyErrors,
|
||||
): DependencyInfo[] {
|
||||
const pkgInstalled = pkg.installed
|
||||
|
||||
if (!pkgInstalled) return []
|
||||
|
||||
return Object.keys(pkgInstalled['current-dependencies'])
|
||||
.filter(id => !!pkgInstalled.manifest.dependencies[id])
|
||||
.map(id => this.getDepValues(pkgInstalled, id, depErrors))
|
||||
}
|
||||
|
||||
private getDepValues(
|
||||
pkgInstalled: InstalledPackageDataEntry,
|
||||
depId: string,
|
||||
depErrors: PkgDependencyErrors,
|
||||
): DependencyInfo {
|
||||
const { errorText, fixText, fixAction } = this.getDepErrors(
|
||||
pkgInstalled,
|
||||
depId,
|
||||
depErrors,
|
||||
)
|
||||
|
||||
const depInfo = pkgInstalled['dependency-info'][depId]
|
||||
|
||||
return {
|
||||
id: depId,
|
||||
version: pkgInstalled.manifest.dependencies[depId].version, // do we want this version range?
|
||||
title: depInfo?.title || depId,
|
||||
icon: depInfo?.icon || '',
|
||||
errorText: errorText
|
||||
? `${errorText}. ${pkgInstalled.manifest.title} will not work as expected.`
|
||||
: '',
|
||||
actionText: fixText || 'View',
|
||||
action:
|
||||
fixAction || (() => this.navCtrl.navigateForward(`/services/${depId}`)),
|
||||
}
|
||||
}
|
||||
|
||||
private getDepErrors(
|
||||
pkgInstalled: InstalledPackageDataEntry,
|
||||
depId: string,
|
||||
depErrors: PkgDependencyErrors,
|
||||
) {
|
||||
const pkgManifest = pkgInstalled.manifest
|
||||
const depError = depErrors[depId]
|
||||
|
||||
let errorText: string | null = null
|
||||
let fixText: string | null = null
|
||||
let fixAction: (() => any) | null = null
|
||||
|
||||
if (depError) {
|
||||
if (depError.type === DependencyErrorType.NotInstalled) {
|
||||
errorText = 'Not installed'
|
||||
fixText = 'Install'
|
||||
fixAction = () => this.fixDep(pkgManifest, 'install', depId)
|
||||
} else if (depError.type === DependencyErrorType.IncorrectVersion) {
|
||||
errorText = 'Incorrect version'
|
||||
fixText = 'Update'
|
||||
fixAction = () => this.fixDep(pkgManifest, 'update', depId)
|
||||
} else if (depError.type === DependencyErrorType.ConfigUnsatisfied) {
|
||||
errorText = 'Config not satisfied'
|
||||
fixText = 'Auto config'
|
||||
fixAction = () => this.fixDep(pkgManifest, 'configure', depId)
|
||||
} else if (depError.type === DependencyErrorType.NotRunning) {
|
||||
errorText = 'Not running'
|
||||
fixText = 'Start'
|
||||
} else if (depError.type === DependencyErrorType.HealthChecksFailed) {
|
||||
errorText = 'Required health check not passing'
|
||||
} else if (depError.type === DependencyErrorType.Transitive) {
|
||||
errorText = 'Dependency has a dependency issue'
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
errorText,
|
||||
fixText,
|
||||
fixAction,
|
||||
}
|
||||
}
|
||||
|
||||
private async fixDep(
|
||||
pkgManifest: Manifest,
|
||||
action: 'install' | 'update' | 'configure',
|
||||
id: string,
|
||||
): Promise<void> {
|
||||
switch (action) {
|
||||
case 'install':
|
||||
case 'update':
|
||||
return this.installDep(pkgManifest, id)
|
||||
case 'configure':
|
||||
return this.configureDep(pkgManifest, id)
|
||||
}
|
||||
}
|
||||
|
||||
private async installDep(
|
||||
pkgManifest: Manifest,
|
||||
depId: string,
|
||||
): Promise<void> {
|
||||
const version = pkgManifest.dependencies[depId].version
|
||||
|
||||
const dependentInfo: DependentInfo = {
|
||||
id: pkgManifest.id,
|
||||
title: pkgManifest.title,
|
||||
version,
|
||||
}
|
||||
const navigationExtras: NavigationExtras = {
|
||||
state: { dependentInfo },
|
||||
}
|
||||
|
||||
await this.navCtrl.navigateForward(
|
||||
`/marketplace/${depId}`,
|
||||
navigationExtras,
|
||||
)
|
||||
}
|
||||
|
||||
private async configureDep(
|
||||
pkgManifest: Manifest,
|
||||
dependencyId: string,
|
||||
): Promise<void> {
|
||||
const dependentInfo: DependentInfo = {
|
||||
id: pkgManifest.id,
|
||||
title: pkgManifest.title,
|
||||
}
|
||||
|
||||
await this.modalService.presentModalConfig({
|
||||
pkgId: dependencyId,
|
||||
dependentInfo,
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
<ion-item-divider>Additional Info</ion-item-divider>
|
||||
<ion-grid *ngIf="pkg.manifest as manifest">
|
||||
<ion-row>
|
||||
<ion-col responsiveCol sizeXs="12" sizeMd="6">
|
||||
<ion-item-group>
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>Version</h2>
|
||||
<p>{{ manifest.version | displayEmver }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item
|
||||
*ngIf="manifest['git-hash'] as gitHash; else noHash"
|
||||
button
|
||||
detail="false"
|
||||
(click)="copy(gitHash)"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>Git Hash</h2>
|
||||
<p>{{ gitHash }}</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="copy-outline"></ion-icon>
|
||||
</ion-item>
|
||||
<ng-template #noHash>
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>Git Hash</h2>
|
||||
<p>Unknown</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-template>
|
||||
<ion-item button detail="false" (click)="presentModalLicense()">
|
||||
<ion-label>
|
||||
<h2>License</h2>
|
||||
<p>{{ manifest.license }}</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="chevron-forward"></ion-icon>
|
||||
</ion-item>
|
||||
<ion-item
|
||||
[href]="manifest['marketing-site']"
|
||||
[disabled]="!manifest['marketing-site']"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
detail="false"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>Marketing Site</h2>
|
||||
<p>{{ manifest['marketing-site'] || 'Not provided' }}</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-col>
|
||||
<ion-col responsiveCol sizeXs="12" sizeMd="6">
|
||||
<ion-item-group>
|
||||
<ion-item
|
||||
[href]="manifest['upstream-repo']"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
detail="false"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>Source Repository</h2>
|
||||
<p>{{ manifest['upstream-repo'] }}</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-item>
|
||||
<ion-item
|
||||
[href]="manifest['wrapper-repo']"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
detail="false"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>Wrapper Repository</h2>
|
||||
<p>{{ manifest['wrapper-repo'] }}</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-item>
|
||||
<ion-item
|
||||
[href]="manifest['support-site']"
|
||||
[disabled]="!manifest['support-site']"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
detail="false"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>Support Site</h2>
|
||||
<p>{{ manifest['support-site'] || 'Not provided' }}</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-item>
|
||||
<ion-item
|
||||
[href]="manifest['donation-url']"
|
||||
[disabled]="!manifest['donation-url']"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
detail="false"
|
||||
>
|
||||
<ion-label>
|
||||
<h2>Donation Link</h2>
|
||||
<p>{{ manifest['donation-url'] || 'Not provided' }}</p>
|
||||
</ion-label>
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
@@ -0,0 +1,48 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { ModalController, ToastController } from '@ionic/angular'
|
||||
import { copyToClipboard, MarkdownComponent } from '@start9labs/shared'
|
||||
import { from } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'app-show-additional',
|
||||
templateUrl: 'app-show-additional.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AppShowAdditionalComponent {
|
||||
@Input()
|
||||
pkg!: PackageDataEntry
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly toastCtrl: ToastController,
|
||||
private readonly api: ApiService,
|
||||
) {}
|
||||
|
||||
async copy(address: string): Promise<void> {
|
||||
const success = await copyToClipboard(address)
|
||||
const message = success
|
||||
? 'Copied to clipboard!'
|
||||
: 'Failed to copy to clipboard.'
|
||||
|
||||
const toast = await this.toastCtrl.create({
|
||||
header: message,
|
||||
position: 'bottom',
|
||||
duration: 1000,
|
||||
})
|
||||
await toast.present()
|
||||
}
|
||||
|
||||
async presentModalLicense() {
|
||||
const modal = await this.modalCtrl.create({
|
||||
componentProps: {
|
||||
title: 'License',
|
||||
content: from(this.api.getStatic(this.pkg['static-files']['license'])),
|
||||
},
|
||||
component: MarkdownComponent,
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
}
|
||||
@@ -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="montserrat">
|
||||
<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,3 @@
|
||||
.icon {
|
||||
padding-right: 4px;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { DependencyInfo } from '../../app-show.page'
|
||||
|
||||
@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,19 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button defaultHref="services"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<div class="header">
|
||||
<img class="logo" [src]="pkg['static-files'].icon" alt="" />
|
||||
<ion-label>
|
||||
<h1
|
||||
class="montserrat"
|
||||
[class.less-large]="pkg.manifest.title.length > 20"
|
||||
>
|
||||
{{ pkg.manifest.title }}
|
||||
</h1>
|
||||
<h2>{{ pkg.manifest.version | displayEmver }}</h2>
|
||||
</ion-label>
|
||||
</div>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
@@ -0,0 +1,13 @@
|
||||
.less-large {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 54px;
|
||||
width: 54px;
|
||||
margin: 0 16px;
|
||||
}
|
||||
@@ -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,93 @@
|
||||
<ng-container
|
||||
*ngIf="pkg | toHealthChecks | async | keyvalue: asIsOrder as checks"
|
||||
>
|
||||
<ng-container *ngIf="checks.length">
|
||||
<ion-item-divider>Health Checks</ion-item-divider>
|
||||
<!-- connected -->
|
||||
<ng-container *ngIf="connected$ | async; else disconnected">
|
||||
<ion-item *ngFor="let health of checks">
|
||||
<!-- result -->
|
||||
<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>
|
||||
<span
|
||||
*ngIf="
|
||||
result === HealthResult.Success &&
|
||||
pkg.manifest['health-checks'][health.key]['success-message']
|
||||
"
|
||||
>
|
||||
:
|
||||
{{
|
||||
pkg.manifest['health-checks'][health.key]['success-message']
|
||||
}}
|
||||
</span>
|
||||
</p>
|
||||
</ion-text>
|
||||
</ion-label>
|
||||
</ng-container>
|
||||
<!-- no result -->
|
||||
<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 class="primary">Awaiting result...</p>
|
||||
</ion-label>
|
||||
</ng-template>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
<!-- disconnected -->
|
||||
<ng-template #disconnected>
|
||||
<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-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,35 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { ConnectionService } from 'src/app/services/connection.service'
|
||||
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
|
||||
|
||||
HealthResult = HealthResult
|
||||
|
||||
readonly connected$ = this.connectionService.connected$
|
||||
|
||||
constructor(private readonly connectionService: ConnectionService) {}
|
||||
|
||||
isLoading(result: HealthResult): boolean {
|
||||
return result === HealthResult.Starting || result === HealthResult.Loading
|
||||
}
|
||||
|
||||
isReady(result: HealthResult): boolean {
|
||||
return result !== HealthResult.Failure && result !== HealthResult.Loading
|
||||
}
|
||||
|
||||
asIsOrder() {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<ion-item-divider>Menu</ion-item-divider>
|
||||
<ion-item
|
||||
*ngFor="let button of buttons"
|
||||
button
|
||||
detail
|
||||
(click)="button.action()"
|
||||
[disabled]="button.disabled"
|
||||
[ngClass]="{ highlighted: button.highlighted$ | async }"
|
||||
>
|
||||
<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,6 @@
|
||||
.highlighted {
|
||||
* {
|
||||
color: var(--ion-color-dark);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
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',
|
||||
styleUrls: ['./app-show-menu.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class AppShowMenuComponent {
|
||||
@Input()
|
||||
buttons: Button[] = []
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
<p>Downloading: {{ progressData.downloadProgress }}%</p>
|
||||
<ion-progress-bar
|
||||
[color]="getColor('download-complete')"
|
||||
[value]="progressData.downloadProgress / 100"
|
||||
[buffer]="!progressData.downloadProgress ? 0 : 1"
|
||||
></ion-progress-bar>
|
||||
|
||||
<p>Validating: {{ progressData.validateProgress }}%</p>
|
||||
<ion-progress-bar
|
||||
[color]="getColor('validation-complete')"
|
||||
[value]="progressData.validateProgress / 100"
|
||||
[buffer]="validationBuffer"
|
||||
></ion-progress-bar>
|
||||
|
||||
<p>Unpacking: {{ progressData.unpackProgress }}%</p>
|
||||
<ion-progress-bar
|
||||
[color]="getColor('unpack-complete')"
|
||||
[value]="progressData.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/types/progress-data'
|
||||
|
||||
@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()
|
||||
progressData!: ProgressData
|
||||
|
||||
get unpackingBuffer(): number {
|
||||
return this.progressData.validateProgress === 100 &&
|
||||
!this.progressData.unpackProgress
|
||||
? 0
|
||||
: 1
|
||||
}
|
||||
|
||||
get validationBuffer(): number {
|
||||
return this.progressData.downloadProgress === 100 &&
|
||||
!this.progressData.validateProgress
|
||||
? 0
|
||||
: 1
|
||||
}
|
||||
|
||||
getColor(action: keyof InstallProgress): string {
|
||||
return this.pkg['install-progress']?.[action] ? 'success' : 'secondary'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
<ion-item-divider>Status</ion-item-divider>
|
||||
<ion-item>
|
||||
<ion-label class="label">
|
||||
<status
|
||||
size="x-large"
|
||||
weight="600"
|
||||
[installProgress]="pkg['install-progress']"
|
||||
[rendering]="PR[status.primary]"
|
||||
[sigtermTimeout]="pkg.manifest.main['sigterm-timeout']"
|
||||
></status>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ng-container *ngIf="isInstalled && (connected$ | async)">
|
||||
<ion-grid>
|
||||
<ion-row style="padding-left: 12px">
|
||||
<ion-col>
|
||||
<ion-button
|
||||
*ngIf="canStop"
|
||||
class="action-button"
|
||||
color="danger"
|
||||
(click)="tryStop()"
|
||||
>
|
||||
<ion-icon slot="start" name="stop-outline"></ion-icon>
|
||||
Stop
|
||||
</ion-button>
|
||||
<ng-container *ngIf="isRunning">
|
||||
<ion-button
|
||||
class="action-button"
|
||||
color="tertiary"
|
||||
(click)="tryRestart()"
|
||||
>
|
||||
<ion-icon slot="start" name="refresh"></ion-icon>
|
||||
Restart
|
||||
</ion-button>
|
||||
</ng-container>
|
||||
|
||||
<ion-button
|
||||
*ngIf="isStopped && pkgStatus?.configured"
|
||||
class="action-button"
|
||||
color="success"
|
||||
(click)="tryStart()"
|
||||
>
|
||||
<ion-icon slot="start" name="play-outline"></ion-icon>
|
||||
Start
|
||||
</ion-button>
|
||||
|
||||
<ion-button
|
||||
*ngIf="!pkgStatus?.configured"
|
||||
class="action-button"
|
||||
color="warning"
|
||||
(click)="presentModalConfig()"
|
||||
>
|
||||
<ion-icon slot="start" name="construct-outline"></ion-icon>
|
||||
Configure
|
||||
</ion-button>
|
||||
|
||||
<ion-button
|
||||
*ngIf="pkgStatus && (interfaces | hasUi)"
|
||||
class="action-button"
|
||||
color="primary"
|
||||
[disabled]="
|
||||
!(pkg.state | isLaunchable: pkgStatus.main.status:interfaces)
|
||||
"
|
||||
(click)="launchUi()"
|
||||
>
|
||||
<ion-icon slot="start" name="open-outline"></ion-icon>
|
||||
Launch UI
|
||||
</ion-button>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,9 @@
|
||||
.label {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
margin: 12px 20px 10px 0;
|
||||
min-height: 42px;
|
||||
min-width: 140px;
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { UiLauncherService } from 'src/app/services/ui-launcher.service'
|
||||
import {
|
||||
PackageStatus,
|
||||
PrimaryRendering,
|
||||
PrimaryStatus,
|
||||
} from 'src/app/services/pkg-status-rendering.service'
|
||||
import {
|
||||
InterfaceDef,
|
||||
PackageDataEntry,
|
||||
PackageState,
|
||||
Status,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { AlertController, LoadingController } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ModalService } from 'src/app/services/modal.service'
|
||||
import { hasCurrentDeps } from 'src/app/util/has-deps'
|
||||
import { ConnectionService } from 'src/app/services/connection.service'
|
||||
|
||||
@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()
|
||||
status!: PackageStatus
|
||||
|
||||
PR = PrimaryRendering
|
||||
|
||||
readonly connected$ = this.connectionService.connected$
|
||||
|
||||
constructor(
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly launcherService: UiLauncherService,
|
||||
private readonly modalService: ModalService,
|
||||
private readonly connectionService: ConnectionService,
|
||||
) {}
|
||||
|
||||
get interfaces(): Record<string, InterfaceDef> {
|
||||
return this.pkg.manifest.interfaces || {}
|
||||
}
|
||||
|
||||
get pkgStatus(): Status | null {
|
||||
return this.pkg.installed?.status || null
|
||||
}
|
||||
|
||||
get isInstalled(): boolean {
|
||||
return this.pkg.state === PackageState.Installed
|
||||
}
|
||||
|
||||
get isRunning(): boolean {
|
||||
return this.status.primary === PrimaryStatus.Running
|
||||
}
|
||||
|
||||
get canStop(): boolean {
|
||||
return [
|
||||
PrimaryStatus.Running,
|
||||
PrimaryStatus.Starting,
|
||||
PrimaryStatus.Restarting,
|
||||
].includes(this.status.primary)
|
||||
}
|
||||
|
||||
get isStopped(): boolean {
|
||||
return this.status.primary === PrimaryStatus.Stopped
|
||||
}
|
||||
|
||||
launchUi(): void {
|
||||
this.launcherService.launch(this.pkg)
|
||||
}
|
||||
|
||||
async presentModalConfig(): Promise<void> {
|
||||
return this.modalService.presentModalConfig({
|
||||
pkgId: this.id,
|
||||
})
|
||||
}
|
||||
|
||||
async tryStart(): Promise<void> {
|
||||
if (this.status.dependency === 'warning') {
|
||||
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 tryStop(): Promise<void> {
|
||||
const { title, alerts } = this.pkg.manifest
|
||||
|
||||
let message = alerts.stop || ''
|
||||
if (hasCurrentDeps(this.pkg)) {
|
||||
const depMessage = `Services that depend on ${title} will no longer work properly and may crash`
|
||||
message = message ? `${message}.\n\n${depMessage}` : depMessage
|
||||
}
|
||||
|
||||
if (message) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Warning',
|
||||
message,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Stop',
|
||||
handler: () => {
|
||||
this.stop()
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
cssClass: 'alert-warning-message',
|
||||
})
|
||||
|
||||
await alert.present()
|
||||
} else {
|
||||
this.stop()
|
||||
}
|
||||
}
|
||||
|
||||
async tryRestart(): Promise<void> {
|
||||
if (hasCurrentDeps(this.pkg)) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Warning',
|
||||
message: `Services that depend on ${this.pkg.manifest.title} may temporarily experiences issues`,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Restart',
|
||||
handler: () => {
|
||||
this.restart()
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
cssClass: 'alert-warning-message',
|
||||
})
|
||||
|
||||
await alert.present()
|
||||
} else {
|
||||
this.restart()
|
||||
}
|
||||
}
|
||||
|
||||
private get id(): string {
|
||||
return this.pkg.manifest.id
|
||||
}
|
||||
|
||||
private async start(): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: `Starting...`,
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.embassyApi.startPackage({ id: this.id })
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private async stop(): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Stopping...',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.embassyApi.stopPackage({ id: this.id })
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private async restart(): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: `Restarting...`,
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.embassyApi.restartPackage({ id: this.id })
|
||||
} catch (e: any) {
|
||||
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: 'Alert',
|
||||
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,13 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
import { ProgressData } from 'src/app/types/progress-data'
|
||||
import { packageLoadingProgress } from 'src/app/util/package-loading-progress'
|
||||
|
||||
@Pipe({
|
||||
name: 'progressData',
|
||||
})
|
||||
export class ProgressDataPipe implements PipeTransform {
|
||||
transform(pkg: PackageDataEntry): ProgressData | null {
|
||||
return packageLoadingProgress(pkg['install-progress'])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { ModalController, NavController } from '@ionic/angular'
|
||||
import { MarkdownComponent } from '@start9labs/shared'
|
||||
import {
|
||||
DataModel,
|
||||
PackageDataEntry,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { ModalService } from 'src/app/services/modal.service'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { from, map, Observable } from 'rxjs'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
|
||||
export interface Button {
|
||||
title: string
|
||||
description: string
|
||||
icon: string
|
||||
action: Function
|
||||
highlighted$?: Observable<boolean>
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'toButtons',
|
||||
})
|
||||
export class ToButtonsPipe implements PipeTransform {
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly modalService: ModalService,
|
||||
private readonly apiService: ApiService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
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',
|
||||
highlighted$: this.patch
|
||||
.watch$('ui', 'ack-instructions', pkg.manifest.id)
|
||||
.pipe(map(seen => !seen)),
|
||||
},
|
||||
// config
|
||||
{
|
||||
action: async () =>
|
||||
this.modalService.presentModalConfig({ pkgId: pkg.manifest.id }),
|
||||
title: 'Config',
|
||||
description: `Customize ${pkgTitle}`,
|
||||
icon: 'options-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
|
||||
this.viewInMarketplaceButton(pkg),
|
||||
]
|
||||
}
|
||||
|
||||
private async presentModalInstructions(pkg: PackageDataEntry) {
|
||||
this.apiService
|
||||
.setDbValue<boolean>(['ack-instructions', pkg.manifest.id], true)
|
||||
.catch(e => console.error('Failed to mark instructions as seen', e))
|
||||
|
||||
const modal = await this.modalCtrl.create({
|
||||
componentProps: {
|
||||
title: 'Instructions',
|
||||
content: from(
|
||||
this.apiService.getStatic(pkg['static-files']['instructions']),
|
||||
),
|
||||
},
|
||||
component: MarkdownComponent,
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
private viewInMarketplaceButton(pkg: PackageDataEntry): Button {
|
||||
const url = pkg.installed?.['marketplace-url']
|
||||
const queryParams = url ? { url } : {}
|
||||
|
||||
let button: Button = {
|
||||
title: 'Marketplace Listing',
|
||||
icon: 'storefront-outline',
|
||||
action: () =>
|
||||
this.navCtrl.navigateForward([`marketplace/${pkg.manifest.id}`], {
|
||||
queryParams,
|
||||
}),
|
||||
disabled: false,
|
||||
description: 'View service in the marketplace',
|
||||
}
|
||||
|
||||
if (!url) {
|
||||
button.disabled = true
|
||||
button.description = 'This package was not installed from the marketplace'
|
||||
button.action = () => {}
|
||||
}
|
||||
|
||||
return button
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import {
|
||||
DataModel,
|
||||
HealthCheckResult,
|
||||
PackageDataEntry,
|
||||
PackageMainStatus,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { isEmptyObject } from '@start9labs/shared'
|
||||
import { map, startWith } from 'rxjs/operators'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { Observable } from 'rxjs'
|
||||
|
||||
@Pipe({
|
||||
name: 'toHealthChecks',
|
||||
})
|
||||
export class ToHealthChecksPipe implements PipeTransform {
|
||||
constructor(private readonly patch: PatchDB<DataModel>) {}
|
||||
|
||||
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(
|
||||
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,59 @@
|
||||
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 {}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { DevConfigPage } from './dev-config.page'
|
||||
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
|
||||
import { BackupReportPageModule } from 'src/app/modals/backup-report/backup-report.module'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: DevConfigPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
BadgeMenuComponentModule,
|
||||
BackupReportPageModule,
|
||||
FormsModule,
|
||||
MonacoEditorModule,
|
||||
],
|
||||
declarations: [DevConfigPage],
|
||||
})
|
||||
export class DevConfigPageModule {}
|
||||
@@ -0,0 +1,28 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button
|
||||
[defaultHref]="'/developer/projects/' + projectId"
|
||||
></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>
|
||||
Config
|
||||
<ion-spinner
|
||||
*ngIf="saving"
|
||||
name="crescent"
|
||||
style="transform: scale(0.55); position: absolute"
|
||||
></ion-spinner>
|
||||
</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="preview()">Preview</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<ngx-monaco-editor
|
||||
(keyup)="save()"
|
||||
[options]="editorOptions"
|
||||
[(ngModel)]="code"
|
||||
></ngx-monaco-editor>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { debounce, ErrorToastService } from '@start9labs/shared'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { filter, take } from 'rxjs/operators'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { getProjectId } from 'src/app/util/get-project-id'
|
||||
import { GenericFormPage } from '../../../modals/generic-form/generic-form.page'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'dev-config',
|
||||
templateUrl: 'dev-config.page.html',
|
||||
styleUrls: ['dev-config.page.scss'],
|
||||
})
|
||||
export class DevConfigPage {
|
||||
readonly projectId = getProjectId(this.route)
|
||||
editorOptions = { theme: 'vs-dark', language: 'yaml' }
|
||||
code: string = ''
|
||||
saving: boolean = false
|
||||
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly api: ApiService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.patch
|
||||
.watch$('ui', 'dev', this.projectId, 'config')
|
||||
.pipe(filter(Boolean), take(1))
|
||||
.subscribe(config => {
|
||||
this.code = config
|
||||
})
|
||||
}
|
||||
|
||||
async preview() {
|
||||
let doc: any
|
||||
try {
|
||||
doc = yaml.load(this.code)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
}
|
||||
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: GenericFormPage,
|
||||
componentProps: {
|
||||
title: 'Config Sample',
|
||||
spec: JSON.parse(JSON.stringify(doc, null, 2)),
|
||||
buttons: [
|
||||
{
|
||||
text: 'OK',
|
||||
handler: () => {
|
||||
return
|
||||
},
|
||||
isSubmit: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
@debounce(1000)
|
||||
async save() {
|
||||
this.saving = true
|
||||
try {
|
||||
await this.api.setDbValue<string>(
|
||||
['dev', this.projectId, 'config'],
|
||||
this.code,
|
||||
)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
this.saving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { DevInstructionsPage } from './dev-instructions.page'
|
||||
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
|
||||
import { BackupReportPageModule } from 'src/app/modals/backup-report/backup-report.module'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: DevInstructionsPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
BadgeMenuComponentModule,
|
||||
BackupReportPageModule,
|
||||
FormsModule,
|
||||
MonacoEditorModule,
|
||||
],
|
||||
declarations: [DevInstructionsPage],
|
||||
})
|
||||
export class DevInstructionsPageModule {}
|
||||
@@ -0,0 +1,28 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button
|
||||
[defaultHref]="'/developer/projects/' + projectId"
|
||||
></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>
|
||||
Instructions
|
||||
<ion-spinner
|
||||
*ngIf="saving"
|
||||
name="crescent"
|
||||
style="transform: scale(0.55); position: absolute"
|
||||
></ion-spinner>
|
||||
</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="preview()">Preview</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<ngx-monaco-editor
|
||||
(keyup)="save()"
|
||||
[options]="editorOptions"
|
||||
[(ngModel)]="code"
|
||||
></ngx-monaco-editor>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,69 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { filter, take } from 'rxjs/operators'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import {
|
||||
debounce,
|
||||
ErrorToastService,
|
||||
MarkdownComponent,
|
||||
} from '@start9labs/shared'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { getProjectId } from 'src/app/util/get-project-id'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'dev-instructions',
|
||||
templateUrl: 'dev-instructions.page.html',
|
||||
styleUrls: ['dev-instructions.page.scss'],
|
||||
})
|
||||
export class DevInstructionsPage {
|
||||
readonly projectId = getProjectId(this.route)
|
||||
editorOptions = { theme: 'vs-dark', language: 'markdown' }
|
||||
code = ''
|
||||
saving = false
|
||||
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly api: ApiService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.patch
|
||||
.watch$('ui', 'dev', this.projectId, 'instructions')
|
||||
.pipe(filter(Boolean), take(1))
|
||||
.subscribe(config => {
|
||||
this.code = config
|
||||
})
|
||||
}
|
||||
|
||||
async preview() {
|
||||
const modal = await this.modalCtrl.create({
|
||||
componentProps: {
|
||||
title: 'Instructions Sample',
|
||||
content: this.code,
|
||||
},
|
||||
component: MarkdownComponent,
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
@debounce(1000)
|
||||
async save() {
|
||||
this.saving = true
|
||||
try {
|
||||
await this.api.setDbValue<string>(
|
||||
['dev', this.projectId, 'instructions'],
|
||||
this.code,
|
||||
)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
this.saving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { DevManifestPage } from './dev-manifest.page'
|
||||
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
|
||||
import { BackupReportPageModule } from 'src/app/modals/backup-report/backup-report.module'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: DevManifestPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
BadgeMenuComponentModule,
|
||||
BackupReportPageModule,
|
||||
FormsModule,
|
||||
MonacoEditorModule,
|
||||
],
|
||||
declarations: [DevManifestPage],
|
||||
})
|
||||
export class DevManifestPageModule {}
|
||||
@@ -0,0 +1,17 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button
|
||||
[defaultHref]="'/developer/projects/' + projectId"
|
||||
></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Manifest</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<ngx-monaco-editor
|
||||
[options]="editorOptions"
|
||||
[(ngModel)]="manifest"
|
||||
></ngx-monaco-editor>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { take } from 'rxjs/operators'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { getProjectId } from 'src/app/util/get-project-id'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'dev-manifest',
|
||||
templateUrl: 'dev-manifest.page.html',
|
||||
styleUrls: ['dev-manifest.page.scss'],
|
||||
})
|
||||
export class DevManifestPage {
|
||||
readonly projectId = getProjectId(this.route)
|
||||
editorOptions = { theme: 'vs-dark', language: 'yaml', readOnly: true }
|
||||
manifest: string = ''
|
||||
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.patch
|
||||
.watch$('ui', 'dev', this.projectId)
|
||||
.pipe(take(1))
|
||||
.subscribe(devData => {
|
||||
this.manifest = yaml.dump(devData['basic-info'])
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { DeveloperListPage } from './developer-list.page'
|
||||
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
|
||||
import { BackupReportPageModule } from 'src/app/modals/backup-report/backup-report.module'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: DeveloperListPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
BadgeMenuComponentModule,
|
||||
BackupReportPageModule,
|
||||
],
|
||||
declarations: [DeveloperListPage],
|
||||
})
|
||||
export class DeveloperListPageModule {}
|
||||
@@ -0,0 +1,37 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Developer Tools</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<badge-menu-button></badge-menu-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<ion-item-divider>Projects</ion-item-divider>
|
||||
|
||||
<ion-item button detail="false" (click)="openCreateProjectModal()">
|
||||
<ion-icon slot="start" name="add" color="dark"></ion-icon>
|
||||
<ion-label>
|
||||
<ion-text color="dark">Create project</ion-text>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item
|
||||
button
|
||||
*ngFor="let entry of devData | keyvalue"
|
||||
[routerLink]="[entry.key]"
|
||||
>
|
||||
<p>{{ entry.value.name }}</p>
|
||||
<ion-button
|
||||
slot="end"
|
||||
fill="clear"
|
||||
(click)="presentAction(entry.key, $event)"
|
||||
>
|
||||
<ion-icon name="ellipsis-horizontal"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,266 @@
|
||||
import { Component } from '@angular/core'
|
||||
import {
|
||||
ActionSheetButton,
|
||||
ActionSheetController,
|
||||
AlertController,
|
||||
LoadingController,
|
||||
ModalController,
|
||||
} from '@ionic/angular'
|
||||
import {
|
||||
GenericInputComponent,
|
||||
GenericInputOptions,
|
||||
} from 'src/app/modals/generic-input/generic-input.component'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ConfigSpec } from 'src/app/pkg-config/config-types'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { v4 } from 'uuid'
|
||||
import { DataModel, DevData } from 'src/app/services/patch-db/data-model'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { TuiDestroyService } from '@taiga-ui/cdk'
|
||||
import { takeUntil } from 'rxjs/operators'
|
||||
|
||||
@Component({
|
||||
selector: 'developer-list',
|
||||
templateUrl: 'developer-list.page.html',
|
||||
styleUrls: ['developer-list.page.scss'],
|
||||
providers: [TuiDestroyService],
|
||||
})
|
||||
export class DeveloperListPage {
|
||||
devData: DevData = {}
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly api: ApiService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly destroy$: TuiDestroyService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly actionCtrl: ActionSheetController,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.patch
|
||||
.watch$('ui', 'dev')
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe(dd => {
|
||||
this.devData = dd
|
||||
})
|
||||
}
|
||||
|
||||
async openCreateProjectModal() {
|
||||
const projNumber = Object.keys(this.devData).length + 1
|
||||
const options: GenericInputOptions = {
|
||||
title: 'Add new project',
|
||||
message: 'Create a new dev project.',
|
||||
label: 'New project',
|
||||
useMask: false,
|
||||
placeholder: `Project ${projNumber}`,
|
||||
nullable: true,
|
||||
initialValue: `Project ${projNumber}`,
|
||||
buttonText: 'Save',
|
||||
submitFn: (value: string) => this.createProject(value),
|
||||
}
|
||||
|
||||
const modal = await this.modalCtrl.create({
|
||||
componentProps: { options },
|
||||
cssClass: 'alertlike-modal',
|
||||
presentingElement: await this.modalCtrl.getTop(),
|
||||
component: GenericInputComponent,
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
async presentAction(id: string, event: Event) {
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
const buttons: ActionSheetButton[] = [
|
||||
{
|
||||
text: 'Edit Name',
|
||||
icon: 'pencil',
|
||||
handler: () => {
|
||||
this.openEditNameModal(id)
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Delete',
|
||||
icon: 'trash',
|
||||
role: 'destructive',
|
||||
handler: () => {
|
||||
this.presentAlertDelete(id)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const action = await this.actionCtrl.create({
|
||||
header: this.devData[id].name,
|
||||
subHeader: 'Manage project',
|
||||
mode: 'ios',
|
||||
buttons,
|
||||
})
|
||||
|
||||
await action.present()
|
||||
}
|
||||
|
||||
async openEditNameModal(id: string) {
|
||||
const curName = this.devData[id].name
|
||||
const options: GenericInputOptions = {
|
||||
title: 'Edit Name',
|
||||
message: 'Edit the name of your project.',
|
||||
label: 'Name',
|
||||
useMask: false,
|
||||
placeholder: curName,
|
||||
nullable: true,
|
||||
initialValue: curName,
|
||||
buttonText: 'Save',
|
||||
submitFn: (value: string) => this.editName(id, value),
|
||||
}
|
||||
|
||||
const modal = await this.modalCtrl.create({
|
||||
componentProps: { options },
|
||||
cssClass: 'alertlike-modal',
|
||||
presentingElement: await this.modalCtrl.getTop(),
|
||||
component: GenericInputComponent,
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
async createProject(name: string) {
|
||||
// fail silently if duplicate project name
|
||||
if (
|
||||
Object.values(this.devData)
|
||||
.map(v => v.name)
|
||||
.includes(name)
|
||||
)
|
||||
return
|
||||
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Creating Project...',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
const id = v4()
|
||||
const config = yaml
|
||||
.dump(SAMPLE_CONFIG)
|
||||
.replace(/warning:/g, '# Optional\n warning:')
|
||||
|
||||
const def = { name, config, instructions: SAMPLE_INSTUCTIONS }
|
||||
await this.api.setDbValue<{
|
||||
name: string
|
||||
config: string
|
||||
instructions: string
|
||||
}>(['dev', id], def)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
async presentAlertDelete(id: string) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Caution',
|
||||
message: `Are you sure you want to delete this project?`,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Delete',
|
||||
handler: () => {
|
||||
this.delete(id)
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
async editName(id: string, newName: string) {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Saving...',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.api.setDbValue<string>(['dev', id, 'name'], newName)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
async delete(id: string) {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Removing Project...',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
const devDataToSave: DevData = JSON.parse(JSON.stringify(this.devData))
|
||||
delete devDataToSave[id]
|
||||
await this.api.setDbValue<DevData>(['dev'], devDataToSave)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const SAMPLE_INSTUCTIONS = `# Create Instructions using Markdown! :)`
|
||||
|
||||
const SAMPLE_CONFIG: ConfigSpec = {
|
||||
'sample-string': {
|
||||
type: 'string',
|
||||
name: 'Example String Input',
|
||||
nullable: false,
|
||||
masked: false,
|
||||
copyable: false,
|
||||
// optional
|
||||
description: 'Example description for required string input.',
|
||||
placeholder: 'Enter string value',
|
||||
pattern: '^[a-zA-Z0-9! _]+$',
|
||||
'pattern-description': 'Must be alphanumeric (may contain underscore).',
|
||||
},
|
||||
'sample-number': {
|
||||
type: 'number',
|
||||
name: 'Example Number Input',
|
||||
nullable: false,
|
||||
range: '[5,1000000]',
|
||||
integral: true,
|
||||
// optional
|
||||
warning: 'Example warning to display when changing this number value.',
|
||||
units: 'ms',
|
||||
description: 'Example description for optional number input.',
|
||||
placeholder: 'Enter number value',
|
||||
},
|
||||
'sample-boolean': {
|
||||
type: 'boolean',
|
||||
name: 'Example Boolean Toggle',
|
||||
// optional
|
||||
description: 'Example description for boolean toggle',
|
||||
default: true,
|
||||
},
|
||||
'sample-enum': {
|
||||
type: 'enum',
|
||||
name: 'Example Enum Select',
|
||||
values: ['red', 'blue', 'green'],
|
||||
'value-names': {
|
||||
red: 'Red',
|
||||
blue: 'Blue',
|
||||
green: 'Green',
|
||||
},
|
||||
// optional
|
||||
warning: 'Example warning to display when changing this enum value.',
|
||||
description: 'Example description for enum select',
|
||||
default: 'red',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { DeveloperMenuPage } from './developer-menu.page'
|
||||
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
|
||||
import { BackupReportPageModule } from 'src/app/modals/backup-report/backup-report.module'
|
||||
import { GenericFormPageModule } from 'src/app/modals/generic-form/generic-form.module'
|
||||
import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: DeveloperMenuPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
BadgeMenuComponentModule,
|
||||
BackupReportPageModule,
|
||||
GenericFormPageModule,
|
||||
FormsModule,
|
||||
MonacoEditorModule,
|
||||
SharedPipesModule,
|
||||
],
|
||||
declarations: [DeveloperMenuPage],
|
||||
})
|
||||
export class DeveloperMenuPageModule {}
|
||||
@@ -0,0 +1,51 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button defaultHref="/developer"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>{{ (projectData$ | async)?.name || '' }}</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button routerLink="manifest">View Manifest</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<ion-item
|
||||
*ngIf="projectData$ | async as projectData"
|
||||
button
|
||||
(click)="openBasicInfoModal(projectData)"
|
||||
>
|
||||
<ion-icon slot="start" name="information-circle-outline"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>Basic Info</h2>
|
||||
<p>Complete basic info for your package</p>
|
||||
</ion-label>
|
||||
<ion-icon
|
||||
slot="end"
|
||||
color="success"
|
||||
name="checkmark"
|
||||
*ngIf="!(projectData['basic-info'] | empty)"
|
||||
></ion-icon>
|
||||
<ion-icon
|
||||
slot="end"
|
||||
color="warning"
|
||||
name="remove-outline"
|
||||
*ngIf="projectData['basic-info'] | empty"
|
||||
></ion-icon>
|
||||
</ion-item>
|
||||
<ion-item button detail routerLink="instructions" routerDirection="forward">
|
||||
<ion-icon slot="start" name="list-outline"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>Instructions Generator</h2>
|
||||
<p>Create instructions and see how they will appear to the end user</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item button detail routerLink="config">
|
||||
<ion-icon slot="start" name="construct-outline"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>Config Generator</h2>
|
||||
<p>Edit the config with YAML and see it in real time</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,68 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { LoadingController, ModalController } from '@ionic/angular'
|
||||
import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page'
|
||||
import { BasicInfo, getBasicInfoSpec } from './form-info'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { getProjectId } from 'src/app/util/get-project-id'
|
||||
import { DataModel, DevProjectData } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'developer-menu',
|
||||
templateUrl: 'developer-menu.page.html',
|
||||
styleUrls: ['developer-menu.page.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DeveloperMenuPage {
|
||||
readonly projectId = getProjectId(this.route)
|
||||
readonly projectData$ = this.patch.watch$('ui', 'dev', this.projectId)
|
||||
|
||||
constructor(
|
||||
private readonly route: ActivatedRoute,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly api: ApiService,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
async openBasicInfoModal(data: DevProjectData) {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: GenericFormPage,
|
||||
componentProps: {
|
||||
title: 'Basic Info',
|
||||
spec: getBasicInfoSpec(data),
|
||||
buttons: [
|
||||
{
|
||||
text: 'Save',
|
||||
handler: (basicInfo: BasicInfo) => {
|
||||
this.saveBasicInfo(basicInfo)
|
||||
},
|
||||
isSubmit: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
async saveBasicInfo(basicInfo: BasicInfo) {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Saving...',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.api.setDbValue<BasicInfo>(
|
||||
['dev', this.projectId, 'basic-info'],
|
||||
basicInfo,
|
||||
)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
import { ConfigSpec } from 'src/app/pkg-config/config-types'
|
||||
import { DevProjectData } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
export type BasicInfo = {
|
||||
id: string
|
||||
title: string
|
||||
'service-version-number': string
|
||||
'release-notes': string
|
||||
license: string
|
||||
'wrapper-repo': string
|
||||
'upstream-repo'?: string
|
||||
'support-site'?: string
|
||||
'marketing-site'?: string
|
||||
description: {
|
||||
short: string
|
||||
long: string
|
||||
}
|
||||
}
|
||||
|
||||
export function getBasicInfoSpec(devData: DevProjectData): ConfigSpec {
|
||||
const basicInfo = devData['basic-info']
|
||||
return {
|
||||
id: {
|
||||
type: 'string',
|
||||
name: 'ID',
|
||||
description: 'The package identifier used by the OS',
|
||||
placeholder: 'e.g. bitcoind',
|
||||
nullable: false,
|
||||
masked: false,
|
||||
copyable: false,
|
||||
pattern: '^([a-z][a-z0-9]*)(-[a-z0-9]+)*$',
|
||||
'pattern-description': 'Must be kebab case',
|
||||
default: basicInfo?.id,
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
name: 'Service Name',
|
||||
description: 'A human readable service title',
|
||||
placeholder: 'e.g. Bitcoin Core',
|
||||
nullable: false,
|
||||
masked: false,
|
||||
copyable: false,
|
||||
default: basicInfo ? basicInfo.title : devData.name,
|
||||
},
|
||||
'service-version-number': {
|
||||
type: 'string',
|
||||
name: 'Service Version',
|
||||
description:
|
||||
'Service version - accepts up to four digits, where the last confirms to revisions necessary for StartOS - see documentation: https://github.com/Start9Labs/emver-rs. This value will change with each release of the service',
|
||||
placeholder: 'e.g. 0.1.2.3',
|
||||
nullable: false,
|
||||
masked: false,
|
||||
copyable: false,
|
||||
pattern: '^([0-9]+).([0-9]+).([0-9]+).([0-9]+)$',
|
||||
'pattern-description': 'Must be valid Emver version',
|
||||
default: basicInfo?.['service-version-number'],
|
||||
},
|
||||
description: {
|
||||
type: 'object',
|
||||
name: 'Marketplace Descriptions',
|
||||
spec: {
|
||||
short: {
|
||||
type: 'string',
|
||||
name: 'Short Description',
|
||||
description:
|
||||
'This is the first description visible to the user in the marketplace',
|
||||
nullable: false,
|
||||
masked: false,
|
||||
copyable: false,
|
||||
textarea: true,
|
||||
default: basicInfo?.description?.short,
|
||||
pattern: '^.{1,320}$',
|
||||
'pattern-description': 'Must be shorter than 320 characters',
|
||||
},
|
||||
long: {
|
||||
type: 'string',
|
||||
name: 'Long Description',
|
||||
description: `This description will display with additional details in the service's individual marketplace page`,
|
||||
nullable: false,
|
||||
masked: false,
|
||||
copyable: false,
|
||||
textarea: true,
|
||||
default: basicInfo?.description?.long,
|
||||
pattern: '^.{1,5000}$',
|
||||
'pattern-description': 'Must be shorter than 5000 characters',
|
||||
},
|
||||
},
|
||||
},
|
||||
'release-notes': {
|
||||
type: 'string',
|
||||
name: 'Release Notes',
|
||||
description:
|
||||
'Markdown supported release notes for this version of this service.',
|
||||
placeholder: 'e.g. Markdown _release notes_ for **Bitcoin Core**',
|
||||
nullable: false,
|
||||
masked: false,
|
||||
copyable: false,
|
||||
textarea: true,
|
||||
default: basicInfo?.['release-notes'],
|
||||
},
|
||||
license: {
|
||||
type: 'enum',
|
||||
name: 'License',
|
||||
values: [
|
||||
'gnu-agpl-v3',
|
||||
'gnu-gpl-v3',
|
||||
'gnu-lgpl-v3',
|
||||
'mozilla-public-license-2.0',
|
||||
'apache-license-2.0',
|
||||
'mit',
|
||||
'boost-software-license-1.0',
|
||||
'the-unlicense',
|
||||
'custom',
|
||||
],
|
||||
'value-names': {
|
||||
'gnu-agpl-v3': 'GNU AGPLv3',
|
||||
'gnu-gpl-v3': 'GNU GPLv3',
|
||||
'gnu-lgpl-v3': 'GNU LGPLv3',
|
||||
'mozilla-public-license-2.0': 'Mozilla Public License 2.0',
|
||||
'apache-license-2.0': 'Apache License 2.0',
|
||||
mit: 'mit',
|
||||
'boost-software-license-1.0': 'Boost Software License 1.0',
|
||||
'the-unlicense': 'The Unlicense',
|
||||
custom: 'Custom',
|
||||
},
|
||||
description: 'Example description for enum select',
|
||||
default: 'mit',
|
||||
},
|
||||
'wrapper-repo': {
|
||||
type: 'string',
|
||||
name: 'Wrapper Repo',
|
||||
description:
|
||||
'The Start9 wrapper repository URL for the package. This repo contains the manifest file (this), any scripts necessary for configuration, backups, actions, or health checks',
|
||||
placeholder: 'e.g. www.github.com/example',
|
||||
nullable: false,
|
||||
masked: false,
|
||||
copyable: false,
|
||||
default: basicInfo?.['wrapper-repo'],
|
||||
},
|
||||
'upstream-repo': {
|
||||
type: 'string',
|
||||
name: 'Upstream Repo',
|
||||
description: 'The original project repository URL',
|
||||
placeholder: 'e.g. www.github.com/example',
|
||||
nullable: true,
|
||||
masked: false,
|
||||
copyable: false,
|
||||
default: basicInfo?.['upstream-repo'],
|
||||
},
|
||||
'support-site': {
|
||||
type: 'string',
|
||||
name: 'Support Site',
|
||||
description: 'URL to the support site / channel for the project',
|
||||
placeholder: 'e.g. start9.com/support',
|
||||
nullable: true,
|
||||
masked: false,
|
||||
copyable: false,
|
||||
default: basicInfo?.['support-site'],
|
||||
},
|
||||
'marketing-site': {
|
||||
type: 'string',
|
||||
name: 'Marketing Site',
|
||||
description: 'URL to the marketing site / channel for the project',
|
||||
placeholder: 'e.g. start9.com',
|
||||
nullable: true,
|
||||
masked: false,
|
||||
copyable: false,
|
||||
default: basicInfo?.['marketing-site'],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { Routes, RouterModule } from '@angular/router'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
redirectTo: 'projects',
|
||||
pathMatch: 'full',
|
||||
},
|
||||
{
|
||||
path: 'projects',
|
||||
loadChildren: () =>
|
||||
import('./developer-list/developer-list.module').then(
|
||||
m => m.DeveloperListPageModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'projects/:projectId',
|
||||
loadChildren: () =>
|
||||
import('./developer-menu/developer-menu.module').then(
|
||||
m => m.DeveloperMenuPageModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'projects/:projectId/config',
|
||||
loadChildren: () =>
|
||||
import('./dev-config/dev-config.module').then(m => m.DevConfigPageModule),
|
||||
},
|
||||
{
|
||||
path: 'projects/:projectId/instructions',
|
||||
loadChildren: () =>
|
||||
import('./dev-instructions/dev-instructions.module').then(
|
||||
m => m.DevInstructionsPageModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'projects/:projectId/manifest',
|
||||
loadChildren: () =>
|
||||
import('./dev-manifest/dev-manifest.module').then(
|
||||
m => m.DevManifestPageModule,
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class DeveloperRoutingModule {}
|
||||
26
web/projects/ui/src/app/pages/home/home.module.ts
Normal file
26
web/projects/ui/src/app/pages/home/home.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { HomePage } from './home.page'
|
||||
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
|
||||
import { WidgetListComponentModule } from 'src/app/components/widget-list/widget-list.component.module'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: HomePage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
BadgeMenuComponentModule,
|
||||
WidgetListComponentModule,
|
||||
],
|
||||
declarations: [HomePage],
|
||||
})
|
||||
export class HomePageModule {}
|
||||
13
web/projects/ui/src/app/pages/home/home.page.html
Normal file
13
web/projects/ui/src/app/pages/home/home.page.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Home</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<badge-menu-button></badge-menu-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<div class="padding-top">
|
||||
<widget-list></widget-list>
|
||||
</div>
|
||||
</ion-content>
|
||||
9
web/projects/ui/src/app/pages/home/home.page.scss
Normal file
9
web/projects/ui/src/app/pages/home/home.page.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
.padding-top {
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
@media (min-width: 2000px) {
|
||||
.padding-top {
|
||||
padding-top: 10rem;
|
||||
}
|
||||
}
|
||||
8
web/projects/ui/src/app/pages/home/home.page.ts
Normal file
8
web/projects/ui/src/app/pages/home/home.page.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Component } from '@angular/core'
|
||||
|
||||
@Component({
|
||||
selector: 'home',
|
||||
templateUrl: 'home.page.html',
|
||||
styleUrls: ['home.page.scss'],
|
||||
})
|
||||
export class HomePage {}
|
||||
@@ -0,0 +1,106 @@
|
||||
<div class="center-container">
|
||||
<ng-container *ngIf="!caTrusted; else trusted">
|
||||
<ion-card id="untrusted" class="text-center">
|
||||
<ion-icon name="lock-closed-outline" class="wiz-icon"></ion-icon>
|
||||
<h1>Trust Your Root CA</h1>
|
||||
<p>
|
||||
Download and trust your server's Root Certificate Authority to establish
|
||||
a secure (HTTPS) connection. You will need to repeat this on every
|
||||
device you use to connect to your server.
|
||||
</p>
|
||||
<ol>
|
||||
<li>
|
||||
<b>Bookmark this page</b>
|
||||
- Save this page so you can access it later. You can also find the
|
||||
address in the
|
||||
<code>StartOS-info.html</code>
|
||||
file downloaded at the end of initial setup.
|
||||
</li>
|
||||
<li>
|
||||
<b>Download your server's Root CA</b>
|
||||
- Your server uses its Root CA to generate SSL/TLS certificates for
|
||||
itself and installed services. These certificates are then used to
|
||||
encrypt network traffic with your client devices.
|
||||
<br />
|
||||
<ion-button
|
||||
strong
|
||||
size="small"
|
||||
shape="round"
|
||||
color="tertiary"
|
||||
(click)="download()"
|
||||
>
|
||||
Download
|
||||
<ion-icon slot="end" name="download-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</li>
|
||||
<li>
|
||||
<b>Trust your server's Root CA</b>
|
||||
- Follow instructions for your OS. By trusting your server's Root CA,
|
||||
your device can verify the authenticity of encrypted communications
|
||||
with your server.
|
||||
<br />
|
||||
<ion-button
|
||||
strong
|
||||
size="small"
|
||||
shape="round"
|
||||
color="primary"
|
||||
href="https://docs.start9.com/0.3.5.x/user-manual/trust-ca#establishing-trust"
|
||||
target="_blank"
|
||||
noreferrer
|
||||
>
|
||||
View Instructions
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</li>
|
||||
<li>
|
||||
<b>Test</b>
|
||||
- Refresh the page. If refreshing the page does not work, you may need
|
||||
to quit and re-open your browser, then revisit this page.
|
||||
<br />
|
||||
<ion-button
|
||||
strong
|
||||
size="small"
|
||||
shape="round"
|
||||
class="refresh"
|
||||
(click)="refresh()"
|
||||
>
|
||||
Refresh
|
||||
<ion-icon slot="end" name="refresh"></ion-icon>
|
||||
</ion-button>
|
||||
</li>
|
||||
</ol>
|
||||
<ion-button fill="clear" (click)="launchHttps()" [disabled]="caTrusted">
|
||||
Skip
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<span class="skip_detail">(not recommended)</span>
|
||||
</ion-card>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #trusted>
|
||||
<ion-card id="trusted" class="text-center">
|
||||
<ion-icon
|
||||
name="shield-checkmark-outline"
|
||||
class="wiz-icon"
|
||||
color="success"
|
||||
></ion-icon>
|
||||
<h1>Root CA Trusted!</h1>
|
||||
<p>
|
||||
You have successfully trusted your server's Root CA and may now log in
|
||||
securely.
|
||||
</p>
|
||||
<ion-button strong (click)="launchHttps()" color="tertiary" shape="round">
|
||||
Go to login
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-card>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
<a
|
||||
id="install-cert"
|
||||
href="/eos/local.crt"
|
||||
[download]="
|
||||
config.isLocal() ? document.location.hostname + '.crt' : 'startos.crt'
|
||||
"
|
||||
></a>
|
||||
@@ -0,0 +1,83 @@
|
||||
#trusted {
|
||||
max-width: 40%;
|
||||
}
|
||||
|
||||
#untrusted {
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.center-container {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
ion-card {
|
||||
color: var(--ion-color-dark);
|
||||
background: #414141;
|
||||
box-shadow: 0 4px 4px rgba(17, 17, 17, 0.144);
|
||||
border-radius: 35px;
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
|
||||
h1 {
|
||||
font-weight: bold;
|
||||
font-size: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 21px;
|
||||
line-height: 25px;
|
||||
margin-bottom: 30px;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
ol {
|
||||
font-size: 17px;
|
||||
line-height: 25px;
|
||||
text-align: left;
|
||||
|
||||
li {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
ion-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.refresh {
|
||||
--background: var(--ion-color-success-shade);
|
||||
}
|
||||
|
||||
.wiz-icon {
|
||||
font-size: 64px;
|
||||
}
|
||||
|
||||
.skip_detail {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
margin-top: -13px;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
#trusted, #untrusted {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 701px) and (max-width: 1200px) {
|
||||
#trusted, #untrusted {
|
||||
max-width: 75%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Component, Inject } from '@angular/core'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { RELATIVE_URL } from '@start9labs/shared'
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
import { WINDOW } from '@ng-web-apis/common'
|
||||
|
||||
@Component({
|
||||
selector: 'ca-wizard',
|
||||
templateUrl: './ca-wizard.component.html',
|
||||
styleUrls: ['./ca-wizard.component.scss'],
|
||||
})
|
||||
export class CAWizardComponent {
|
||||
caTrusted = false
|
||||
|
||||
constructor(
|
||||
private readonly api: ApiService,
|
||||
public readonly config: ConfigService,
|
||||
@Inject(RELATIVE_URL) private readonly relativeUrl: string,
|
||||
@Inject(DOCUMENT) public readonly document: Document,
|
||||
@Inject(WINDOW) private readonly windowRef: Window,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.testHttps().catch(e =>
|
||||
console.warn('Failed Https connection attempt'),
|
||||
)
|
||||
}
|
||||
|
||||
download() {
|
||||
this.document.getElementById('install-cert')?.click()
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.document.location.reload()
|
||||
}
|
||||
|
||||
launchHttps() {
|
||||
const host = this.config.getHost()
|
||||
this.windowRef.open(`https://${host}`, '_self')
|
||||
}
|
||||
|
||||
private async testHttps() {
|
||||
const url = `https://${this.document.location.host}${this.relativeUrl}`
|
||||
await this.api.echo({ message: 'ping' }, url).then(() => {
|
||||
this.caTrusted = true
|
||||
})
|
||||
}
|
||||
}
|
||||
30
web/projects/ui/src/app/pages/login/login.module.ts
Normal file
30
web/projects/ui/src/app/pages/login/login.module.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
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 { CAWizardComponent } from './ca-wizard/ca-wizard.component'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
import { TuiHintModule, TuiTooltipModule } from '@taiga-ui/core'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: LoginPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
SharedPipesModule,
|
||||
RouterModule.forChild(routes),
|
||||
TuiTooltipModule,
|
||||
TuiHintModule,
|
||||
],
|
||||
declarations: [LoginPage, CAWizardComponent],
|
||||
})
|
||||
export class LoginPageModule {}
|
||||
93
web/projects/ui/src/app/pages/login/login.page.html
Normal file
93
web/projects/ui/src/app/pages/login/login.page.html
Normal file
@@ -0,0 +1,93 @@
|
||||
<ion-content class="content">
|
||||
<!-- Local HTTP -->
|
||||
<ng-container *ngIf="config.isLanHttp(); else notLanHttp">
|
||||
<ca-wizard></ca-wizard>
|
||||
</ng-container>
|
||||
|
||||
<!-- not Local HTTP -->
|
||||
<ng-template #notLanHttp>
|
||||
<div *ngIf="config.isTorHttp()" class="banner">
|
||||
<ion-item color="warning">
|
||||
<ion-icon slot="start" name="warning-outline"></ion-icon>
|
||||
<ion-label>
|
||||
<h2 style="font-weight: bold">Http detected</h2>
|
||||
<p style="font-weight: 600">
|
||||
Tor is faster over https. Your Root CA must be trusted.
|
||||
<a
|
||||
href="https://docs.start9.com/0.3.5.x/user-manual/trust-ca"
|
||||
target="_blank"
|
||||
noreferrer
|
||||
style="color: black"
|
||||
>
|
||||
View instructions
|
||||
</a>
|
||||
</p>
|
||||
</ion-label>
|
||||
<ion-button slot="end" color="light" (click)="launchHttps()">
|
||||
Open Https
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
</div>
|
||||
|
||||
<ion-grid class="grid">
|
||||
<ion-row class="row">
|
||||
<ion-col>
|
||||
<ion-card>
|
||||
<img
|
||||
alt="StartOS Icon"
|
||||
class="header-icon"
|
||||
src="assets/img/icon.png"
|
||||
/>
|
||||
<ion-card-header>
|
||||
<ion-card-title class="title">Login to StartOS</ion-card-title>
|
||||
</ion-card-header>
|
||||
<ion-card-content class="ion-margin">
|
||||
<form (submit)="submit()">
|
||||
<ion-item color="dark" fill="solid">
|
||||
<ion-icon
|
||||
slot="start"
|
||||
size="small"
|
||||
color="base"
|
||||
name="key-outline"
|
||||
style="margin-right: 16px"
|
||||
></ion-icon>
|
||||
<ion-input
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
[type]="unmasked ? 'text' : 'password'"
|
||||
[(ngModel)]="password"
|
||||
(ionChange)="error = ''"
|
||||
></ion-input>
|
||||
<ion-button
|
||||
slot="end"
|
||||
fill="clear"
|
||||
color="dark"
|
||||
(click)="unmasked = !unmasked"
|
||||
>
|
||||
<ion-icon
|
||||
slot="icon-only"
|
||||
size="small"
|
||||
[name]="unmasked ? 'eye-off-outline' : 'eye-outline'"
|
||||
></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
<p class="error ion-text-center">
|
||||
<ion-text color="danger">{{ error }}</ion-text>
|
||||
</p>
|
||||
<ion-button
|
||||
class="login-button"
|
||||
type="submit"
|
||||
expand="block"
|
||||
color="tertiary"
|
||||
>
|
||||
Login
|
||||
</ion-button>
|
||||
</form>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ng-template>
|
||||
</ion-content>
|
||||
76
web/projects/ui/src/app/pages/login/login.page.scss
Normal file
76
web/projects/ui/src/app/pages/login/login.page.scss
Normal file
@@ -0,0 +1,76 @@
|
||||
.content {
|
||||
--background: #333333;
|
||||
}
|
||||
|
||||
.grid {
|
||||
height: 100%;
|
||||
max-width: 540px;
|
||||
}
|
||||
|
||||
.row {
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.banner {
|
||||
position: absolute;
|
||||
padding: 20px;
|
||||
width: 100%;
|
||||
display: inline-block;
|
||||
|
||||
ion-item {
|
||||
max-width: 800px;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
ion-card {
|
||||
background: #414141;
|
||||
box-shadow: 0 4px 4px rgba(17, 17, 17, 0.144);
|
||||
border-radius: 35px;
|
||||
min-height: 16rem;
|
||||
contain: unset;
|
||||
overflow: unset;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
ion-item {
|
||||
--background: transparent;
|
||||
--border-radius: 0px;
|
||||
}
|
||||
|
||||
.title {
|
||||
padding-top: 55px;
|
||||
color: #e0e0e0;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
&-icon {
|
||||
width: 100px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-left: -50px;
|
||||
top: -17%;
|
||||
z-index: 100;
|
||||
}
|
||||
}
|
||||
|
||||
.login-button {
|
||||
height: 45px;
|
||||
width: 120px;
|
||||
--border-radius: 50px;
|
||||
margin: 0 auto;
|
||||
margin-top: 27px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.item-interactive {
|
||||
--highlight-background: #5260ff !important;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: block;
|
||||
padding-top: 4px;
|
||||
}
|
||||
64
web/projects/ui/src/app/pages/login/login.page.ts
Normal file
64
web/projects/ui/src/app/pages/login/login.page.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Component, Inject } from '@angular/core'
|
||||
import { getPlatforms, LoadingController } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { AuthService } from 'src/app/services/auth.service'
|
||||
import { Router } from '@angular/router'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
import { WINDOW } from '@ng-web-apis/common'
|
||||
|
||||
@Component({
|
||||
selector: 'login',
|
||||
templateUrl: './login.page.html',
|
||||
styleUrls: ['./login.page.scss'],
|
||||
})
|
||||
export class LoginPage {
|
||||
password = ''
|
||||
unmasked = false
|
||||
error = ''
|
||||
|
||||
constructor(
|
||||
private readonly router: Router,
|
||||
private readonly authService: AuthService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly api: ApiService,
|
||||
public readonly config: ConfigService,
|
||||
@Inject(DOCUMENT) public readonly document: Document,
|
||||
@Inject(WINDOW) private readonly windowRef: Window,
|
||||
) {}
|
||||
|
||||
launchHttps() {
|
||||
const host = this.config.getHost()
|
||||
this.windowRef.open(`https://${host}`, '_self')
|
||||
}
|
||||
|
||||
async submit() {
|
||||
this.error = ''
|
||||
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Logging in...',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
document.cookie = ''
|
||||
if (this.password.length > 64) {
|
||||
this.error = 'Password must be less than 65 characters'
|
||||
return
|
||||
}
|
||||
await this.api.login({
|
||||
password: this.password,
|
||||
metadata: { platforms: getPlatforms() },
|
||||
})
|
||||
|
||||
this.password = ''
|
||||
this.authService.setVerified()
|
||||
this.router.navigate([''], { replaceUrl: true })
|
||||
} catch (e: any) {
|
||||
// code 7 is for incorrect password
|
||||
this.error = e.code === 7 ? 'Invalid Password' : e.message
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { Routes, RouterModule } from '@angular/router'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import {
|
||||
SharedPipesModule,
|
||||
EmverPipesModule,
|
||||
ResponsiveColModule,
|
||||
} from '@start9labs/shared'
|
||||
import {
|
||||
FilterPackagesPipeModule,
|
||||
CategoriesModule,
|
||||
ItemModule,
|
||||
SearchModule,
|
||||
SkeletonModule,
|
||||
} from '@start9labs/marketplace'
|
||||
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
|
||||
import { MarketplaceStatusModule } from '../marketplace-status/marketplace-status.module'
|
||||
import { MarketplaceListPage } from './marketplace-list.page'
|
||||
import { MarketplaceSettingsPageModule } from 'src/app/modals/marketplace-settings/marketplace-settings.module'
|
||||
import { StoreIconComponentModule } from 'src/app/components/store-icon/store-icon.component.module'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: MarketplaceListPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
FormsModule,
|
||||
RouterModule.forChild(routes),
|
||||
SharedPipesModule,
|
||||
EmverPipesModule,
|
||||
FilterPackagesPipeModule,
|
||||
MarketplaceStatusModule,
|
||||
BadgeMenuComponentModule,
|
||||
ItemModule,
|
||||
CategoriesModule,
|
||||
SearchModule,
|
||||
SkeletonModule,
|
||||
MarketplaceSettingsPageModule,
|
||||
StoreIconComponentModule,
|
||||
ResponsiveColModule,
|
||||
],
|
||||
declarations: [MarketplaceListPage],
|
||||
exports: [MarketplaceListPage],
|
||||
})
|
||||
export class MarketplaceListPageModule {}
|
||||
@@ -0,0 +1,89 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start" *ngIf="back">
|
||||
<ion-back-button></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Marketplace</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<badge-menu-button></badge-menu-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding with-widgets">
|
||||
<ng-container *ngIf="details$ | async as details">
|
||||
<ion-item [color]="details.color">
|
||||
<ion-icon slot="start" name="information-circle-outline"></ion-icon>
|
||||
<ion-label>
|
||||
<h2 style="font-weight: 600" [innerHTML]="details.description"></h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col>
|
||||
<div class="heading">
|
||||
<store-icon
|
||||
class="icon"
|
||||
size="80px"
|
||||
[url]="details.url"
|
||||
></store-icon>
|
||||
<h1 class="montserrat">{{ details.name }}</h1>
|
||||
</div>
|
||||
<ion-button fill="clear" (click)="presentModalMarketplaceSettings()">
|
||||
<ion-icon slot="start" name="repeat-outline"></ion-icon>
|
||||
Change
|
||||
</ion-button>
|
||||
<marketplace-search [(query)]="query"></marketplace-search>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
<ion-row class="ion-align-items-center">
|
||||
<ion-col>
|
||||
<ng-container *ngIf="store$ | async as store; else loading">
|
||||
<marketplace-categories
|
||||
[categories]="store.categories"
|
||||
[category]="query ? '' : category"
|
||||
(categoryChange)="onCategoryChange($event)"
|
||||
></marketplace-categories>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<ion-grid
|
||||
*ngIf="store.packages | filterPackages: query:category as filtered"
|
||||
>
|
||||
<ng-container *ngIf="filtered.length; else empty">
|
||||
<ion-row *ngIf="localPkgs$ | async as localPkgs">
|
||||
<ion-col
|
||||
*ngFor="let pkg of filtered"
|
||||
responsiveCol
|
||||
sizeXs="12"
|
||||
sizeSm="12"
|
||||
sizeMd="6"
|
||||
>
|
||||
<marketplace-item [pkg]="pkg">
|
||||
<marketplace-status
|
||||
class="status"
|
||||
[version]="pkg.manifest.version"
|
||||
[localPkg]="localPkgs[pkg.manifest.id]"
|
||||
></marketplace-status>
|
||||
</marketplace-item>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #empty>
|
||||
<div class="ion-padding">
|
||||
<h2>No results</h2>
|
||||
</div>
|
||||
</ng-template>
|
||||
</ion-grid>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #loading>
|
||||
<marketplace-skeleton></marketplace-skeleton>
|
||||
</ng-template>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ng-container>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,40 @@
|
||||
.heading {
|
||||
margin-top: 32px;
|
||||
h1 {
|
||||
font-size: 42px;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
display: inline-block;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
margin: 24px;
|
||||
}
|
||||
|
||||
.ion-padding {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.description {
|
||||
|
||||
ion-icon {
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
@media (min-width: 1000px) {
|
||||
ion-label {
|
||||
::ng-deep p {
|
||||
font-size: 1.1rem;
|
||||
line-height: 25px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
|
||||
import { ActivatedRoute } from '@angular/router'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { AbstractMarketplaceService } from '@start9labs/marketplace'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { map } from 'rxjs'
|
||||
import { MarketplaceSettingsPage } from 'src/app/modals/marketplace-settings/marketplace-settings.page'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { MarketplaceService } from 'src/app/services/marketplace.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-list',
|
||||
templateUrl: 'marketplace-list.page.html',
|
||||
styleUrls: ['./marketplace-list.page.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class MarketplaceListPage {
|
||||
readonly back = !!this.route.snapshot.queryParamMap.get('back')
|
||||
|
||||
readonly store$ = this.marketplaceService.getSelectedStore$().pipe(
|
||||
map(({ info, packages }) => {
|
||||
const categories = new Set<string>()
|
||||
if (info.categories.includes('featured')) categories.add('featured')
|
||||
info.categories.forEach(c => categories.add(c))
|
||||
categories.add('all')
|
||||
|
||||
return { categories: Array.from(categories), packages }
|
||||
}),
|
||||
)
|
||||
|
||||
readonly localPkgs$ = this.patch.watch$('package-data')
|
||||
|
||||
readonly details$ = this.marketplaceService.getSelectedHost$().pipe(
|
||||
map(({ url, name }) => {
|
||||
const { start9, community } = this.config.marketplace
|
||||
let color: string
|
||||
let description: string
|
||||
|
||||
if (url === start9) {
|
||||
color = 'success'
|
||||
description =
|
||||
'Services from this registry are packaged and maintained by the Start9 team. If you experience an issue or have a question related to a service from this registry, one of our dedicated support staff will be happy to assist you.'
|
||||
} else if (url === community) {
|
||||
color = 'tertiary'
|
||||
description =
|
||||
'Services from this registry are packaged and maintained by members of the Start9 community. <b>Install at your own risk</b>. If you experience an issue or have a question related to a service in this marketplace, please reach out to the package developer for assistance.'
|
||||
} else if (url.includes('beta')) {
|
||||
color = 'warning'
|
||||
description =
|
||||
'Services from this registry are undergoing <b>beta</b> testing and may contain bugs. <b>Install at your own risk</b>.'
|
||||
} else if (url.includes('alpha')) {
|
||||
color = 'danger'
|
||||
description =
|
||||
'Services from this registry are undergoing <b>alpha</b> testing. They are expected to contain bugs and could damage your system. <b>Install at your own risk</b>.'
|
||||
} else {
|
||||
// alt marketplace
|
||||
color = 'warning'
|
||||
description =
|
||||
'This is a Custom Registry. Start9 cannot verify the integrity or functionality of services from this registry, and they could damage your system. <b>Install at your own risk</b>.'
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
url,
|
||||
color,
|
||||
description,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
constructor(
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
@Inject(AbstractMarketplaceService)
|
||||
private readonly marketplaceService: MarketplaceService,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly config: ConfigService,
|
||||
private readonly route: ActivatedRoute,
|
||||
) {}
|
||||
|
||||
category = 'featured'
|
||||
query = ''
|
||||
|
||||
async presentModalMarketplaceSettings() {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: MarketplaceSettingsPage,
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
onCategoryChange(category: string): void {
|
||||
this.category = category
|
||||
this.query = ''
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { Routes, RouterModule } from '@angular/router'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
pathMatch: 'full',
|
||||
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('./release-notes/release-notes.module').then(
|
||||
m => m.ReleaseNotesPageModule,
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class MarketplaceRoutingModule {}
|
||||
@@ -0,0 +1,46 @@
|
||||
<div class="action-buttons">
|
||||
<ion-button
|
||||
*ngIf="localPkg"
|
||||
expand="block"
|
||||
color="primary"
|
||||
[routerLink]="['/services', pkg.manifest.id]"
|
||||
>
|
||||
View Installed
|
||||
</ion-button>
|
||||
<ng-container *ngIf="localPkg; else install">
|
||||
<ng-container *ngIf="localPkg.state === PackageState.Installed">
|
||||
<ion-button
|
||||
*ngIf="(localVersion | compareEmver: pkg.manifest.version) === -1"
|
||||
expand="block"
|
||||
color="success"
|
||||
(click)="tryInstall()"
|
||||
>
|
||||
Update
|
||||
</ion-button>
|
||||
<ion-button
|
||||
*ngIf="(localVersion | compareEmver: pkg.manifest.version) === 1"
|
||||
expand="block"
|
||||
color="warning"
|
||||
(click)="tryInstall()"
|
||||
>
|
||||
Downgrade
|
||||
</ion-button>
|
||||
<ng-container *ngIf="showDevTools$ | async">
|
||||
<ion-button
|
||||
*ngIf="(localVersion | compareEmver: pkg.manifest.version) === 0"
|
||||
expand="block"
|
||||
color="success"
|
||||
(click)="tryInstall()"
|
||||
>
|
||||
Reinstall
|
||||
</ion-button>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #install>
|
||||
<ion-button expand="block" color="success" (click)="tryInstall()">
|
||||
Install
|
||||
</ion-button>
|
||||
</ng-template>
|
||||
</div>
|
||||
@@ -0,0 +1,233 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { AlertController, LoadingController } from '@ionic/angular'
|
||||
import {
|
||||
AbstractMarketplaceService,
|
||||
MarketplacePkg,
|
||||
} from '@start9labs/marketplace'
|
||||
import {
|
||||
Emver,
|
||||
ErrorToastService,
|
||||
isEmptyObject,
|
||||
sameUrl,
|
||||
} from '@start9labs/shared'
|
||||
import {
|
||||
DataModel,
|
||||
PackageDataEntry,
|
||||
PackageState,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { ClientStorageService } from 'src/app/services/client-storage.service'
|
||||
import { MarketplaceService } from 'src/app/services/marketplace.service'
|
||||
import { hasCurrentDeps } from 'src/app/util/has-deps'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { getAllPackages } from 'src/app/util/get-package-data'
|
||||
import { firstValueFrom } from 'rxjs'
|
||||
import { dryUpdate } from 'src/app/util/dry-update'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-show-controls',
|
||||
templateUrl: 'marketplace-show-controls.component.html',
|
||||
styleUrls: ['./marketplace-show-controls.page.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class MarketplaceShowControlsComponent {
|
||||
@Input()
|
||||
url?: string
|
||||
|
||||
@Input()
|
||||
pkg!: MarketplacePkg
|
||||
|
||||
@Input()
|
||||
localPkg!: PackageDataEntry | null
|
||||
|
||||
readonly showDevTools$ = this.ClientStorageService.showDevTools$
|
||||
|
||||
readonly PackageState = PackageState
|
||||
|
||||
constructor(
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly ClientStorageService: ClientStorageService,
|
||||
@Inject(AbstractMarketplaceService)
|
||||
private readonly marketplaceService: MarketplaceService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly emver: Emver,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
get localVersion(): string {
|
||||
return this.localPkg?.manifest.version || ''
|
||||
}
|
||||
|
||||
async tryInstall() {
|
||||
const currentMarketplace = await firstValueFrom(
|
||||
this.marketplaceService.getSelectedHost$(),
|
||||
)
|
||||
const url = this.url || currentMarketplace.url
|
||||
|
||||
if (!this.localPkg) {
|
||||
this.alertInstall(url)
|
||||
} else {
|
||||
const originalUrl = this.localPkg.installed?.['marketplace-url']
|
||||
|
||||
if (!sameUrl(url, originalUrl)) {
|
||||
const proceed = await this.presentAlertDifferentMarketplace(
|
||||
url,
|
||||
originalUrl,
|
||||
)
|
||||
if (!proceed) return
|
||||
}
|
||||
|
||||
if (
|
||||
this.emver.compare(this.localVersion, this.pkg.manifest.version) !==
|
||||
0 &&
|
||||
hasCurrentDeps(this.localPkg)
|
||||
) {
|
||||
this.dryInstall(url)
|
||||
} else {
|
||||
this.install(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async presentAlertDifferentMarketplace(
|
||||
url: string,
|
||||
originalUrl: string | null | undefined,
|
||||
): Promise<boolean> {
|
||||
const marketplaces = await firstValueFrom(
|
||||
this.patch.watch$('ui', 'marketplace'),
|
||||
)
|
||||
|
||||
const name: string = marketplaces['known-hosts'][url]?.name || url
|
||||
|
||||
let originalName: string | undefined
|
||||
if (originalUrl) {
|
||||
originalName =
|
||||
marketplaces['known-hosts'][originalUrl]?.name || originalUrl
|
||||
}
|
||||
|
||||
return new Promise(async resolve => {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Warning',
|
||||
message: `This service was originally ${
|
||||
originalName ? 'installed from ' + originalName : 'side loaded'
|
||||
}, but you are currently connected to ${name}. To install from ${name} anyway, click "Continue".`,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
handler: () => {
|
||||
resolve(false)
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Continue',
|
||||
handler: () => {
|
||||
resolve(true)
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
cssClass: 'alert-warning-message',
|
||||
})
|
||||
|
||||
await alert.present()
|
||||
})
|
||||
}
|
||||
|
||||
private async dryInstall(url: string) {
|
||||
const breakages = dryUpdate(
|
||||
this.pkg.manifest,
|
||||
await getAllPackages(this.patch),
|
||||
this.emver,
|
||||
)
|
||||
|
||||
if (isEmptyObject(breakages)) {
|
||||
this.install(url)
|
||||
} else {
|
||||
const proceed = await this.presentAlertBreakages(breakages)
|
||||
if (proceed) {
|
||||
this.install(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async alertInstall(url: string) {
|
||||
const installAlert = this.pkg.manifest.alerts.install
|
||||
|
||||
if (!installAlert) return this.install(url)
|
||||
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Alert',
|
||||
message: installAlert,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Install',
|
||||
handler: () => {
|
||||
this.install(url)
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
private async install(url: string) {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Beginning Install...',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
const { id, version } = this.pkg.manifest
|
||||
|
||||
try {
|
||||
await this.marketplaceService.installPackage(id, version, url)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
private async presentAlertBreakages(breakages: string[]): Promise<boolean> {
|
||||
let message: string =
|
||||
'As a result of this update, the following services will no longer work properly and may crash:<ul>'
|
||||
const bullets = breakages.map(title => `<li><b>${title}</b></li>`)
|
||||
message = `${message}${bullets.join('')}</ul>`
|
||||
|
||||
return new Promise(async resolve => {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Warning',
|
||||
message,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
handler: () => {
|
||||
resolve(false)
|
||||
},
|
||||
},
|
||||
{
|
||||
text: 'Continue',
|
||||
handler: () => {
|
||||
resolve(true)
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
cssClass: 'alert-warning-message',
|
||||
})
|
||||
|
||||
await alert.present()
|
||||
})
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user