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:
Matt Hill
2023-11-13 14:22:23 -07:00
committed by GitHub
parent 871f78b570
commit 86567e7fa5
968 changed files with 812 additions and 6672 deletions

View File

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

View File

@@ -0,0 +1,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 {}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,28 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { 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 {}

View File

@@ -0,0 +1,26 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [defaultHref]="'/services/' + pkgId"></ion-back-button>
</ion-buttons>
<ion-title>Interfaces</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top 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>

View File

@@ -0,0 +1,3 @@
p {
font-family: 'Courier New';
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,9 @@
.welcome-header {
padding-bottom: 1rem;
text-align: center;
h1 {
font-weight: bold;
font-size: 2rem;
}
}

View File

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

View File

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

View File

@@ -0,0 +1,24 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { 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 {}

View File

@@ -0,0 +1,8 @@
<logs
[fetchLogs]="fetchLogs()"
[followLogs]="followLogs()"
[defaultBack]="'/services/' + pkgId"
[context]="pkgId"
pageTitle="Service Logs"
class="ion-page"
></logs>

View File

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

View File

@@ -0,0 +1,26 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppMetricsPage } from './app-metrics.page'
import { 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 {}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,29 @@
<ion-item-divider>Dependencies</ion-item-divider>
<!-- dependencies are a subset of the pkg.manifest.dependencies that are currently required as determined by the service config -->
<ion-item button *ngFor="let dep of dependencies" (click)="dep.action()">
<ion-thumbnail slot="start">
<img [src]="dep.icon" alt="" />
</ion-thumbnail>
<ion-label>
<h2 class="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>

View File

@@ -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[] = []
}

View File

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

View File

@@ -0,0 +1,13 @@
.less-large {
font-size: 18px !important;
}
.header {
display: flex;
}
.logo {
height: 54px;
width: 54px;
margin: 0 16px;
}

View File

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

View File

@@ -0,0 +1,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>

View File

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

View File

@@ -0,0 +1,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
}
}

View File

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

View File

@@ -0,0 +1,6 @@
.highlighted {
* {
color: var(--ion-color-dark);
font-weight: bold;
}
}

View File

@@ -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[] = []
}

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import {
InstallProgress,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { ProgressData } from 'src/app/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'
}
}

View File

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

View File

@@ -0,0 +1,9 @@
.label {
overflow: visible;
}
.action-button {
margin: 12px 20px 10px 0;
min-height: 42px;
min-width: 140px;
}

View File

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

View File

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

View File

@@ -0,0 +1,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'])
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -0,0 +1,9 @@
.padding-top {
padding-top: 2rem;
}
@media (min-width: 2000px) {
.padding-top {
padding-top: 10rem;
}
}

View File

@@ -0,0 +1,8 @@
import { Component } from '@angular/core'
@Component({
selector: 'home',
templateUrl: 'home.page.html',
styleUrls: ['home.page.scss'],
})
export class HomePage {}

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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