Merge branch 'next' of github.com:Start9Labs/start-os into rebase/integration/refactors

This commit is contained in:
Aiden McClelland
2023-09-28 13:27:41 -06:00
167 changed files with 20082 additions and 7978 deletions

View File

@@ -38,12 +38,13 @@ export class HttpService {
async rpcRequest<T>(
opts: RPCOptions,
fullUrl?: string,
): Promise<LocalHttpResponse<RPCResponse<T>>> {
const { method, headers, params, timeout } = opts
return this.httpRequest<RPCResponse<T>>({
method: Method.POST,
url: this.relativeUrl,
url: fullUrl || this.relativeUrl,
headers,
body: { method, params },
timeout,

View File

@@ -15,7 +15,9 @@ export type WorkspaceConfig = {
community: 'https://community-registry.start9.com/'
}
mocks: {
maskAs: 'tor' | 'lan'
maskAs: 'tor' | 'local' | 'localhost'
// enables local development in secure mode
maskAsHttps: boolean
skipStartupAlerts: boolean
}
}

View File

@@ -3,8 +3,10 @@ import { RouterModule, Routes } from '@angular/router'
import { CommonModule } from '@angular/common'
import { FormsModule } from '@angular/forms'
import { IonicModule } from '@ionic/angular'
import { SharedPipesModule } from '@start9labs/shared'
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 = [
{
@@ -20,7 +22,9 @@ const routes: Routes = [
IonicModule,
SharedPipesModule,
RouterModule.forChild(routes),
TuiTooltipModule,
TuiHintModule,
],
declarations: [LoginPage],
declarations: [LoginPage, CAWizardComponent],
})
export class LoginPageModule {}

View File

@@ -1,54 +1,81 @@
<ion-content class="content">
<ion-grid class="grid">
<ion-row class="row">
<ion-col>
<img src="assets/img/logo.png" alt="Start9" class="logo" />
<!-- Local HTTP -->
<ng-container *ngIf="config.isLocalHttp(); else notLanHttp">
<ca-wizard></ca-wizard>
</ng-container>
<ion-card class="card">
<ion-card-header>
<ion-card-title class="title">StartOS Login</ion-card-title>
</ion-card-header>
<!-- 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.</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-card-content class="ion-margin">
<form class="form" (submit)="submit()">
<ion-item-group>
<ion-item color="dark">
<ion-icon
slot="start"
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 fill="clear" color="light" (click)="toggleMask()">
<ion-grid class="grid">
<ion-row class="row">
<ion-col>
<img src="assets/img/logo.png" alt="Start9" class="logo" />
<ion-card class="card">
<ion-card-header>
<ion-card-title class="title">StartOS Login</ion-card-title>
</ion-card-header>
<ion-card-content class="ion-margin">
<form class="form" (submit)="submit()">
<ion-item-group>
<ion-item color="dark" class="login-item">
<ion-icon
slot="icon-only"
size="small"
[name]="unmasked ? 'eye-off-outline' : 'eye-outline'"
slot="start"
name="key-outline"
style="margin-right: 16px"
></ion-icon>
</ion-button>
</ion-item>
</ion-item-group>
<ion-button
class="login-button"
type="submit"
expand="block"
color="tertiary"
>
Login
</ion-button>
</form>
<p class="error">
<ion-text color="danger">{{ error }}</ion-text>
</p>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</ion-grid>
<ion-input
name="password"
placeholder="Password"
[type]="unmasked ? 'text' : 'password'"
[(ngModel)]="password"
(ionChange)="error = ''"
maxlength="64"
></ion-input>
<ion-button
fill="clear"
color="light"
(click)="unmasked = !unmasked"
>
<ion-icon
slot="icon-only"
size="small"
[name]="unmasked ? 'eye-off-outline' : 'eye-outline'"
></ion-icon>
</ion-button>
</ion-item>
</ion-item-group>
<ion-button
class="login-button side-button"
type="submit"
expand="block"
color="tertiary"
>
Login
</ion-button>
</form>
<p class="error">
<ion-text color="danger">{{ error }}</ion-text>
</p>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</ion-grid>
</ng-template>
</ion-content>

View File

@@ -18,7 +18,7 @@
}
.row {
height: 90%;
height: 100%;
align-items: center;
text-align: center;
}
@@ -34,18 +34,30 @@
padding-top: 4px;
}
ion-button {
.banner {
position: absolute;
padding: 20px;
width: 100%;
display: inline-block;
ion-item {
max-width: 800px;
margin: auto;
}
}
.side-button {
--border-radius: 0 4px 4px 0;
}
ion-item {
.login-item {
--border-style: solid;
--border-color: var(--ion-color-light);
--border-radius: 4px 0 0 4px;
box-shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.2), 0 2px 2px 0 rgba(0, 0, 0, 0.14),
0 1px 5px 0 rgba(0, 0, 0, 0.12);
ion-button {
.side-button {
--border-radius: 4px;
}
}
@@ -80,12 +92,12 @@ ion-card {
@media (max-width: 500px) {
ion-button {
.side-button {
--border-radius: 4px;
margin-top: 0.7rem;
}
ion-item {
.login-item {
--border-radius: 4px;
}
}

View File

@@ -1,9 +1,12 @@
import { Component } from '@angular/core'
import { LoadingController, getPlatforms } from '@ionic/angular'
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 { pauseFor, RELATIVE_URL } from '@start9labs/shared'
import { DOCUMENT } from '@angular/common'
import { WINDOW } from '@ng-web-apis/common'
@Component({
selector: 'login',
@@ -14,42 +17,71 @@ export class LoginPage {
password = ''
unmasked = false
error = ''
loader?: HTMLIonLoadingElement
secure = this.config.isSecure()
downloadClicked = false
instructionsClicked = false
polling = false
caTrusted = false
constructor(
private readonly router: Router,
private readonly authService: AuthService,
private readonly loadingCtrl: LoadingController,
private readonly api: ApiService,
private readonly config: ConfigService,
public readonly config: ConfigService,
@Inject(RELATIVE_URL) private readonly relativeUrl: string,
@Inject(DOCUMENT) public readonly document: Document,
@Inject(WINDOW) private readonly windowRef: Window,
) {}
async ionViewDidEnter() {
if (!this.secure) {
async ngOnInit() {
if (!this.config.isSecure()) {
await this.testHttps().catch(e =>
console.warn('Failed Https connection attempt'),
)
}
}
download() {
this.downloadClicked = true
this.document.getElementById('install-cert')?.click()
}
instructions() {
this.windowRef.open(
'https://docs.start9.com/getting-started/trust-ca/#trust-your-server-s-root-ca',
'_blank',
'noreferrer',
)
this.instructionsClicked = true
this.startDaemon()
}
private async startDaemon(): Promise<void> {
this.polling = true
while (this.polling) {
try {
await this.api.getPubKey()
} catch (e: any) {
this.error = e.message
await this.testHttps()
this.polling = false
} catch (e) {
console.warn('Failed Https connection attempt')
await pauseFor(2000)
}
}
}
ngOnDestroy() {
this.loader?.dismiss()
}
toggleMask() {
this.unmasked = !this.unmasked
launchHttps() {
const host = this.config.getHost()
this.windowRef.open(`https://${host}`, '_blank', 'noreferrer')
}
async submit() {
this.error = ''
this.loader = await this.loadingCtrl.create({
const loader = await this.loadingCtrl.create({
message: 'Logging in...',
})
await this.loader.present()
await loader.present()
try {
document.cookie = ''
@@ -58,9 +90,7 @@ export class LoginPage {
return
}
await this.api.login({
password: this.secure
? this.password
: await this.api.encrypt(this.password),
password: this.password,
metadata: { platforms: getPlatforms() },
})
@@ -71,7 +101,16 @@ export class LoginPage {
// code 7 is for incorrect password
this.error = e.code === 7 ? 'Invalid Password' : e.message
} finally {
this.loader.dismiss()
loader.dismiss()
}
}
private async testHttps() {
const url = `https://${this.document.location.host}${this.relativeUrl}`
await this.api.echo({ message: 'ping' }, url).then(() => {
this.downloadClicked = true
this.instructionsClicked = true
this.caTrusted = true
})
}
}

View File

@@ -23,11 +23,10 @@ import {
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 { ApiService } from 'src/app/services/api/embassy-api.service'
import { Breakages } from 'src/app/services/api/api.types'
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',
@@ -57,7 +56,6 @@ export class MarketplaceShowControlsComponent {
private readonly loadingCtrl: LoadingController,
private readonly emver: Emver,
private readonly errToast: ErrorToastService,
private readonly embassyApi: ApiService,
private readonly patch: PatchDB<DataModel>,
) {}
@@ -141,30 +139,19 @@ export class MarketplaceShowControlsComponent {
}
private async dryInstall(url: string) {
const loader = await this.loadingCtrl.create({
message: 'Checking dependent services...',
})
await loader.present()
const breakages = dryUpdate(
this.pkg.manifest,
await getAllPackages(this.patch),
this.emver,
)
const { id, version } = this.pkg.manifest
try {
const breakages = await this.embassyApi.dryUpdatePackage({
id,
version: `${version}`,
})
if (isEmptyObject(breakages)) {
this.install(url, loader)
} else {
await loader.dismiss()
const proceed = await this.presentAlertBreakages(breakages)
if (proceed) {
this.install(url)
}
if (isEmptyObject(breakages)) {
this.install(url)
} else {
const proceed = await this.presentAlertBreakages(breakages)
if (proceed) {
this.install(url)
}
} catch (e: any) {
this.errToast.present(e)
}
}
@@ -193,14 +180,11 @@ export class MarketplaceShowControlsComponent {
await alert.present()
}
private async install(url: string, loader?: HTMLIonLoadingElement) {
const message = 'Beginning Install...'
if (loader) {
loader.message = message
} else {
loader = await this.loadingCtrl.create({ message })
await loader.present()
}
private async install(url: string) {
const loader = await this.loadingCtrl.create({
message: 'Beginning Install...',
})
await loader.present()
const { id, version } = this.pkg.manifest
@@ -213,14 +197,10 @@ export class MarketplaceShowControlsComponent {
}
}
private async presentAlertBreakages(breakages: Breakages): Promise<boolean> {
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 localPkgs = await getAllPackages(this.patch)
const bullets = Object.keys(breakages).map(id => {
const title = localPkgs[id].manifest.title
return `<li><b>${title}</b></li>`
})
const bullets = breakages.map(title => `<li><b>${title}</b></li>`)
message = `${message}${bullets.join('')}</ul>`
return new Promise(async resolve => {

View File

@@ -14,7 +14,7 @@
<div class="welcome-header">
<h1>Welcome to StartOS</h1>
</div>
<widget-list></widget-list>
<!-- <widget-list></widget-list> -->
</ng-container>
<ng-template #list>

View File

@@ -1,22 +1,30 @@
import { Pipe, PipeTransform } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import { filter, map, startWith, Observable } from 'rxjs'
import { Observable, combineLatest } from 'rxjs'
import { filter, map, startWith } from 'rxjs/operators'
import {
DataModel,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { getPackageInfo } from 'src/app/util/get-package-info'
import { PkgInfo } from 'src/app/types/pkg-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>) {}
constructor(
private readonly patch: PatchDB<DataModel>,
private readonly depErrorService: DepErrorService,
) {}
transform(pkg: PackageDataEntry): Observable<PkgInfo> {
return this.patch
.watch$('package-data', pkg.manifest.id)
.pipe(filter(Boolean), startWith(pkg), map(getPackageInfo))
return combineLatest([
this.patch
.watch$('package-data', pkg.manifest.id)
.pipe(filter(Boolean), startWith(pkg)),
this.depErrorService.depErrors$,
]).pipe(map(([pkg, depErrors]) => getPackageInfo(pkg, depErrors)))
}
}

View File

@@ -20,8 +20,6 @@ import { AppShowHealthChecksComponent } from './components/app-show-health-check
import { AppShowAdditionalComponent } from './components/app-show-additional/app-show-additional.component'
import { HealthColorPipe } from './pipes/health-color.pipe'
import { ToButtonsPipe } from './pipes/to-buttons.pipe'
import { ToDependenciesPipe } from './pipes/to-dependencies.pipe'
import { ToStatusPipe } from './pipes/to-status.pipe'
import { ProgressDataPipe } from './pipes/progress-data.pipe'
import { InsecureWarningComponentModule } from 'src/app/common/insecure-warning/insecure-warning.module'
import { LaunchMenuComponentModule } from '../launch-menu/launch-menu.module'
@@ -39,8 +37,6 @@ const routes: Routes = [
HealthColorPipe,
ProgressDataPipe,
ToButtonsPipe,
ToDependenciesPipe,
ToStatusPipe,
AppShowHeaderComponent,
AppShowProgressComponent,
AppShowStatusComponent,

View File

@@ -1,9 +1,9 @@
<ng-container *ngIf="pkg$ | async as pkg">
<ng-container *ngIf="pkgPlus$ | async as pkgPlus">
<!-- header -->
<app-show-header [pkg]="pkg"></app-show-header>
<app-show-header [pkg]="pkgPlus.pkg"></app-show-header>
<!-- content -->
<ion-content class="ion-padding with-widgets">
<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
@@ -15,41 +15,27 @@
<!-- Installed -->
<ng-template #installed>
<!-- SECURE -->
<ng-container *ngIf="secure; else insecure">
<ng-container *ngIf="pkg | toDependencies as dependencies">
<ion-item-group *ngIf="pkg | toStatus as status">
<!-- ** status ** -->
<app-show-status
[pkg]="pkg"
[dependencies]="dependencies"
[status]="status"
></app-show-status>
<!-- ** installed && !backing-up ** -->
<ng-container *ngIf="isInstalled(pkg) && !isBackingUp(status)">
<!-- ** health checks ** -->
<app-show-health-checks
*ngIf="isRunning(status)"
[pkgId]="pkgId"
></app-show-health-checks>
<!-- ** dependencies ** -->
<app-show-dependencies
*ngIf="dependencies.length"
[dependencies]="dependencies"
></app-show-dependencies>
<!-- ** menu ** -->
<app-show-menu [buttons]="pkg | toButtons"></app-show-menu>
<!-- ** additional ** -->
<app-show-additional [pkg]="pkg"></app-show-additional>
</ng-container>
</ion-item-group>
<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)"
[pkgId]="pkgId"
></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>
</ng-container>
<!-- INSECURE -->
<ng-template #insecure>
<insecure-warning></insecure-warning>
</ng-template>
</ion-item-group>
</ng-template>
</ion-content>
</ng-container>

View File

@@ -1,19 +1,43 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { NavController } from '@ionic/angular'
import { getPkgId } from '@start9labs/shared'
import { PatchDB } from 'patch-db-client'
import { tap } from 'rxjs'
import {
DataModel,
PackageDataEntry,
PackageState,
InstalledPackageInfo,
} from 'src/app/services/patch-db/data-model'
import {
PackageStatus,
PrimaryStatus,
renderPkgStatus,
} from 'src/app/services/pkg-status-rendering.service'
import { ConfigService } from 'src/app/services/config.service'
import { map, tap } from 'rxjs/operators'
import { ActivatedRoute, NavigationExtras } from '@angular/router'
import { getPkgId } from '@start9labs/shared'
import { DependentInfo } from 'src/app/types/dependent-info'
import {
DepErrorService,
DependencyErrorType,
PackageDependencyErrors,
} from 'src/app/services/dep-error.service'
import { combineLatest } from 'rxjs'
import { Manifest } from '@start9labs/marketplace'
import {
AppConfigPage,
PackageConfigData,
} from './modals/app-config/app-config.page'
import { FormDialogService } from 'src/app/services/form-dialog.service'
export interface DependencyInfo {
id: string
title: string
icon: string
version: string
errorText: string
actionText: string
action: () => any
}
const STATES = [
PackageState.Installing,
@@ -27,14 +51,23 @@ const STATES = [
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AppShowPage {
readonly secure = this.config.isSecure()
readonly pkgId = getPkgId(this.route)
readonly pkg$ = this.patch.watch$('package-data', this.pkgId).pipe(
tap(pkg => {
readonly pkgPlus$ = combineLatest([
this.patch.watch$('package-data'),
this.depErrorService.depErrors$,
]).pipe(
tap(([pkgs, _]) => {
// if package disappears, navigate to list page
if (!pkg) this.navCtrl.navigateRoot('/services')
if (!pkgs[this.pkgId]) this.navCtrl.navigateRoot('/services')
}),
map(([pkgs, depErrors]) => {
const pkg = pkgs[this.pkgId]
return {
pkg,
dependencies: this.getDepInfo(pkg, depErrors),
status: renderPkgStatus(pkg, depErrors),
}
}),
)
@@ -42,7 +75,8 @@ export class AppShowPage {
private readonly route: ActivatedRoute,
private readonly navCtrl: NavController,
private readonly patch: PatchDB<DataModel>,
private readonly config: ConfigService,
private readonly depErrorService: DepErrorService,
private readonly formDialog: FormDialogService,
) {}
isInstalled({ state }: PackageDataEntry): boolean {
@@ -60,4 +94,140 @@ export class AppShowPage {
showProgress({ state }: PackageDataEntry): boolean {
return STATES.includes(state)
}
private getDepInfo(
pkg: PackageDataEntry,
depErrors: PackageDependencyErrors,
): DependencyInfo[] {
const pkgInstalled = pkg.installed
if (!pkgInstalled) return []
const pkgManifest = pkg.manifest
return Object.keys(pkgInstalled['current-dependencies'])
.filter(depId => !!pkg.manifest.dependencies[depId])
.map(depId =>
this.getDepValues(pkgInstalled, pkgManifest, depId, depErrors),
)
}
private getDepValues(
pkgInstalled: InstalledPackageInfo,
pkgManifest: Manifest,
depId: string,
depErrors: PackageDependencyErrors,
): DependencyInfo {
const { errorText, fixText, fixAction } = this.getDepErrors(
pkgManifest,
depId,
depErrors,
)
const depInfo = pkgInstalled['dependency-info'][depId]
return {
id: depId,
version: pkgManifest.dependencies[depId].version, // do we want this version range?
title: depInfo?.title || depId,
icon: depInfo?.icon || '',
errorText: errorText
? `${errorText}. ${pkgManifest.title} will not work as expected.`
: '',
actionText: fixText || 'View',
action:
fixAction || (() => this.navCtrl.navigateForward(`/services/${depId}`)),
}
}
private getDepErrors(
pkgManifest: Manifest,
depId: string,
depErrors: PackageDependencyErrors,
) {
const depError = depErrors[pkgManifest.id][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 = 'Health check failed'
} 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(manifest: Manifest, depId: string): Promise<void> {
const version = manifest.dependencies[depId].version
const dependentInfo: DependentInfo = {
id: manifest.id,
title: manifest.title,
version,
}
const navigationExtras: NavigationExtras = {
state: { dependentInfo },
}
await this.navCtrl.navigateForward(
`/marketplace/${depId}`,
navigationExtras,
)
}
private async configureDep(
manifest: Manifest,
dependencyId: string,
): Promise<void> {
const dependentInfo: DependentInfo = {
id: manifest.id,
title: manifest.title,
}
return this.formDialog.open<PackageConfigData>(AppConfigPage, {
label: 'Config',
data: {
pkgId: dependencyId,
dependentInfo,
},
})
}
}

View File

@@ -1,5 +1,5 @@
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { DependencyInfo } from '../../pipes/to-dependencies.pipe'
import { DependencyInfo } from '../../app-show.page'
@Component({
selector: 'app-show-dependencies',

View File

@@ -24,7 +24,6 @@ import {
AppConfigPage,
PackageConfigData,
} from '../../modals/app-config/app-config.page'
import { DependencyInfo } from '../../pipes/to-dependencies.pipe'
import { hasCurrentDeps } from 'src/app/util/has-deps'
import { ConnectionService } from 'src/app/services/connection.service'
import { PatchDB } from 'patch-db-client'
@@ -45,8 +44,7 @@ export class AppShowStatusComponent {
@Input()
status!: PackageStatus
@Input()
dependencies: DependencyInfo[] = []
PR = PrimaryRendering
readonly connected$ = this.connectionService.connected$
@@ -101,7 +99,7 @@ export class AppShowStatusComponent {
}
async tryStart(): Promise<void> {
if (this.dependencies.some(d => !!d.errorText)) {
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)

View File

@@ -1,150 +0,0 @@
import { Pipe, PipeTransform } from '@angular/core'
import { NavigationExtras } from '@angular/router'
import { NavController } from '@ionic/angular'
import {
DependencyErrorType,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { DependentInfo } from 'src/app/types/dependent-info'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import {
AppConfigPage,
PackageConfigData,
} from '../modals/app-config/app-config.page'
import { Manifest } from '@start9labs/marketplace'
export interface DependencyInfo {
id: string
title: string
icon: string
version: string
errorText: string
actionText: string
action: () => any
}
@Pipe({
name: 'toDependencies',
})
export class ToDependenciesPipe implements PipeTransform {
constructor(
private readonly navCtrl: NavController,
private readonly formDialog: FormDialogService,
) {}
transform(pkg: PackageDataEntry): DependencyInfo[] {
if (!pkg.installed) return []
return Object.keys(pkg.installed['current-dependencies'])
.filter(depId => !!pkg.manifest.dependencies[depId])
.map(depId => this.setDepValues(pkg, depId))
}
private setDepValues(pkg: PackageDataEntry, depId: string): DependencyInfo {
let errorText = ''
let actionText = 'View'
let action: () => any = () =>
this.navCtrl.navigateForward(`/services/${depId}`)
const error = pkg.installed!.status['dependency-errors'][depId]
if (error) {
// health checks failed
if (error.type === DependencyErrorType.HealthChecksFailed) {
errorText = 'Health check failed'
// not installed
} else if (error.type === DependencyErrorType.NotInstalled) {
errorText = 'Not installed'
actionText = 'Install'
action = () => this.fixDep(pkg, 'install', depId)
// incorrect version
} else if (error.type === DependencyErrorType.IncorrectVersion) {
errorText = 'Incorrect version'
actionText = 'Update'
action = () => this.fixDep(pkg, 'update', depId)
// not running
} else if (error.type === DependencyErrorType.NotRunning) {
errorText = 'Not running'
actionText = 'Start'
// config unsatisfied
} else if (error.type === DependencyErrorType.ConfigUnsatisfied) {
errorText = 'Config not satisfied'
actionText = 'Auto config'
action = () => this.fixDep(pkg, 'configure', depId)
} else if (error.type === DependencyErrorType.Transitive) {
errorText = 'Dependency has a dependency issue'
}
errorText = `${errorText}. ${pkg.manifest.title} will not work as expected.`
}
const depInfo = pkg.installed!['dependency-info'][depId]
return {
id: depId,
version: pkg.manifest.dependencies[depId].version,
title: depInfo?.title || depId,
icon: depInfo?.icon || '',
errorText,
actionText,
action,
}
}
async fixDep(
pkg: PackageDataEntry,
action: 'install' | 'update' | 'configure',
depId: string,
): Promise<void> {
switch (action) {
case 'install':
case 'update':
return this.installDep(pkg.manifest, depId)
case 'configure':
return this.formDialog.open<PackageConfigData>(AppConfigPage, {
label: `${
pkg.installed!['dependency-info'][depId].title
} configuration`,
data: {
pkgId: depId,
dependentInfo: pkg.manifest,
},
})
}
}
private async installDep(manifest: Manifest, depId: string): Promise<void> {
const version = manifest.dependencies[depId].version
const dependentInfo: DependentInfo = {
id: manifest.id,
title: manifest.title,
version,
}
const navigationExtras: NavigationExtras = {
state: { dependentInfo },
}
await this.navCtrl.navigateForward(
`/marketplace/${depId}`,
navigationExtras,
)
}
private async configureDep(
manifest: Manifest,
dependencyId: string,
): Promise<void> {
const dependentInfo: DependentInfo = {
id: manifest.id,
title: manifest.title,
}
return this.formDialog.open<PackageConfigData>(AppConfigPage, {
label: 'Config',
data: {
pkgId: dependencyId,
dependentInfo,
},
})
}
}

View File

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

View File

@@ -35,9 +35,5 @@
</ion-item-group>
<!-- hidden element for downloading cert -->
<a
id="install-cert"
href="/public/eos/local.crt"
[download]="crtName$ | async"
></a>
<a id="install-cert" href="/eos/local.crt" [download]="crtName$ | async"></a>
</ion-content>

View File

@@ -15,7 +15,26 @@
<!-- loaded -->
<ion-item-group *ngIf="server$ | async as server; else loading">
<insecure-warning *ngIf="!secure"></insecure-warning>
<ion-item *ngIf="isTorHttp" 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.
<a
[routerLink]="['/system', 'root-ca']"
style="color: var(--ion-color-light)"
>
Download and trust your server's Root CA
</a>
, then switch to https.
</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 *ngFor="let cat of settings | keyvalue : asIsOrder">
<ion-item-divider>

View File

@@ -44,9 +44,7 @@ export class ServerShowPage {
readonly showUpdate$ = this.eosService.showUpdate$
readonly showDiskRepair$ = this.ClientStorageService.showDiskRepair$
readonly secure = this.config.isSecure()
readonly isTorHttp =
this.config.isTor() && this.document.location.protocol === 'http:'
readonly isTorHttp = this.config.isTorHttp()
constructor(
private readonly alertCtrl: AlertController,
@@ -316,6 +314,11 @@ export class ServerShowPage {
await alert.present()
}
async launchHttps() {
const { 'tor-address': torAddress } = await getServerInfo(this.patch)
window.open(torAddress)
}
private async setName(value: string | null): Promise<void> {
const loader = await this.loadingCtrl.create({
message: 'Saving...',
@@ -520,7 +523,7 @@ export class ServerShowPage {
icon: 'key-outline',
action: () => this.presentAlertResetPassword(),
detail: false,
disabled$: of(!this.secure),
disabled$: of(false),
},
{
title: 'Experimental Features',

View File

@@ -1,5 +1,4 @@
import { Component, Inject } from '@angular/core'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { PatchDB } from 'patch-db-client'
import {
DataModel,
@@ -12,16 +11,12 @@ import {
Manifest,
StoreIdentity,
} from '@start9labs/marketplace'
import { isEmptyObject } from '@start9labs/shared'
import { Emver, isEmptyObject } from '@start9labs/shared'
import { combineLatest, Observable } from 'rxjs'
import {
AlertController,
LoadingController,
NavController,
} from '@ionic/angular'
import { hasCurrentDeps } from 'src/app/util/has-deps'
import { AlertController, NavController } from '@ionic/angular'
import { getAllPackages } from 'src/app/util/get-package-data'
import { Breakages } from 'src/app/services/api/api.types'
import { dryUpdate } from 'src/app/util/dry-update'
import { hasCurrentDeps } from 'src/app/util/has-deps'
interface UpdatesData {
hosts: StoreIdentity[]
@@ -46,11 +41,10 @@ export class UpdatesPage {
constructor(
@Inject(AbstractMarketplaceService)
readonly marketplaceService: MarketplaceService,
private readonly api: ApiService,
private readonly patch: PatchDB<DataModel>,
private readonly navCtrl: NavController,
private readonly loadingCtrl: LoadingController,
private readonly alertCtrl: AlertController,
private readonly emver: Emver,
) {}
viewInMarketplace(event: Event, url: string, id: string) {
@@ -74,56 +68,41 @@ export class UpdatesPage {
delete this.marketplaceService.updateErrors[id]
this.marketplaceService.updateQueue[id] = true
if (await hasCurrentDeps(this.patch, local.manifest.id)) {
this.dryUpdate(manifest, url)
if (hasCurrentDeps(local)) {
this.dryInstall(manifest, url)
} else {
this.update(id, version, url)
this.install(id, version, url)
}
}
private async dryUpdate(manifest: Manifest, url: string) {
const loader = await this.loadingCtrl.create({
message: 'Checking dependent services...',
})
await loader.present()
private async dryInstall(manifest: Manifest, url: string) {
const { id, version, title } = manifest
const { id, version } = manifest
const breakages = dryUpdate(
manifest,
await getAllPackages(this.patch),
this.emver,
)
try {
const breakages = await this.api.dryUpdatePackage({
id,
version: `${version}`,
})
await loader.dismiss()
if (isEmptyObject(breakages)) {
this.update(id, version, url)
if (isEmptyObject(breakages)) {
this.install(id, version, url)
} else {
const proceed = await this.presentAlertBreakages(title, breakages)
if (proceed) {
this.install(id, version, url)
} else {
const proceed = await this.presentAlertBreakages(
manifest.title,
breakages,
)
if (proceed) {
this.update(id, version, url)
} else {
delete this.marketplaceService.updateQueue[id]
}
delete this.marketplaceService.updateQueue[id]
}
} catch (e: any) {
delete this.marketplaceService.updateQueue[id]
this.marketplaceService.updateErrors[id] = e.message
}
}
private async presentAlertBreakages(
title: string,
breakages: Breakages,
breakages: string[],
): Promise<boolean> {
let message: string = `As a result of updating ${title}, the following services will no longer work properly and may crash:<ul>`
const localPkgs = await getAllPackages(this.patch)
const bullets = Object.keys(breakages).map(id => {
const title = localPkgs[id].manifest.title
return `<li><b>${title}</b></li>`
const bullets = breakages.map(depTitle => {
return `<li><b>${depTitle}</b></li>`
})
message = `${message}${bullets.join('')}</ul>`
@@ -154,7 +133,7 @@ export class UpdatesPage {
})
}
private async update(id: string, version: string, url: string) {
private async install(id: string, version: string, url: string) {
try {
await this.marketplaceService.installPackage(id, version, url)
delete this.marketplaceService.updateQueue[id]

View File

@@ -1,7 +1,10 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { map } from 'rxjs/operators'
import {
DataModel,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { PrimaryStatus } from 'src/app/services/pkg-status-rendering.service'
import { getPackageInfo } from 'src/app/util/get-package-info'
import { PkgInfo } from 'src/app/types/pkg-info'
@@ -21,11 +24,13 @@ export class HealthComponent {
'Transitioning',
] as const
readonly data$ = inject(PatchDB)
readonly data$ = inject(PatchDB<DataModel>)
.watch$('package-data')
.pipe(
map(data => {
const pkgs = Object.values<PackageDataEntry>(data).map(getPackageInfo)
const pkgs = Object.values<PackageDataEntry>(data).map(
pkg => getPackageInfo(pkg, {}), // @TODO hack because not currently using widget
)
const result = this.labels.reduce<Record<string, number>>(
(acc, label) => ({
...acc,

View File

@@ -0,0 +1,108 @@
<ion-grid class="grid-wiz">
<img width="60px" height="60px" src="/assets/img/icon_transparent.png" />
<ion-row>
<ion-col class="ion-text-center">
<ion-icon name="lock-closed-outline" class="wiz-icon"></ion-icon>
</ion-col>
</ion-row>
<ion-row>
<ion-col class="ion-text-center">
<h2><b>Trust your Root Certificate Authority (CA)</b></h2>
<p>
Download and trust your server's Root CA to establish secure, encrypted
(
<b>HTTPS</b>
) connections with your server
</p>
</ion-col>
</ion-row>
<ion-row>
<ion-col sizeXs="12" sizeLg="4">
<div class="wiz-card">
<ion-row class="ion-justify-content-between">
<b class="wiz-step">1</b>
<tui-tooltip
content="Your server uses its Root CA to generate SSL/TLS certificates for itself and its installed services. These certificates are used to encrypt network traffic with your client devices."
direction="right"
></tui-tooltip>
</ion-row>
<div class="ion-text-center">
<h2>Download Root CA</h2>
<p>Download your server's Root CA</p>
</div>
<ion-button class="wiz-card-button" shape="round" (click)="download()">
<ion-icon slot="start" name="download-outline"></ion-icon>
Download
</ion-button>
</div>
</ion-col>
<ion-col sizeXs="12" sizeLg="4">
<div class="wiz-card" [class.disabled]="!downloadClicked">
<ion-row class="ion-justify-content-between">
<b class="wiz-step">2</b>
<tui-tooltip
content="By trusting your server's Root CA, your device can verify the authenticity of its encrypted communications with your server and installed services. You will need to trust the Root CA on every device used to connect to your server."
direction="right"
></tui-tooltip>
</ion-row>
<div class="ion-text-center">
<h2>Trust Root CA</h2>
<p>Follow instructions for your OS</p>
</div>
<ion-button
class="wiz-card-button"
shape="round"
(click)="instructions()"
[disabled]="!downloadClicked"
>
View Docs
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-button>
</div>
</ion-col>
<ion-col sizeXs="12" sizeLg="4">
<div class="wiz-card" [class.disabled]="!polling && !caTrusted">
<b class="wiz-step">3</b>
<div class="ion-text-center">
<h2>Go To Login</h2>
<p *ngIf="instructionsClicked; else space" class="inline-center">
<ion-spinner
class="wiz-spinner"
*ngIf="!caTrusted; else trusted"
></ion-spinner>
<ng-template #trusted>
<ion-icon name="ribbon-outline" color="success"></ion-icon>
</ng-template>
&nbsp;{{ caTrusted ? 'Root CA trusted!' : 'Waiting for trust...' }}
</p>
<ng-template #space>
<!-- to keep alignment -->
<p><br /></p>
</ng-template>
</div>
<ion-button
class="wiz-card-button"
shape="round"
(click)="launchHttps()"
[disabled]="!caTrusted"
>
Open
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-button>
</div>
</ion-col>
</ion-row>
<ion-row>
<ion-col class="ion-text-center">
<ion-button fill="clear" (click)="launchHttps()" [disabled]="caTrusted">
Skip
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-button>
</ion-col>
</ion-row>
</ion-grid>
<a
id="install-cert"
href="/public/eos/local.crt"
[download]="document.location.hostname"
></a>

View File

@@ -0,0 +1,44 @@
.grid-wiz {
--ion-grid-padding: 36px;
height: 100%
}
.wiz-icon {
font-size: 84px;
}
.wiz-card {
background: #414141;
margin: 24px;
padding: 16px;
height: 280px;
border-radius: 16px;
display: grid;
& h2 {
font-weight: 600;
}
}
.wiz-card-button {
justify-self: center;
white-space: normal;
}
.wiz-spinner {
width: 14px;
height: 14px;
}
.disabled {
filter: saturate(0.2) contrast(0.5)
}
.wiz-step {
margin-top: 4px;
}
.inline-center {
display: inline-flex;
align-items: center;
}

View File

@@ -0,0 +1,76 @@
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 { pauseFor, 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 {
downloadClicked = false
instructionsClicked = false
polling = false
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() {
if (!this.config.isSecure()) {
await this.testHttps().catch(e =>
console.warn('Failed Https connection attempt'),
)
}
}
download() {
this.downloadClicked = true
this.document.getElementById('install-cert')?.click()
}
instructions() {
this.windowRef.open(
'https://docs.start9.com/getting-started/trust-ca/#trust-your-root-ca',
'_blank',
'noreferrer',
)
this.instructionsClicked = true
this.startDaemon()
}
private async startDaemon(): Promise<void> {
this.polling = true
while (this.polling) {
try {
await this.testHttps()
this.polling = false
} catch (e) {
console.warn('Failed Https connection attempt')
await pauseFor(2000)
}
}
}
launchHttps() {
const host = this.config.getHost()
this.windowRef.open(`https://${host}`, '_blank', 'noreferrer')
}
private async testHttps() {
const url = `https://${this.document.location.host}${this.relativeUrl}`
await this.api.echo({ message: 'ping' }, url).then(() => {
this.downloadClicked = true
this.instructionsClicked = true
this.caTrusted = true
})
}
}

View File

@@ -845,7 +845,7 @@ export module Mock {
integer: false,
}),
}),
displayAs: 'I\'m {{last-name}}, {{first-name}} {{last-name}}',
displayAs: "I'm {{last-name}}, {{first-name}} {{last-name}}",
uniqueBy: 'last-name',
},
),
@@ -1258,7 +1258,7 @@ export module Mock {
},
},
},
'dependency-errors': {},
'dependency-config-errors': {},
},
'address-info': {
rpc: {
@@ -1282,6 +1282,7 @@ export module Mock {
},
},
'current-dependencies': {},
'current-dependents': {},
'dependency-info': {},
'marketplace-url': 'https://registry.start9.com/',
'developer-key': 'developer-key',
@@ -1334,7 +1335,7 @@ export module Mock {
main: {
status: PackageMainStatus.Stopped,
},
'dependency-errors': {},
'dependency-config-errors': {},
},
'address-info': {
rpc: {
@@ -1347,6 +1348,7 @@ export module Mock {
ui: false,
},
},
'current-dependents': {},
'current-dependencies': {
bitcoind: {
'health-checks': [],
@@ -1377,11 +1379,8 @@ export module Mock {
main: {
status: PackageMainStatus.Stopped,
},
'dependency-errors': {
'btc-rpc-proxy': {
type: DependencyErrorType.ConfigUnsatisfied,
error: 'This is a config unsatisfied error',
},
'dependency-config-errors': {
'btc-rpc-proxy': 'Username not found',
},
},
'address-info': {
@@ -1414,6 +1413,7 @@ export module Mock {
'health-checks': [],
},
},
'current-dependents': {},
'dependency-info': {
bitcoind: {
title: 'Bitcoin Core',

View File

@@ -3,7 +3,7 @@ import { MarketplacePkg, StoreInfo, Manifest } from '@start9labs/marketplace'
import { InputSpec } from '@start9labs/start-sdk/lib/config/configTypes'
import {
DataModel,
DependencyError,
HealthCheckResult,
} from 'src/app/services/patch-db/data-model'
import { StartOSDiskInfo, LogsRes, ServerLogsReq } from '@start9labs/shared'
import { customSmtp } from '@start9labs/start-sdk/lib/config/configConstants'
@@ -21,7 +21,7 @@ export module RR {
// auth
export type LoginReq = {
password: Encrypted | string
password: string
metadata: SessionMetadata
} // auth.login - unauthed
export type loginRes = null
@@ -252,9 +252,6 @@ export module RR {
} // package.install
export type InstallPackageRes = null
export type DryUpdatePackageReq = { id: string; version: string } // package.update.dry
export type DryUpdatePackageRes = Breakages
export type GetPackageConfigReq = { id: string } // package.config.get
export type GetPackageConfigRes = { spec: InputSpec; config: object }
@@ -552,3 +549,49 @@ export type Encrypted = {
}
export type CloudProvider = 'dropbox' | 'google-drive'
export type DependencyError =
| DependencyErrorNotInstalled
| DependencyErrorNotRunning
| DependencyErrorIncorrectVersion
| DependencyErrorConfigUnsatisfied
| DependencyErrorHealthChecksFailed
| DependencyErrorTransitive
export enum DependencyErrorType {
NotInstalled = 'not-installed',
NotRunning = 'not-running',
IncorrectVersion = 'incorrect-version',
ConfigUnsatisfied = 'config-unsatisfied',
HealthChecksFailed = 'health-checks-failed',
InterfaceHealthChecksFailed = 'interface-health-checks-failed',
Transitive = 'transitive',
}
export interface DependencyErrorNotInstalled {
type: DependencyErrorType.NotInstalled
}
export interface DependencyErrorNotRunning {
type: DependencyErrorType.NotRunning
}
export interface DependencyErrorIncorrectVersion {
type: DependencyErrorType.IncorrectVersion
expected: string // version range
received: string // version
}
export interface DependencyErrorConfigUnsatisfied {
type: DependencyErrorType.ConfigUnsatisfied
error: string
}
export interface DependencyErrorHealthChecksFailed {
type: DependencyErrorType.HealthChecksFailed
check: HealthCheckResult
}
export interface DependencyErrorTransitive {
type: DependencyErrorType.Transitive
}

View File

@@ -1,28 +1,12 @@
import { BehaviorSubject, Observable } from 'rxjs'
import { Update } from 'patch-db-client'
import { RR, Encrypted, BackupTargetType, Metrics } from './api.types'
import { RR, BackupTargetType, Metrics } from './api.types'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { Log, SetupStatus } from '@start9labs/shared'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
import type { JWK } from 'node-jose'
export abstract class ApiService {
protected readonly jose = import('node-jose')
readonly patchStream$ = new BehaviorSubject<Update<DataModel>[]>([])
pubkey?: JWK.Key
async encrypt(toEncrypt: string): Promise<Encrypted> {
const { pubkey } = this
if (!pubkey) throw new Error('No pubkey found!')
const encrypted = await this.jose.then(jose =>
jose.JWE.createEncrypt(pubkey).update(toEncrypt).final(),
)
return { encrypted }
}
// http
@@ -43,8 +27,6 @@ export abstract class ApiService {
// auth
abstract getPubKey(): Promise<void>
abstract login(params: RR.LoginReq): Promise<RR.loginRes>
abstract logout(params: RR.LogoutReq): Promise<RR.LogoutRes>
@@ -59,7 +41,7 @@ export abstract class ApiService {
// server
abstract echo(params: RR.EchoReq): Promise<RR.EchoRes>
abstract echo(params: RR.EchoReq, urlOverride?: string): Promise<RR.EchoRes>
abstract openPatchWebsocket$(): Observable<Update<DataModel>>
@@ -250,10 +232,6 @@ export abstract class ApiService {
params: RR.InstallPackageReq,
): Promise<RR.InstallPackageRes>
abstract dryUpdatePackage(
params: RR.DryUpdatePackageReq,
): Promise<RR.DryUpdatePackageRes>
abstract getPackageConfig(
params: RR.GetPackageConfigReq,
): Promise<RR.GetPackageConfigRes>

View File

@@ -77,18 +77,6 @@ export class LiveApiService extends ApiService {
// auth
/**
* We want to update the pubkey, which means that we will call in clearnet the
* getPubKey, and all the information is never in the clear, and only public
* information is sent across the network.
*/
async getPubKey() {
this.pubkey = await this.rpcRequest({
method: 'auth.get-pubkey',
params: {},
})
}
async login(params: RR.LoginReq): Promise<RR.loginRes> {
return this.rpcRequest({ method: 'auth.login', params }, false)
}
@@ -113,8 +101,8 @@ export class LiveApiService extends ApiService {
// server
async echo(params: RR.EchoReq): Promise<RR.EchoRes> {
return this.rpcRequest({ method: 'echo', params }, false)
async echo(params: RR.EchoReq, urlOverride?: string): Promise<RR.EchoRes> {
return this.rpcRequest({ method: 'echo', params }, false, urlOverride)
}
openPatchWebsocket$(): Observable<Update<DataModel>> {
@@ -427,12 +415,6 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'package.install', params })
}
async dryUpdatePackage(
params: RR.DryUpdatePackageReq,
): Promise<RR.DryUpdatePackageRes> {
return this.rpcRequest({ method: 'package.update.dry', params })
}
async getPackageConfig(
params: RR.GetPackageConfigReq,
): Promise<RR.GetPackageConfigRes> {
@@ -521,6 +503,7 @@ export class LiveApiService extends ApiService {
private async rpcRequest<T>(
options: RPCOptions,
addHeader = true,
urlOverride?: string,
): Promise<T> {
if (addHeader) {
options.headers = {
@@ -529,7 +512,7 @@ export class LiveApiService extends ApiService {
}
}
const res = await this.http.rpcRequest<T>(options)
const res = await this.http.rpcRequest<T>(options, urlOverride)
const encodedUpdates = res.headers.get('x-patch-updates')
const encodedError = res.headers.get('x-patch-error')

View File

@@ -10,7 +10,6 @@ import {
} from 'patch-db-client'
import {
DataModel,
DependencyErrorType,
InstallProgress,
PackageDataEntry,
PackageMainStatus,
@@ -115,24 +114,6 @@ export class MockApiService extends ApiService {
// auth
async getPubKey() {
await pauseFor(1000)
// randomly generated
// const keystore = jose.JWK.createKeyStore()
// this.pubkey = await keystore.generate('EC', 'P-256')
// generated from backend
this.pubkey = await this.jose.then(jose =>
jose.JWK.asKey({
kty: 'EC',
crv: 'P-256',
x: 'yHTDYSfjU809fkSv9MmN4wuojf5c3cnD7ZDN13n-jz4',
y: '8Mpkn744A5KDag0DmX2YivB63srjbugYZzWc3JOpQXI',
}),
)
}
async login(params: RR.LoginReq): Promise<RR.loginRes> {
await pauseFor(2000)
@@ -167,7 +148,13 @@ export class MockApiService extends ApiService {
// server
async echo(params: RR.EchoReq): Promise<RR.EchoRes> {
async echo(params: RR.EchoReq, url?: string): Promise<RR.EchoRes> {
if (url) {
const num = Math.floor(Math.random() * 10) + 1
console.warn(num)
if (num > 8) return params.message
throw new Error()
}
await pauseFor(2000)
return params.message
}
@@ -731,22 +718,6 @@ export class MockApiService extends ApiService {
return this.withRevision(patch)
}
async dryUpdatePackage(
params: RR.DryUpdatePackageReq,
): Promise<RR.DryUpdatePackageRes> {
await pauseFor(2000)
return {
lnd: {
dependency: 'bitcoind',
error: {
type: DependencyErrorType.IncorrectVersion,
expected: '>0.23.0',
received: params.version,
},
},
}
}
async getPackageConfig(
params: RR.GetPackageConfigReq,
): Promise<RR.GetPackageConfigRes> {

View File

@@ -21,6 +21,10 @@ export class ConfigService {
constructor(@Inject(DOCUMENT) private readonly document: Document) {}
hostname = this.document.location.hostname
// includes port
host = this.document.location.host
// includes ":" (e.g. "http:")
protocol = this.document.location.protocol
version = require('../../../../../package.json').version as string
useMocks = useMocks
mocks = mocks
@@ -34,22 +38,41 @@ export class ConfigService {
supportsWebSockets = !!window.WebSocket || this.isConsulate
isTor(): boolean {
return (
this.hostname.endsWith('.onion') || (useMocks && mocks.maskAs === 'tor')
)
return useMocks ? mocks.maskAs === 'tor' : this.hostname.endsWith('.onion')
}
isLocal(): boolean {
return useMocks
? mocks.maskAs === 'local'
: this.hostname.endsWith('.local')
}
isLocalhost(): boolean {
return useMocks
? mocks.maskAs === 'localhost'
: this.hostname === 'localhost'
}
isLan(): boolean {
return (
this.hostname === 'localhost' ||
this.hostname.endsWith('.local') ||
(useMocks && mocks.maskAs === 'lan')
)
// @TODO will not work once clearnet arrives
return !this.isTor()
}
isTorHttp(): boolean {
return this.isTor() && !this.isHttps()
}
isLocalHttp(): boolean {
return this.isLocal() && !this.isHttps()
}
isSecure(): boolean {
return window.isSecureContext || this.isTor()
}
private isHttps(): boolean {
return useMocks ? mocks.maskAsHttps : this.protocol === 'https:'
}
}
export function hasUi(

View File

@@ -0,0 +1,211 @@
import { Injectable } from '@angular/core'
import { Emver } from '@start9labs/shared'
import { map, shareReplay } from 'rxjs/operators'
import { PatchDB } from 'patch-db-client'
import {
DataModel,
HealthCheckResult,
HealthResult,
InstalledPackageDataEntry,
PackageMainStatus,
} from './patch-db/data-model'
export type PackageDependencyErrors = Record<string, DependencyErrors>
export type DependencyErrors = Record<string, DependencyError | null>
@Injectable({
providedIn: 'root',
})
export class DepErrorService {
readonly depErrors$ = this.patch.watch$('package-data').pipe(
map(pkgs =>
Object.keys(pkgs)
.map(id => ({
id,
depth: dependencyDepth(pkgs, id),
}))
.sort((a, b) => (b.depth > a.depth ? -1 : 1))
.reduce(
(errors, { id }): PackageDependencyErrors => ({
...errors,
[id]: this.getDepErrors(pkgs, id, errors),
}),
{} as PackageDependencyErrors,
),
),
shareReplay(1),
)
constructor(
private readonly emver: Emver,
private readonly patch: PatchDB<DataModel>,
) {}
private getDepErrors(
pkgs: DataModel['package-data'],
pkgId: string,
outerErrors: PackageDependencyErrors,
): DependencyErrors {
const pkgInstalled = pkgs[pkgId].installed
if (!pkgInstalled) return {}
return currentDeps(pkgs, pkgId).reduce(
(innerErrors, depId): DependencyErrors => ({
...innerErrors,
[depId]: this.getDepError(pkgs, pkgInstalled, depId, outerErrors),
}),
{} as DependencyErrors,
)
}
private getDepError(
pkgs: DataModel['package-data'],
pkgInstalled: InstalledPackageDataEntry,
depId: string,
outerErrors: PackageDependencyErrors,
): DependencyError | null {
const depInstalled = pkgs[depId]?.installed
// not installed
if (!depInstalled) {
return {
type: DependencyErrorType.NotInstalled,
}
}
const pkgManifest = pkgInstalled.manifest
const depManifest = depInstalled.manifest
// incorrect version
if (
!this.emver.satisfies(
depManifest.version,
pkgManifest.dependencies[depId].version,
)
) {
return {
type: DependencyErrorType.IncorrectVersion,
expected: pkgManifest.dependencies[depId].version,
received: depManifest.version,
}
}
// invalid config
if (
Object.values(pkgInstalled.status['dependency-config-errors']).some(
err => !!err,
)
) {
return {
type: DependencyErrorType.ConfigUnsatisfied,
}
}
const depStatus = depInstalled.status.main.status
// not running
if (
depStatus !== PackageMainStatus.Running &&
depStatus !== PackageMainStatus.Starting &&
!(
depStatus === PackageMainStatus.BackingUp &&
depInstalled.status.main.started
)
) {
return {
type: DependencyErrorType.NotRunning,
}
}
// health check failure
if (depStatus === PackageMainStatus.Running) {
for (let id of pkgInstalled['current-dependencies'][depId][
'health-checks'
]) {
if (
depInstalled.status.main.health[id].result !== HealthResult.Success
) {
return {
type: DependencyErrorType.HealthChecksFailed,
check: depInstalled.status.main.health[id],
}
}
}
}
// transitive
const transitiveError = currentDeps(pkgs, depId).some(transitiveId =>
Object.values(outerErrors[transitiveId]).some(err => !!err),
)
if (transitiveError) {
return {
type: DependencyErrorType.Transitive,
}
}
return null
}
}
function currentDeps(pkgs: DataModel['package-data'], id: string): string[] {
return Object.keys(
pkgs[id]?.installed?.['current-dependencies'] || {},
).filter(depId => depId !== id)
}
function dependencyDepth(
pkgs: DataModel['package-data'],
id: string,
depth = 0,
): number {
return currentDeps(pkgs, id).reduce(
(prev, depId) => dependencyDepth(pkgs, depId, prev + 1),
depth,
)
}
export type DependencyError =
| DependencyErrorNotInstalled
| DependencyErrorNotRunning
| DependencyErrorIncorrectVersion
| DependencyErrorConfigUnsatisfied
| DependencyErrorHealthChecksFailed
| DependencyErrorTransitive
export enum DependencyErrorType {
NotInstalled = 'notInstalled',
NotRunning = 'notRunning',
IncorrectVersion = 'incorrectVersion',
ConfigUnsatisfied = 'configUnsatisfied',
HealthChecksFailed = 'healthChecksFailed',
Transitive = 'transitive',
}
export interface DependencyErrorNotInstalled {
type: DependencyErrorType.NotInstalled
}
export interface DependencyErrorNotRunning {
type: DependencyErrorType.NotRunning
}
export interface DependencyErrorIncorrectVersion {
type: DependencyErrorType.IncorrectVersion
expected: string // version range
received: string // version
}
export interface DependencyErrorConfigUnsatisfied {
type: DependencyErrorType.ConfigUnsatisfied
}
export interface DependencyErrorHealthChecksFailed {
type: DependencyErrorType.HealthChecksFailed
check: HealthCheckResult
}
export interface DependencyErrorTransitive {
type: DependencyErrorType.Transitive
}

View File

@@ -158,6 +158,7 @@ export interface InstalledPackageInfo {
'last-backup': string | null
'installed-at': string
'current-dependencies': Record<string, CurrentDependencyInfo>
'current-dependents': Record<string, CurrentDependencyInfo>
'dependency-info': Record<string, { title: string; icon: Url }>
'address-info': Record<string, AddressInfo>
'marketplace-url': string | null
@@ -188,7 +189,7 @@ export interface Action {
export interface Status {
configured: boolean
main: MainStatus
'dependency-errors': { [id: string]: DependencyError | null }
'dependency-config-errors': { [id: string]: string | null }
}
export type MainStatus =
@@ -280,51 +281,6 @@ export interface HealthCheckResultFailure {
error: string
}
export type DependencyError =
| DependencyErrorNotInstalled
| DependencyErrorNotRunning
| DependencyErrorIncorrectVersion
| DependencyErrorConfigUnsatisfied
| DependencyErrorHealthChecksFailed
| DependencyErrorTransitive
export enum DependencyErrorType {
NotInstalled = 'not-installed',
NotRunning = 'not-running',
IncorrectVersion = 'incorrect-version',
ConfigUnsatisfied = 'config-unsatisfied',
HealthChecksFailed = 'health-checks-failed',
Transitive = 'transitive',
}
export interface DependencyErrorNotInstalled {
type: DependencyErrorType.NotInstalled
}
export interface DependencyErrorNotRunning {
type: DependencyErrorType.NotRunning
}
export interface DependencyErrorIncorrectVersion {
type: DependencyErrorType.IncorrectVersion
expected: string // version range
received: string // version
}
export interface DependencyErrorConfigUnsatisfied {
type: DependencyErrorType.ConfigUnsatisfied
error: string
}
export interface DependencyErrorHealthChecksFailed {
type: DependencyErrorType.HealthChecksFailed
check: HealthCheckResult
}
export interface DependencyErrorTransitive {
type: DependencyErrorType.Transitive
}
export interface InstallProgress {
readonly size: number | null
readonly downloaded: number

View File

@@ -1,11 +1,11 @@
import { isEmptyObject } from '@start9labs/shared'
import {
InstalledPackageInfo,
PackageDataEntry,
PackageMainStatus,
PackageState,
Status,
} from 'src/app/services/patch-db/data-model'
import { PackageDependencyErrors } from './dep-error.service'
import { Manifest } from '../../../../marketplace/src/types'
export interface PackageStatus {
primary: PrimaryStatus | PackageState | PackageMainStatus
@@ -13,14 +13,17 @@ export interface PackageStatus {
health: HealthStatus | null
}
export function renderPkgStatus(pkg: PackageDataEntry): PackageStatus {
export function renderPkgStatus(
pkg: PackageDataEntry,
depErrors: PackageDependencyErrors,
): PackageStatus {
let primary: PrimaryStatus | PackageState | PackageMainStatus
let dependency: DependencyStatus | null = null
let health: HealthStatus | null = null
if (pkg.state === PackageState.Installed && pkg.installed) {
primary = getPrimaryStatus(pkg.installed.status)
dependency = getDependencyStatus(pkg.installed)
dependency = getDependencyStatus(pkg.manifest, depErrors)
health = getHealthStatus(pkg.installed.status)
} else {
primary = pkg.state
@@ -38,14 +41,12 @@ function getPrimaryStatus(status: Status): PrimaryStatus | PackageMainStatus {
}
function getDependencyStatus(
installed: InstalledPackageInfo,
): DependencyStatus | null {
if (isEmptyObject(installed['current-dependencies'])) return null
const depErrors = installed.status['dependency-errors']
const depIds = Object.keys(depErrors).filter(key => !!depErrors[key])
return depIds.length ? DependencyStatus.Warning : DependencyStatus.Satisfied
manifest: Manifest,
depErrors: PackageDependencyErrors,
): DependencyStatus {
return Object.values(depErrors[manifest.id]).some(err => !!err)
? DependencyStatus.Warning
: DependencyStatus.Satisfied
}
function getHealthStatus(status: Status): HealthStatus | null {

View File

@@ -0,0 +1,17 @@
import { Emver } from '@start9labs/shared'
import { DataModel } from '../services/patch-db/data-model'
export function dryUpdate(
{ id, version }: { id: string; version: string },
pkgs: DataModel['package-data'],
emver: Emver,
): string[] {
return Object.values(pkgs)
.filter(
pkg =>
Object.keys(pkg.installed?.['current-dependencies'] || {}).some(
pkgId => pkgId === id,
) && !emver.satisfies(version, pkg.manifest.dependencies[id].version),
)
.map(pkg => pkg.manifest.title)
}

View File

@@ -14,6 +14,6 @@ export async function getPackage(
export async function getAllPackages(
patch: PatchDB<DataModel>,
): Promise<Record<string, PackageDataEntry>> {
): Promise<DataModel['package-data']> {
return firstValueFrom(patch.watch$('package-data'))
}

View File

@@ -8,9 +8,13 @@ import {
} from '../services/pkg-status-rendering.service'
import { PkgInfo } from '../types/pkg-info'
import { packageLoadingProgress } from './package-loading-progress'
import { PackageDependencyErrors } from '../services/dep-error.service'
export function getPackageInfo(entry: PackageDataEntry): PkgInfo {
const statuses = renderPkgStatus(entry)
export function getPackageInfo(
entry: PackageDataEntry,
depErrors: PackageDependencyErrors,
): PkgInfo {
const statuses = renderPkgStatus(entry, depErrors)
const primaryRendering = PrimaryRendering[statuses.primary]
return {

View File

@@ -1,13 +1,7 @@
import { PatchDB } from 'patch-db-client'
import { DataModel } from '../services/patch-db/data-model'
import { getAllPackages } from './get-package-data'
import { PackageDataEntry } from '../services/patch-db/data-model'
export async function hasCurrentDeps(
patch: PatchDB<DataModel>,
id: string,
): Promise<boolean> {
const pkgs = await getAllPackages(patch)
return !!Object.keys(pkgs)
.filter(pkgId => pkgId !== id)
.find(pkgId => pkgs[pkgId].installed?.['current-dependencies'][pkgId])
export function hasCurrentDeps(pkg: PackageDataEntry): boolean {
return !!Object.keys(pkg.installed?.['current-dependents'] || {}).filter(
depId => depId !== pkg.manifest.id,
).length
}

View File

@@ -53,8 +53,7 @@
*/
(window as any).global = window
global.Buffer = global.Buffer || require('buffer').Buffer;
(window as any).process = { env: { DEBUG: undefined }, browser: true }
; (window as any).process = { env: { DEBUG: undefined }, browser: true }
import './zone-flags'
@@ -62,8 +61,7 @@ import './zone-flags'
* Zone JS is required by default for Angular itself.
*/
import 'zone.js/dist/zone' // Included with Angular CLI.
import 'zone.js/dist/zone' // Included with Angular CLI.
/***************************************************************************************************
* APPLICATION IMPORTS

View File

@@ -395,3 +395,11 @@ tui-hint[data-appearance='onDark'] {
cursor: pointer;
margin: 0 12px 6px 0;
}
p {
font-size: 1rem;
}
svg:not(:root) {
overflow: auto;
}