rebased and compiling again

This commit is contained in:
Matt Hill
2023-11-09 15:35:47 -07:00
333 changed files with 15121 additions and 18641 deletions

View File

@@ -27,10 +27,7 @@
type="overlay"
side="end"
class="right-menu container"
[class.container_offline]="
(authService.isVerified$ | async) &&
!(connection.connected$ | async)
"
[class.container_offline]="offline$ | async"
[class.right-menu_hidden]="!drawer.open"
[style.--side-width.px]="drawer.width"
>
@@ -48,10 +45,7 @@
[responsiveColViewport]="viewport"
id="main-content"
class="container"
[class.container_offline]="
(authService.isVerified$ | async) &&
!(connection.connected$ | async)
"
[class.container_offline]="offline$ | async"
>
<ion-content
#viewport="viewport"

View File

@@ -1,6 +1,6 @@
import { Component, inject, OnDestroy } from '@angular/core'
import { Router } from '@angular/router'
import { combineLatest, map, merge } from 'rxjs'
import { combineLatest, map, merge, startWith } from 'rxjs'
import { AuthService } from './services/auth.service'
import { SplitPaneTracker } from './services/split-pane.service'
import { PatchDataService } from './services/patch-data.service'
@@ -34,11 +34,26 @@ export class AppComponent implements OnDestroy {
readonly sidebarOpen$ = this.splitPane.sidebarOpen$
readonly widgetDrawer$ = this.clientStorageService.widgetDrawer$
readonly theme$ = inject(THEME)
readonly navigation$ = combineLatest([
this.authService.isVerified$,
this.router.events.pipe(map(() => hasNavigation(this.router.url))),
]).pipe(map(([isVerified, hasNavigation]) => isVerified && hasNavigation))
readonly offline$ = combineLatest([
this.authService.isVerified$,
this.connection.connected$,
this.patch
.watch$('server-info', 'status-info')
.pipe(startWith({ restarting: false, 'shutting-down': false })),
]).pipe(
map(
([verified, connected, status]) =>
verified &&
(!connected || status.restarting || status['shutting-down']),
),
)
constructor(
private readonly router: Router,
private readonly titleService: Title,

View File

@@ -11,12 +11,13 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
import { IonicModule } from '@ionic/angular'
import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'
import {
MarkdownModule,
DarkThemeModule,
SharedPipesModule,
LightThemeModule,
LoadingModule,
ResponsiveColViewportDirective,
EnterModule,
MarkdownModule,
} from '@start9labs/shared'
import { AppComponent } from './app.component'
@@ -26,7 +27,6 @@ import { QRComponentModule } from './common/qr/qr.module'
import { PreloaderModule } from './app/preloader/preloader.module'
import { FooterModule } from './app/footer/footer.module'
import { MenuModule } from './app/menu/menu.module'
import { EnterModule } from './app/enter/enter.module'
import { APP_PROVIDERS } from './app.providers'
import { PatchDbModule } from './services/patch-db/patch-db.module'
import { ToastContainerModule } from './common/toast-container/toast-container.module'

View File

@@ -1,6 +1,8 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import { combineLatest, map, Observable, startWith } from 'rxjs'
import { ConnectionService } from 'src/app/services/connection.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
@Component({
selector: 'connection-bar',
@@ -19,8 +21,11 @@ export class ConnectionBarComponent {
}> = combineLatest([
this.connectionService.networkConnected$,
this.websocket$.pipe(startWith(false)),
this.patch
.watch$('server-info', 'status-info')
.pipe(startWith({ restarting: false, 'shutting-down': false })),
]).pipe(
map(([network, websocket]) => {
map(([network, websocket, status]) => {
if (!network)
return {
message: 'No Internet',
@@ -35,6 +40,20 @@ export class ConnectionBarComponent {
icon: 'cloud-offline-outline',
dots: true,
}
if (status['shutting-down'])
return {
message: 'Shutting Down',
color: 'dark',
icon: 'power',
dots: true,
}
if (status.restarting)
return {
message: 'Restarting',
color: 'dark',
icon: 'power',
dots: true,
}
return {
message: 'Connected',
@@ -45,5 +64,8 @@ export class ConnectionBarComponent {
}),
)
constructor(private readonly connectionService: ConnectionService) {}
constructor(
private readonly connectionService: ConnectionService,
private readonly patch: PatchDB<DataModel>,
) {}
}

View File

@@ -1,21 +0,0 @@
import { Directive, HostListener, Inject } from '@angular/core'
import { DOCUMENT } from '@angular/common'
import { debounce } from '@start9labs/shared'
@Directive({
selector: '[appEnter]',
})
export class EnterDirective {
constructor(@Inject(DOCUMENT) private readonly document: Document) {}
@HostListener('document:keydown.enter')
@debounce()
handleKeyboardEvent() {
const elems = this.document.querySelectorAll('.enter-click')
const elem = elems[elems.length - 1] as HTMLButtonElement
if (elem && !elem.classList.contains('no-click') && !elem.disabled) {
elem.click()
}
}
}

View File

@@ -1,9 +0,0 @@
import { NgModule } from '@angular/core'
import { EnterDirective } from './enter.directive'
@NgModule({
declarations: [EnterDirective],
exports: [EnterDirective],
})
export class EnterModule {}

View File

@@ -1,10 +1,5 @@
<a class="logo ion-padding" routerLink="/home">
<img
alt="Start9"
src="assets/img/{{
(theme$ | async) === 'Dark' ? 'logo' : 'logo_dark'
}}.png"
/>
<a class="logo" routerLink="/home">
<img alt="StartOS" src="assets/img/icon.png" />
</a>
<ion-item-group class="menu">
<ion-menu-toggle *ngFor="let page of pages" auto-hide="false">
@@ -27,11 +22,17 @@
<ion-label class="label montserrat" routerLinkActive="label_selected">
{{ page.title }}
</ion-label>
<ion-icon
*ngIf="page.url === '/system' && (warning$ | async)"
color="warning"
size="small"
name="warning"
></ion-icon>
<ion-icon
*ngIf="page.url === '/system' && (showEOSUpdate$ | async)"
color="success"
size="small"
name="rocket-outline"
name="rocket"
></ion-icon>
<ion-badge
*ngIf="page.url === '/updates' && (updateCount$ | async) as updateCount"

View File

@@ -4,8 +4,9 @@
.logo {
display: block;
width: 60%;
width: 36%;
margin: 0 auto;
padding: 16px 16px 0 16px;
}
.menu {

View File

@@ -11,7 +11,9 @@ import {
filter,
first,
map,
merge,
Observable,
of,
pairwise,
startWith,
switchMap,
@@ -22,6 +24,7 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
import { SplitPaneTracker } from 'src/app/services/split-pane.service'
import { Emver, THEME } from '@start9labs/shared'
import { ConnectionService } from 'src/app/services/connection.service'
import { ConfigService } from 'src/app/services/config.service'
@Component({
selector: 'app-menu',
@@ -110,6 +113,11 @@ export class MenuComponent {
readonly theme$ = inject(THEME)
readonly warning$ = merge(
of(this.config.isTorHttp()),
this.patch.watch$('server-info', 'ntp-synced').pipe(map(synced => !synced)),
)
constructor(
private readonly patch: PatchDB<DataModel>,
private readonly eosService: EOSService,
@@ -118,5 +126,6 @@ export class MenuComponent {
private readonly splitPane: SplitPaneTracker,
private readonly emver: Emver,
private readonly connectionService: ConnectionService,
private readonly config: ConfigService,
) {}
}

View File

@@ -60,10 +60,7 @@
<ion-toolbar></ion-toolbar>
<!-- images -->
<img src="assets/img/icons/bitcoin.svg" />
<img src="assets/img/icon.png" />
<img src="assets/img/logo.png" />
<img src="assets/img/icon_transparent.png" />
<img src="assets/img/community-store.png" />
<img src="assets/img/icons/snek.png" />
<img src="assets/img/icons/wifi-1.png" />

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

@@ -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,20 +1,54 @@
<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.isLanHttp(); 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. 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-card-content class="ion-margin">
<form class="form" (submit)="submit()">
<ion-item-group>
<ion-item color="dark">
<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>
@@ -25,7 +59,12 @@
[(ngModel)]="password"
(ionChange)="error = ''"
></ion-input>
<ion-button fill="clear" color="light" (click)="toggleMask()">
<ion-button
slot="end"
fill="clear"
color="dark"
(click)="unmasked = !unmasked"
>
<ion-icon
slot="icon-only"
size="small"
@@ -33,22 +72,22 @@
></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>
<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

@@ -1,15 +1,5 @@
.content {
--background: #222428;
}
.card {
background: #414141;
}
.title {
margin: 24px 0 16px;
color: #e0e0e0;
text-transform: uppercase;
--background: #333333;
}
.grid {
@@ -18,74 +8,69 @@
}
.row {
height: 90%;
height: 100%;
align-items: center;
text-align: center;
}
.logo {
max-width: 240px;
padding-bottom: 16px;
}
.banner {
position: absolute;
padding: 20px;
width: 100%;
display: inline-block;
.error {
display: block;
text-align: left;
padding-top: 4px;
}
ion-button {
--border-radius: 0 4px 4px 0;
}
ion-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 {
--border-radius: 4px;
ion-item {
max-width: 800px;
margin: auto;
}
}
ion-card {
background: var(--ion-color-step-200);
box-shadow: 0 4px 4px rgba(0, 0, 0, 0.25);
border-radius: 44px;
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 {
margin-inline-start: 0;
margin-inline-end: 0;
height: 49px;
font-size: larger;
font-weight: bold;
}
.form {
margin-bottom: 12px;
* {
display: inline-block;
vertical-align: middle;
}
height: 45px;
width: 120px;
--border-radius: 50px;
margin: 0 auto;
margin-top: 27px;
margin-bottom: 10px;
}
.item-interactive {
--highlight-background: #5260ff !important;
}
@media (max-width: 500px) {
ion-button {
--border-radius: 4px;
margin-top: 0.7rem;
}
ion-item {
--border-radius: 4px;
}
.error {
display: block;
padding-top: 4px;
}

View File

@@ -8,6 +8,7 @@ import { LoadingService } from '@start9labs/shared'
import { TuiDestroyService } from '@taiga-ui/cdk'
import { takeUntil } from 'rxjs'
import { DOCUMENT } from '@angular/common'
import { WINDOW } from '@ng-web-apis/common'
@Component({
selector: 'login',
@@ -19,30 +20,21 @@ export class LoginPage {
password = ''
unmasked = false
error = ''
secure = this.config.isSecure()
constructor(
@Inject(DOCUMENT) private readonly document: Document,
private readonly destroy$: TuiDestroyService,
private readonly router: Router,
private readonly authService: AuthService,
private readonly loader: LoadingService,
private readonly api: ApiService,
private readonly config: ConfigService,
public readonly config: ConfigService,
@Inject(DOCUMENT) public readonly document: Document,
@Inject(WINDOW) private readonly windowRef: Window,
) {}
async ionViewDidEnter() {
if (!this.secure) {
try {
await this.api.getPubKey()
} catch (e: any) {
this.error = e.message
}
}
}
toggleMask() {
this.unmasked = !this.unmasked
launchHttps() {
const host = this.config.getHost()
this.windowRef.open(`https://${host}`, '_self')
}
async submit() {
@@ -60,9 +52,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() },
})

View File

@@ -3,26 +3,18 @@ import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiDialogService } from '@taiga-ui/core'
import { TuiButtonModule } from '@taiga-ui/experimental'
import { tuiPure } from '@taiga-ui/cdk'
import { TUI_PROMPT } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import { filter } from 'rxjs'
import {
PackageStatus,
PrimaryStatus,
renderPkgStatus,
} from 'src/app/services/pkg-status-rendering.service'
import {
DataModel,
InterfaceInfo,
PackageDataEntry,
PackageMainStatus,
PackagePlus,
} from 'src/app/services/patch-db/data-model'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { hasCurrentDeps } from 'src/app/util/has-deps'
import { ServiceConfigModal } from '../modals/config.component'
import { PackageConfigData } from '../types/package-config-data'
import { ToDependenciesPipe } from '../pipes/to-dependencies.pipe'
@Component({
selector: 'service-actions',
@@ -69,12 +61,11 @@ import { ToDependenciesPipe } from '../pipes/to-dependencies.pipe'
styles: [':host { display: flex; gap: 1rem }'],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
providers: [ToDependenciesPipe],
imports: [CommonModule, TuiButtonModule],
})
export class ServiceActionsComponent {
@Input({ required: true })
service!: PackageDataEntry
service!: PackagePlus
constructor(
private readonly dialogs: TuiDialogService,
@@ -82,51 +73,51 @@ export class ServiceActionsComponent {
private readonly loader: LoadingService,
private readonly embassyApi: ApiService,
private readonly formDialog: FormDialogService,
private readonly patch: PatchDB<DataModel>,
private readonly dependencies: ToDependenciesPipe,
) {}
private get id(): string {
return this.service.manifest.id
return this.service.pkg.manifest.id
}
get interfaceInfo(): Record<string, InterfaceInfo> {
return this.service.installed!['interfaceInfo']
return this.service.pkg.installed!['interfaceInfo']
}
get isConfigured(): boolean {
return this.service.installed!.status.configured
return this.service.pkg.installed!.status.configured
}
get isRunning(): boolean {
return this.getStatus(this.service).primary === PrimaryStatus.Running
return (
this.service.pkg.installed?.status.main.status ===
PackageMainStatus.Running
)
}
get isStopped(): boolean {
return this.getStatus(this.service).primary === PrimaryStatus.Stopped
}
@tuiPure
getStatus(service: PackageDataEntry): PackageStatus {
return renderPkgStatus(service)
return (
this.service.pkg.installed?.status.main.status ===
PackageMainStatus.Stopped
)
}
presentModalConfig(): void {
this.formDialog.open<PackageConfigData>(ServiceConfigModal, {
label: `${this.service.manifest.title} configuration`,
label: `${this.service.pkg.manifest.title} configuration`,
data: { pkgId: this.id },
})
}
async tryStart(): Promise<void> {
if (this.dependencies.transform(this.service)?.some(d => !!d.errorText)) {
const depErrMsg = `${this.service.manifest.title} has unmet dependencies. It will not work as expected.`
const pkg = this.service.pkg
if (Object.values(this.service.dependencies).some(dep => !!dep.errorText)) {
const depErrMsg = `${pkg.manifest.title} has unmet dependencies. It will not work as expected.`
const proceed = await this.presentAlertStart(depErrMsg)
if (!proceed) return
}
const alertMsg = this.service.manifest.alerts.start
const alertMsg = pkg.manifest.alerts.start
if (alertMsg) {
const proceed = await this.presentAlertStart(alertMsg)
@@ -138,10 +129,10 @@ export class ServiceActionsComponent {
}
async tryStop(): Promise<void> {
const { title, alerts, id } = this.service.manifest
const { title, alerts } = this.service.pkg.manifest
let content = alerts.stop || ''
if (await hasCurrentDeps(this.patch, id)) {
if (hasCurrentDeps(this.service.pkg)) {
const depMessage = `Services that depend on ${title} will no longer work properly and may crash`
content = content ? `${content}.\n\n${depMessage}` : depMessage
}
@@ -165,15 +156,13 @@ export class ServiceActionsComponent {
}
async tryRestart(): Promise<void> {
const { id, title } = this.service.manifest
if (await hasCurrentDeps(this.patch, id)) {
if (hasCurrentDeps(this.service.pkg)) {
this.dialogs
.open(TUI_PROMPT, {
label: 'Warning',
size: 's',
data: {
content: `Services that depend on ${title} may temporarily experiences issues`,
content: `Services that depend on ${this.service.pkg.manifest} may temporarily experiences issues`,
yes: 'Restart',
no: 'Cancel',
},

View File

@@ -1,12 +1,11 @@
import { NgForOf } from '@angular/common'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { PackagePlus } from 'src/app/services/patch-db/data-model'
import {
PackageStatus,
PrimaryStatus,
} from 'src/app/services/pkg-status-rendering.service'
import { InterfaceInfoPipe } from '../pipes/interface-info.pipe'
import { ToStatusPipe } from '../pipes/to-status.pipe'
import { ServiceInterfaceComponent } from './interface.component'
import { RouterLink } from '@angular/router'
@@ -15,26 +14,20 @@ import { RouterLink } from '@angular/router'
template: `
<h3 class="g-title">Interfaces</h3>
<a
*ngFor="let info of service | interfaceInfo"
*ngFor="let info of service.pkg | interfaceInfo"
class="g-action"
[serviceInterface]="info"
[disabled]="!isRunning(service | toStatus)"
[disabled]="!isRunning(service.status)"
[routerLink]="info.routerLink"
></a>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
NgForOf,
RouterLink,
InterfaceInfoPipe,
ServiceInterfaceComponent,
ToStatusPipe,
],
imports: [NgForOf, RouterLink, InterfaceInfoPipe, ServiceInterfaceComponent],
})
export class ServiceInterfacesComponent {
@Input({ required: true })
service!: PackageDataEntry
service!: PackagePlus
isRunning({ primary }: PackageStatus): boolean {
return primary === PrimaryStatus.Running

View File

@@ -192,7 +192,7 @@ export class ServiceConfigModal {
try {
await this.uploadFiles(config, loader)
if (await hasCurrentDeps(this.patchDb, this.pkgId)) {
if (hasCurrentDeps(this.pkg!)) {
await this.configureDeps(config, loader)
} else {
await this.configure(config, loader)

View File

@@ -1,137 +0,0 @@
import { Pipe, PipeTransform } from '@angular/core'
import { NavigationExtras, Router } from '@angular/router'
import { Manifest } from '@start9labs/marketplace'
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 { ServiceConfigModal } from '../modals/config.component'
import { DependencyInfo } from '../types/dependency-info'
import { PackageConfigData } from '../types/package-config-data'
import { NavigationService } from '../../../services/navigation.service'
import { toRouterLink } from '../../../utils/to-router-link'
@Pipe({
name: 'toDependencies',
standalone: true,
})
export class ToDependenciesPipe implements PipeTransform {
constructor(
private readonly router: Router,
private readonly formDialog: FormDialogService,
private readonly navigation: NavigationService,
) {}
transform(pkg: PackageDataEntry): DependencyInfo[] | null {
if (!pkg.installed) return null
const deps = Object.keys(pkg.installed['current-dependencies'])
.filter(depId => pkg.manifest.dependencies[depId])
.map(depId => this.setDepValues(pkg, depId))
return deps.length ? deps : null
}
private setDepValues(pkg: PackageDataEntry, id: string): DependencyInfo {
const error = pkg.installed!.status['dependency-errors'][id]
const depInfo = pkg.installed!['dependency-info'][id]
const version = pkg.manifest.dependencies[id].version
const title = depInfo?.title || id
const icon = depInfo?.icon || ''
let errorText = ''
let actionText = 'View'
let action = () => {
this.navigation.addTab({ icon, title, routerLink: toRouterLink(id) })
this.router.navigate([`portal`, `service`, id])
}
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', id)
// incorrect version
} else if (error.type === DependencyErrorType.IncorrectVersion) {
errorText = 'Incorrect version'
actionText = 'Update'
action = () => this.fixDep(pkg, 'update', id)
// not running
} else if (error.type === DependencyErrorType.NotRunning) {
errorText = 'Not running'
actionText = 'Start'
// config unsatisfied
} else if (error.type === DependencyErrorType.ConfigUnsatisfied) {
errorText = 'Config not satisfied'
actionText = 'Auto config'
action = () => this.fixDep(pkg, 'configure', id)
} else if (error.type === DependencyErrorType.Transitive) {
errorText = 'Dependency has a dependency issue'
}
errorText = `${errorText}. ${pkg.manifest.title} will not work as expected.`
}
return { id, icon, title, version, 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>(ServiceConfigModal, {
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.router.navigate(['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>(ServiceConfigModal, {
label: 'Config',
data: {
pkgId: dependencyId,
dependentInfo,
},
})
}
}

View File

@@ -1,16 +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',
standalone: true,
})
export class ToStatusPipe implements PipeTransform {
transform(pkg: PackageDataEntry): PackageStatus {
return renderPkgStatus(pkg)
}
}

View File

@@ -136,13 +136,13 @@ export class ServiceActionsRoute {
}
async tryUninstall(pkg: PackageDataEntry): Promise<void> {
const { title, alerts, id } = pkg.manifest
const { title, alerts } = pkg.manifest
let content =
alerts.uninstall ||
`Uninstalling ${title} will permanently delete its data`
if (await hasCurrentDeps(this.patch, id)) {
if (hasCurrentDeps(pkg)) {
content = `${content}. Services that depend on ${title} will no longer work properly and may crash`
}

View File

@@ -1,12 +1,13 @@
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ActivatedRoute } from '@angular/router'
import { ActivatedRoute, NavigationExtras, Router } from '@angular/router'
import { getPkgId, isEmptyObject } from '@start9labs/shared'
import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs'
import { combineLatest, map } from 'rxjs'
import {
DataModel,
HealthCheckResult,
InstalledPackageInfo,
MainStatus,
PackageDataEntry,
PackageState,
@@ -16,6 +17,7 @@ import {
PrimaryRendering,
PrimaryStatus,
StatusRendering,
renderPkgStatus,
} from 'src/app/services/pkg-status-rendering.service'
import { ConnectionService } from 'src/app/services/connection.service'
import { ServiceProgressComponent } from '../components/progress.component'
@@ -27,8 +29,19 @@ import { ServiceDependenciesComponent } from '../components/dependencies.compone
import { ServiceMenuComponent } from '../components/menu.component'
import { ServiceAdditionalComponent } from '../components/additional.component'
import { ProgressDataPipe } from '../pipes/progress-data.pipe'
import { ToDependenciesPipe } from '../pipes/to-dependencies.pipe'
import { ToStatusPipe } from '../pipes/to-status.pipe'
import {
DepErrorService,
DependencyErrorType,
PkgDependencyErrors,
} from 'src/app/services/dep-error.service'
import { DependencyInfo } from '../types/dependency-info'
import { Manifest } from '@start9labs/marketplace'
import { NavigationService } from '../../../services/navigation.service'
import { toRouterLink } from '../../../utils/to-router-link'
import { PackageConfigData } from '../types/package-config-data'
import { ServiceConfigModal } from '../modals/config.component'
import { DependentInfo } from 'src/app/types/dependent-info'
import { FormDialogService } from 'src/app/services/form-dialog.service'
const STATES = [
PackageState.Installing,
@@ -39,8 +52,8 @@ const STATES = [
@Component({
template: `
<ng-container *ngIf="service$ | async as service">
<ng-container *ngIf="showProgress(service); else installed">
<ng-container *ngIf="service | progressData as progress">
<ng-container *ngIf="showProgress(service.pkg); else installed">
<ng-container *ngIf="service.pkg | progressData as progress">
<p [progress]="progress.downloadProgress">Downloading</p>
<p [progress]="progress.validateProgress">Validating</p>
<p [progress]="progress.unpackProgress">Unpacking</p>
@@ -48,31 +61,28 @@ const STATES = [
</ng-container>
<ng-template #installed>
<ng-container *ngIf="service | toStatus as status">
<h3 class="g-title">Status</h3>
<service-status
[connected]="!!(connected$ | async)"
[installProgress]="service['install-progress']"
[rendering]="$any(getRendering(status))"
/>
<service-actions
*ngIf="isInstalled(service) && (connected$ | async)"
[service]="service"
/>
<h3 class="g-title">Status</h3>
<service-status
[connected]="!!(connected$ | async)"
[installProgress]="service.pkg['install-progress']"
[rendering]="$any(getRendering(service.status))"
/>
<service-actions
*ngIf="isInstalled(service.pkg) && (connected$ | async)"
[service]="service"
/>
<ng-container *ngIf="isInstalled(service) && !isBackingUp(status)">
<service-interfaces [service]="service" />
<service-health-checks
*ngIf="isRunning(status) && (health$ | async) as checks"
[checks]="checks"
/>
<service-dependencies
*ngIf="service | toDependencies as dependencies"
[dependencies]="dependencies"
/>
<service-menu [service]="service" />
<service-additional [service]="service" />
</ng-container>
<ng-container
*ngIf="isInstalled(service.pkg) && !isBackingUp(service.status)"
>
<service-interfaces [service]="service" />
<service-health-checks
*ngIf="isRunning(service.status) && (health$ | async) as checks"
[checks]="checks"
/>
<service-dependencies [dependencies]="service.dependencies" />
<service-menu [service]="service.pkg" />
<service-additional [service]="service.pkg" />
</ng-container>
</ng-template>
</ng-container>
@@ -92,16 +102,29 @@ const STATES = [
ServiceAdditionalComponent,
ProgressDataPipe,
ToDependenciesPipe,
ToStatusPipe,
],
})
export class ServiceRoute {
private readonly patch = inject(PatchDB<DataModel>)
private readonly pkgId = getPkgId(inject(ActivatedRoute))
private readonly depErrorService = inject(DepErrorService)
private readonly navigation = inject(NavigationService)
private readonly router = inject(Router)
private readonly formDialog = inject(FormDialogService)
readonly connected$ = inject(ConnectionService).connected$
readonly service$ = this.patch.watch$('package-data', this.pkgId)
readonly service$ = combineLatest([
this.patch.watch$('package-data', this.pkgId),
this.depErrorService.getPkgDepErrors$(this.pkgId),
]).pipe(
map(([pkg, depErrors]) => {
return {
pkg,
dependencies: this.getDepInfo(pkg, depErrors),
status: renderPkgStatus(pkg, depErrors),
}
}),
)
readonly health$ = this.patch
.watch$('package-data', this.pkgId, 'installed', 'status', 'main')
.pipe(map(toHealthCheck))
@@ -125,6 +148,141 @@ export class ServiceRoute {
showProgress({ state }: PackageDataEntry): boolean {
return STATES.includes(state)
}
private getDepInfo(
pkg: PackageDataEntry,
depErrors: PkgDependencyErrors,
): 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: PkgDependencyErrors,
): DependencyInfo {
const { errorText, fixText, fixAction } = this.getDepErrors(
pkgInstalled,
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.navigation.addTab({
icon: depInfo.icon,
title: depInfo.title,
routerLink: toRouterLink(depId),
})
this.router.navigate([`portal`, `service`, depId])
}),
}
}
private getDepErrors(
pkgInstalled: InstalledPackageInfo,
pkgManifest: Manifest,
depId: string,
depErrors: PkgDependencyErrors,
) {
const depError = (depErrors[pkgManifest.id] as any)?.[depId] // @TODO fix
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(pkgInstalled, pkgManifest, 'install', depId)
} else if (depError.type === DependencyErrorType.IncorrectVersion) {
errorText = 'Incorrect version'
fixText = 'Update'
fixAction = () =>
this.fixDep(pkgInstalled, pkgManifest, 'update', depId)
} else if (depError.type === DependencyErrorType.ConfigUnsatisfied) {
errorText = 'Config not satisfied'
fixText = 'Auto config'
fixAction = () =>
this.fixDep(pkgInstalled, 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,
}
}
async fixDep(
pkgInstalled: InstalledPackageInfo,
pkgManifest: Manifest,
action: 'install' | 'update' | 'configure',
depId: string,
): Promise<void> {
switch (action) {
case 'install':
case 'update':
return this.installDep(pkgManifest, depId)
case 'configure':
return this.formDialog.open<PackageConfigData>(ServiceConfigModal, {
label: `${pkgInstalled!['dependency-info'][depId].title} config`,
data: {
pkgId: depId,
dependentInfo: pkgManifest,
},
})
}
}
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.router.navigate(['marketplace', depId], navigationExtras)
}
}
function toHealthCheck(main: MainStatus): HealthCheckResult[] | null {

View File

@@ -8,8 +8,6 @@ import {
} from '@start9labs/marketplace'
import {
EmverPipesModule,
isEmptyObject,
LoadingService,
MarkdownPipeModule,
SafeLinksDirective,
SharedPipesModule,
@@ -27,16 +25,9 @@ import {
TuiProgressModule,
} from '@taiga-ui/kit'
import { NgDompurifyModule } from '@tinkoff/ng-dompurify'
import { PatchDB } from 'patch-db-client'
import {
DataModel,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
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 { getAllPackages } from 'src/app/util/get-package-data'
import { InstallProgressPipe } from '../pipes/install-progress.pipe'
@Component({
@@ -44,15 +35,15 @@ import { InstallProgressPipe } from '../pipes/install-progress.pipe'
template: `
<tui-accordion-item borders="top-bottom">
<div class="g-action">
<tui-avatar size="s" [src]="pkg | mimeType | trustUrl" />
<tui-avatar size="s" [src]="marketplacePkg | mimeType | trustUrl" />
<div [style.flex]="1" [style.overflow]="'hidden'">
<strong>{{ pkg.manifest.title }}</strong>
<strong>{{ marketplacePkg.manifest.title }}</strong>
<div>
<!-- @TODO left side should be local['old-manifest'] (or whatever), not manifest. -->
{{ local.manifest.version || '' | displayEmver }}
{{ localPkg.manifest.version || '' | displayEmver }}
<tui-svg src="tuiIconArrowRight"></tui-svg>
<span [style.color]="'var(--tui-positive)'">
{{ pkg.manifest.version | displayEmver }}
{{ marketplacePkg.manifest.version | displayEmver }}
</span>
</div>
<div [style.color]="'var(--tui-negative)'">
@@ -60,10 +51,10 @@ import { InstallProgressPipe } from '../pipes/install-progress.pipe'
</div>
</div>
<tui-progress-circle
*ngIf="local.state === 'updating'; else button"
*ngIf="localPkg.state === 'updating'; else button"
style="color: var(--tui-positive)"
[max]="100"
[value]="local['install-progress'] | installProgress"
[value]="localPkg['install-progress'] | installProgress"
></tui-progress-circle>
<ng-template #button>
<button
@@ -84,13 +75,15 @@ import { InstallProgressPipe } from '../pipes/install-progress.pipe'
<strong>What's new</strong>
<p
safeLinks
[innerHTML]="pkg.manifest['release-notes'] | markdown | dompurify"
[innerHTML]="
marketplacePkg.manifest['release-notes'] | markdown | dompurify
"
></p>
<a
tuiLink
iconAlign="right"
icon="tuiIconExternalLink"
[routerLink]="'/marketplace/' + pkg.manifest.id"
[routerLink]="'/marketplace/' + marketplacePkg.manifest.id"
[queryParams]="{ url: url }"
>
View listing
@@ -131,77 +124,49 @@ import { InstallProgressPipe } from '../pipes/install-progress.pipe'
],
})
export class UpdatesItemComponent {
private readonly api = inject(ApiService)
private readonly dialogs = inject(TuiDialogService)
private readonly loader = inject(LoadingService)
private readonly patch = inject(PatchDB<DataModel>)
private readonly marketplace = inject(
AbstractMarketplaceService,
) as MarketplaceService
@Input({ required: true })
pkg!: MarketplacePkg
marketplacePkg!: MarketplacePkg
@Input({ required: true })
local!: PackageDataEntry
localPkg!: PackageDataEntry
@Input({ required: true })
url = ''
get errors(): string {
return this.marketplace.updateErrors[this.pkg.manifest.id]
return this.marketplace.updateErrors[this.marketplacePkg.manifest.id]
}
get ready(): boolean {
return !this.marketplace.updateQueue[this.pkg.manifest.id]
return !this.marketplace.updateQueue[this.marketplacePkg.manifest.id]
}
async onClick() {
const { id, version } = this.pkg.manifest
const { id } = this.marketplacePkg.manifest
delete this.marketplace.updateErrors[id]
this.marketplace.updateQueue[id] = true
if (await hasCurrentDeps(this.patch, this.local.manifest.id)) {
await this.dry()
if (hasCurrentDeps(this.localPkg)) {
const proceed = await this.alert()
if (proceed) {
await this.update()
} else {
delete this.marketplace.updateQueue[id]
}
} else {
await this.update()
}
}
private async dry() {
const { id, version } = this.pkg.manifest
const loader = this.loader
.open('Checking dependent services...')
.subscribe()
try {
const breakages = await this.api.dryUpdatePackage({
id,
version,
})
loader.unsubscribe()
if (isEmptyObject(breakages)) {
await this.update()
} else {
const proceed = await this.alert(breakages)
if (proceed) {
await this.update()
} else {
delete this.marketplace.updateQueue[id]
}
}
} catch (e: any) {
delete this.marketplace.updateQueue[id]
this.marketplace.updateErrors[id] = e.message
loader.unsubscribe()
}
}
private async update() {
const { id, version } = this.pkg.manifest
const { id, version } = this.marketplacePkg.manifest
try {
await this.marketplace.installPackage(id, version, this.url)
@@ -212,20 +177,14 @@ export class UpdatesItemComponent {
}
}
private async alert(breakages: Breakages): Promise<boolean> {
const content: string = `As a result of updating ${this.pkg.manifest.title}, the following services will no longer work properly and may crash:<ul>`
const local = await getAllPackages(this.patch)
const bullets = Object.keys(breakages)
.map(id => `<li><b>${local[id].manifest.title}</b></li>`)
.join('')
private async alert(): Promise<boolean> {
return new Promise(async resolve => {
this.dialogs
.open<boolean>(TUI_PROMPT, {
label: 'Warning',
size: 's',
data: {
content: `${content}${bullets}</ul>`,
content: `Services that depend on ${this.localPkg.manifest.title} will no longer work properly and may crash`,
yes: 'Continue',
no: 'Cancel',
},

View File

@@ -25,11 +25,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 { TUI_PROMPT } from '@taiga-ui/kit'
import { dryUpdate } from 'src/app/util/dry-update'
@Component({
selector: 'marketplace-show-controls',
@@ -59,7 +58,6 @@ export class MarketplaceShowControlsComponent {
private readonly loader: LoadingService,
private readonly emver: Emver,
private readonly errorService: ErrorService,
private readonly embassyApi: ApiService,
private readonly patch: PatchDB<DataModel>,
) {}
@@ -86,10 +84,11 @@ export class MarketplaceShowControlsComponent {
if (!proceed) return
}
const { id, version } = this.pkg.manifest
const currentDeps = await hasCurrentDeps(this.patch, id)
if (currentDeps && this.emver.compare(this.localVersion, version) !== 0) {
const currentDeps = hasCurrentDeps(this.localPkg)
if (
currentDeps &&
this.emver.compare(this.localVersion, this.pkg.manifest.version) !== 0
) {
this.dryInstall(url)
} else {
this.install(url)
@@ -131,29 +130,19 @@ export class MarketplaceShowControlsComponent {
}
private async dryInstall(url: string) {
const loader = this.loader
.open('Checking dependent services...')
.subscribe()
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 {
loader.unsubscribe()
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.errorService.handleError(e)
}
}
@@ -200,14 +189,10 @@ export class MarketplaceShowControlsComponent {
}
}
private async presentAlertBreakages(breakages: Breakages): Promise<boolean> {
private async presentAlertBreakages(breakages: string[]): Promise<boolean> {
let content: 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>`)
content = `${content}${bullets.join('')}</ul>`
return new Promise(async resolve => {

View File

@@ -103,7 +103,7 @@ export class AppActionsPage {
alerts.uninstall ||
`Uninstalling ${title} will permanently delete its data`
if (await hasCurrentDeps(this.patch, id)) {
if (hasCurrentDeps(pkg)) {
content = `${content}. Services that depend on ${title} will no longer work properly and may crash`
}

View File

@@ -1,3 +1,4 @@
.metric-note {
ion-note {
font-size: 16px;
color: white;
}

View File

@@ -27,7 +27,7 @@
sizeMd="6"
>
<app-list-pkg
*ngIf="pkg | packageInfo | async as info"
*ngIf="pkg.manifest.id | packageInfo | async as info"
[pkg]="info"
></app-list-pkg>
</ion-col>

View File

@@ -1,22 +1,25 @@
import { Pipe, PipeTransform } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import { filter, map, startWith, Observable } from 'rxjs'
import {
DataModel,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { Observable, combineLatest } from 'rxjs'
import { filter, map } from 'rxjs/operators'
import { DataModel } 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))
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

@@ -22,8 +22,6 @@ import {
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 { 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 '../app-list/app-list-pkg/launch-menu/launch-menu.module'
@@ -40,8 +38,6 @@ const routes: Routes = [
AppShowPage,
HealthColorPipe,
ProgressDataPipe,
ToDependenciesPipe,
ToStatusPipe,
AppShowHeaderComponent,
AppShowProgressComponent,
AppShowStatusComponent,
@@ -64,6 +60,6 @@ const routes: Routes = [
InsecureWarningComponentModule,
LaunchMenuComponentModule,
],
exports: [InterfaceInfoPipe, ToStatusPipe],
exports: [InterfaceInfoPipe],
})
export class AppShowPageModule {}

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,43 +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)">
<!-- ** interfaces ** -->
<app-show-interfaces [pkg]="pkg.installed!"></app-show-interfaces>
<!-- ** 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 [pkg]="pkg"></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 [pkg]="pkg"></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,
PkgDependencyErrors,
} from 'src/app/services/dep-error.service'
import { Observable, 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,22 +51,31 @@ 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.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 config: ConfigService,
private readonly depErrorService: DepErrorService,
private readonly formDialog: FormDialogService,
) {}
isInstalled({ state }: PackageDataEntry): boolean {
@@ -60,4 +93,140 @@ export class AppShowPage {
showProgress({ state }: PackageDataEntry): boolean {
return STATES.includes(state)
}
private getDepInfo(
pkg: PackageDataEntry,
depErrors: PkgDependencyErrors,
): 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: PkgDependencyErrors,
): 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: PkgDependencyErrors,
) {
const depError = (depErrors[pkgManifest.id] as any)?.[depId] // @TODO fix
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(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

@@ -14,11 +14,16 @@
<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="danger" (click)="tryStop()">
<ion-icon slot="start" name="stop-outline"></ion-icon>
Stop
</ion-button>
<ion-button
class="action-button"
color="tertiary"

View File

@@ -27,7 +27,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 { LaunchMenuComponent } from '../../../app-list/app-list-pkg/launch-menu/launch-menu.component'
@@ -47,8 +46,7 @@ export class AppShowStatusComponent {
@Input({ required: true })
status!: PackageStatus
@Input()
dependencies: DependencyInfo[] = []
PR = PrimaryRendering
readonly connected$ = this.connectionService.connected$
@@ -82,6 +80,14 @@ export class AppShowStatusComponent {
return this.status.primary === PrimaryStatus.Running
}
get canStop(): boolean {
return [
PrimaryStatus.Running,
PrimaryStatus.Starting,
PrimaryStatus.Restarting,
].includes(this.status.primary as PrimaryStatus)
}
get isStopped(): boolean {
return this.status.primary === PrimaryStatus.Stopped
}
@@ -98,7 +104,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)
@@ -117,10 +123,10 @@ export class AppShowStatusComponent {
}
async tryStop(): Promise<void> {
const { title, alerts, id } = this.pkg.manifest
const { title, alerts } = this.pkg.manifest
let content = alerts.stop || ''
if (await hasCurrentDeps(this.patch, id)) {
if (hasCurrentDeps(this.pkg)) {
const depMessage = `Services that depend on ${title} will no longer work properly and may crash`
content = content ? `${content}.\n\n${depMessage}` : depMessage
}
@@ -144,15 +150,13 @@ export class AppShowStatusComponent {
}
async tryRestart(): Promise<void> {
const { id, title } = this.pkg.manifest
if (await hasCurrentDeps(this.patch, id)) {
if (hasCurrentDeps(this.pkg)) {
this.dialogs
.open(TUI_PROMPT, {
label: 'Warning',
size: 's',
data: {
content: `Services that depend on ${title} may temporarily experiences issues`,
content: `Services that depend on ${this.pkg.manifest} may temporarily experiences issues`,
yes: 'Restart',
no: 'Cancel',
},

View File

@@ -109,7 +109,7 @@ export class AppConfigPage {
try {
await this.uploadFiles(config, loader)
if (await hasCurrentDeps(this.patchDb, this.pkgId)) {
if (hasCurrentDeps(this.pkg!)) {
await this.configureDeps(config, loader)
} else {
await this.configure(config, loader)

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

@@ -6,18 +6,25 @@
[style.font-style]="style"
[style.font-weight]="weight"
>
<span *ngIf="!installProgress">
{{ (connected$ | async) ? rendering.display : 'Unknown' }}
<span *ngIf="rendering.showDots" class="loading-dots"></span>
{{ (connected$ | async) ? rendering.display : 'Unknown' }}
<span
*ngIf="
rendering.display === PR[PS.Stopping].display &&
(sigtermTimeout | durationToSeconds) > 30
"
>
this may take a while
</span>
<span *ngIf="installProgress">
<ion-text
*ngIf="installProgress | installProgressDisplay as progress"
color="primary"
>
Installing
<span class="loading-dots"></span>
{{ progress }}
</ion-text>
</span>
<span *ngIf="rendering.showDots" class="loading-dots"></span>
</p>

View File

@@ -1,7 +1,7 @@
<logs
[fetchLogs]="fetchLogs()"
[followLogs]="followLogs()"
context="eos"
context="start-os"
defaultBack="system"
pageTitle="OS Logs"
class="ion-page"

View File

@@ -1,7 +1,7 @@
import { Component } from '@angular/core'
import { Metrics } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { TimeInfo, TimeService } from 'src/app/services/time-service'
import { TimeService } from 'src/app/services/time-service'
import {
catchError,
combineLatest,
@@ -29,9 +29,24 @@ export class ServerMetricsPage {
private readonly connectionService: ConnectionService,
) {}
private getServerData$(): Observable<[TimeInfo, Metrics]> {
private getServerData$(): Observable<
[
{
value: number
synced: boolean
},
{
days: number
hours: number
minutes: number
seconds: number
},
Metrics,
]
> {
return combineLatest([
this.timeService.getTimeInfo$(),
this.timeService.now$,
this.timeService.uptime$,
this.getMetrics$(),
]).pipe(
catchError(() => {

View File

@@ -14,12 +14,52 @@
</ng-template>
<!-- loaded -->
<div
*ngIf="server$ | async as server; else loading"
class="cap-width"
style="padding-top: 0"
>
<insecure-warning *ngIf="!secure"></insecure-warning>
<ion-item-group *ngIf="server$ | async as server; else loading">
<ion-item
*ngIf="!server['ntp-synced']"
color="warning"
class="ion-margin-bottom"
>
<ion-icon slot="start" name="warning-outline"></ion-icon>
<ion-label>
<h2 style="font-weight: bold">Clock sync failure</h2>
<p style="font-weight: 600">
This will cause connectivity issues. Refer to the StartOS docs to
resolve the issue.
</p>
</ion-label>
<ion-button
slot="end"
color="light"
href="https://docs.start9.com/0.3.5.x/support/common-issues#clock-sync-failure"
target="_blank"
rel="noreferrer"
>
Open Docs
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-button>
</ion-item>
<ion-item *ngIf="isTorHttp" color="warning" class="ion-margin-bottom">
<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>
@@ -80,5 +120,5 @@
</ion-item>
</ion-item-group>
</div>
</div>
</ion-item-group>
</ion-content>

View File

@@ -21,7 +21,7 @@ import { TuiAlertService, TuiDialogService } from '@taiga-ui/core'
import { PROMPT } from 'src/app/apps/ui/modals/prompt/prompt.component'
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
import { TUI_PROMPT } from '@taiga-ui/kit'
import { DOCUMENT } from '@angular/common'
import { WINDOW } from '@ng-web-apis/common'
import { getServerInfo } from 'src/app/util/get-server-info'
import * as argon2 from '@start9labs/argon2'
import { ProxyService } from 'src/app/services/proxy.service'
@@ -39,9 +39,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 dialogs: TuiDialogService,
@@ -58,7 +56,7 @@ export class ServerShowPage {
private readonly config: ConfigService,
private readonly formDialog: FormDialogService,
private readonly proxyService: ProxyService,
@Inject(DOCUMENT) private readonly document: Document,
@Inject(WINDOW) private readonly windowRef: Window,
) {}
addClick(title: string) {
@@ -254,6 +252,11 @@ export class ServerShowPage {
.subscribe(() => this.systemRebuild())
}
async launchHttps() {
const info = await getServerInfo(this.patch)
this.windowRef.open(`https://${info.ui.torHostname}`, '_self')
}
private async setName(value: string | null): Promise<void> {
const loader = this.loader.open('Saving...').subscribe()
@@ -276,7 +279,6 @@ export class ServerShowPage {
try {
await this.api.restartServer({})
this.presentAlertInProgress(action, ` until ${action} completes.`)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
@@ -290,10 +292,6 @@ export class ServerShowPage {
try {
await this.api.shutdownServer({})
this.presentAlertInProgress(
action,
'.<br /><br /><b>You will need to physically power cycle the device to regain connectivity.</b>',
)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
@@ -307,7 +305,6 @@ export class ServerShowPage {
try {
await this.api.systemRebuild({})
this.presentAlertInProgress(action, ` until ${action} completes.`)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
@@ -343,18 +340,6 @@ export class ServerShowPage {
.subscribe()
}
private presentAlertInProgress(verb: string, message: string) {
this.dialogs
.open(
`Stopping all services gracefully. This can take a while.<br /><br />If you have a speaker, your server will <b>♫ play a melody ♫</b> before shutting down. Your server will then become unreachable${message}`,
{
label: `${verb} In Progress...`,
size: 's',
},
)
.subscribe()
}
settings: ServerSettings = {
General: [
{
@@ -404,7 +389,7 @@ export class ServerShowPage {
icon: 'key-outline',
action: () => this.presentAlertResetPassword(),
detail: false,
disabled$: of(!this.secure),
disabled$: of(false),
},
{
title: 'Experimental Features',
@@ -559,8 +544,8 @@ export class ServerShowPage {
description: 'Discover what StartOS can do',
icon: 'map-outline',
action: () =>
window.open(
'https://docs.start9.com/latest/user-manual',
this.windowRef.open(
'https://docs.start9.com/0.3.5.x/user-manual',
'_blank',
'noreferrer',
),
@@ -572,7 +557,11 @@ export class ServerShowPage {
description: 'Get help from the Start9 team and community',
icon: 'chatbubbles-outline',
action: () =>
window.open('https://start9.com/contact', '_blank', 'noreferrer'),
this.windowRef.open(
'https://start9.com/contact',
'_blank',
'noreferrer',
),
detail: true,
disabled$: of(false),
},
@@ -581,7 +570,7 @@ export class ServerShowPage {
description: `Support StartOS development`,
icon: 'logo-bitcoin',
action: () =>
this.document.defaultView?.open(
this.windowRef.open(
'https://donate.start9.com',
'_blank',
'noreferrer',

View File

@@ -13,7 +13,7 @@ import { TUI_PROMPT } from '@taiga-ui/kit'
styleUrls: ['ssh-keys.page.scss'],
})
export class SSHKeysPage {
readonly docsUrl = 'https://docs.start9.com/latest/user-manual/ssh'
readonly docsUrl = 'https://docs.start9.com/0.3.5.x/user-manual/ssh'
sshKeys: SSHKey[] = []
loading$ = new BehaviorSubject(true)

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,15 +11,16 @@ import {
Marketplace,
StoreIdentity,
} from '@start9labs/marketplace'
import { isEmptyObject, LoadingService } from '@start9labs/shared'
import { LoadingService } from '@start9labs/shared'
import { TuiDialogService } from '@taiga-ui/core'
import { combineLatest, filter, Observable } from 'rxjs'
import { NavController } from '@ionic/angular'
import { hasCurrentDeps } from 'src/app/util/has-deps'
import { getAllPackages } from 'src/app/util/get-package-data'
import { Breakages } from 'src/app/services/api/api.types'
import { ConfigService } from 'src/app/services/config.service'
import { TUI_PROMPT } from '@taiga-ui/kit'
import { Emver, isEmptyObject } from '@start9labs/shared'
import { combineLatest, Observable } from 'rxjs'
import { dryUpdate } from 'src/app/util/dry-update'
interface UpdatesData {
hosts: StoreIdentity[]
@@ -45,11 +45,11 @@ export class UpdatesPage {
constructor(
@Inject(AbstractMarketplaceService)
readonly marketplaceService: MarketplaceService,
private readonly api: ApiService,
private readonly patch: PatchDB<DataModel>,
private readonly navCtrl: NavController,
private readonly loader: LoadingService,
private readonly dialogs: TuiDialogService,
private readonly emver: Emver,
readonly config: ConfigService,
) {}
@@ -74,55 +74,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 = this.loader
.open('Checking dependent services...')
.subscribe()
const { id, version } = manifest
private async dryInstall(manifest: Manifest, url: string) {
const { id, version, title } = manifest
try {
const breakages = await this.api.dryUpdatePackage({
id,
version: `${version}`,
})
loader.unsubscribe()
const breakages = dryUpdate(
manifest,
await getAllPackages(this.patch),
this.emver,
)
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
loader.unsubscribe()
}
}
private async presentAlertBreakages(
title: string,
breakages: Breakages,
breakages: string[],
): Promise<boolean> {
let content: 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>`
})
content = `${content}${bullets.join('')}</ul>`
@@ -141,7 +127,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,6 +1,6 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs'
import { map } from 'rxjs/operators'
import {
DataModel,
PackageDataEntry,
@@ -8,6 +8,8 @@ import {
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'
import { combineLatest } from 'rxjs'
import { DepErrorService } from 'src/app/services/dep-error.service'
@Component({
selector: 'widget-health',
@@ -24,29 +26,32 @@ export class HealthComponent {
'Transitioning',
] as const
readonly data$ = inject(PatchDB<DataModel>)
.watch$('package-data')
.pipe(
map(data => {
const pkgs = Object.values<PackageDataEntry>(data).map(getPackageInfo)
const result = this.labels.reduce<Record<string, number>>(
(acc, label) => ({
...acc,
[label]: this.getCount(label, pkgs),
}),
{},
)
readonly data$ = combineLatest([
inject(PatchDB<DataModel>).watch$('package-data'),
inject(DepErrorService).depErrors$,
]).pipe(
map(([data, depErrors]) => {
const pkgs = Object.values<PackageDataEntry>(data).map(pkg =>
getPackageInfo(pkg, depErrors[pkg.manifest.id]),
)
const result = this.labels.reduce<Record<string, number>>(
(acc, label) => ({
...acc,
[label]: this.getCount(label, pkgs),
}),
{},
)
result['Healthy'] =
pkgs.length -
result['Error'] -
result['Needs Attention'] -
result['Stopped'] -
result['Transitioning']
result['Healthy'] =
pkgs.length -
result['Error'] -
result['Needs Attention'] -
result['Stopped'] -
result['Transitioning']
return this.labels.map(label => result[label])
}),
)
return this.labels.map(label => result[label])
}),
)
private getCount(label: string, pkgs: PkgInfo[]): number {
switch (label) {

View File

@@ -1,10 +1,9 @@
<h2>This Release</h2>
<h4>0.3.4.4</h4>
<h4>0.3.5</h4>
<p class="note-padding">
View the complete
<a
href="https://github.com/Start9Labs/start-os/releases/tag/v0.3.4.4"
href="https://github.com/Start9Labs/start-os/releases/tag/v0.3.5"
target="_blank"
noreferrer
>
@@ -14,81 +13,22 @@
</p>
<h6>Highlights</h6>
<ul class="spaced-list">
<li>Https over Tor for faster UI loading times</li>
<li>Change password through UI</li>
<li>Use IP address for Network Folder backups</li>
<li>
Multiple bug fixes, performance enhancements, and other small features
This release contains significant under-the-hood improvements to performance
and reliability
</li>
<li>Ditch Docker, replace with Podman</li>
<li>Remove locking behavior from PatchDB and optimize</li>
<li>Boost efficiency of service manager</li>
<li>Require HTTPS on LAN, and improve setup flow for trusting Root CA</li>
<li>Better default privacy settings for Firefox kiosk mode</li>
<li>Eliminate memory leak from Javascript runtime</li>
<li>Other small bug fixes</li>
<li>Update license to MIT</li>
</ul>
<h2>Previous Releases</h2>
<h4>0.3.4.3</h4>
<p class="note-padding">
View the complete
<a
href="https://github.com/Start9Labs/start-os/releases/tag/v0.3.4.3"
target="_blank"
noreferrer
>
release notes
</a>
for more details.
</p>
<h6>Highlights</h6>
<ul class="spaced-list">
<li>Improved Tor reliability</li>
<li>Experimental features tab</li>
<li>Multiple bugfixes and general performance enhancements</li>
<li>Update branding</li>
</ul>
<h4>0.3.4.2</h4>
<p class="note-padding">
View the complete
<a
href="https://github.com/Start9Labs/start-os/releases/tag/v0.3.4.2"
target="_blank"
noreferrer
>
release notes
</a>
for more details.
</p>
<h6>Highlights</h6>
<ul class="spaced-list">
<li>Update build system for Server Lite and NUC-based Server One</li>
<li>Rename embassyOS to StartOS</li>
<li>
PWA support for StartOS web interface. You can now save StartOS to your
phone as an app!
</li>
</ul>
<h4>0.3.4</h4>
<p class="note-padding">
View the complete
<a
href="https://github.com/Start9Labs/start-os/releases/tag/v0.3.4"
target="_blank"
noreferrer
>
release notes
</a>
for more details.
</p>
<h6>Highlights</h6>
<ul class="spaced-list">
<li>Security patches</li>
<li>Bug fixes</li>
<li>Breakout services to Community Registry</li>
<li>SSL support for IP access</li>
<li>UI display improvements</li>
<li>Better logs</li>
<li>New system metrics</li>
<li>EFI support</li>
</ul>
<button tuiButton class="begin" (click)="context.$implicit.complete()">
Begin
</button>
<div class="ion-text-center ion-padding">
<button tuiButton class="begin" (click)="context.$implicit.complete()">
Begin
</button>
</div>

View File

@@ -19,33 +19,35 @@ ion-card {
font-family: 'Open Sans', sans-serif;
padding: 0.6rem;
font-weight: 600;
font-size: calc(12px + 0.5vw);
height: 3rem;
height: 2.4rem;
}
ion-card-content {
min-height: 9rem;
min-height: 8rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
ion-icon {
font-size: calc(90px + 0.5vw);
font-size: calc(90px + 0.4vw);
--ionicon-stroke-width: 1rem;
}
}
ion-footer {
padding: 1rem;
font-family: 'Open Sans', sans-serif;
padding: 0 1rem;
font-family: 'Open Sans';
font-size: clamp(1rem, calc(12px + 0.5vw), 1.3rem);
height: 9rem;
height: 4.5rem;
width: clamp(13rem, 80%, 18rem);
margin: 0 auto;
* {
max-width: 100%;
}
p {
margin-top: 0;
}
}
.footer-md::before {
@@ -54,10 +56,6 @@ ion-card {
}
@media (max-width: 900px) {
ion-card-title,
ion-footer {
height: auto !important;
}
ion-footer {
width: 10rem;
}

View File

@@ -1,14 +1,7 @@
<div #gridContent>
<ion-grid>
<ion-row class="ion-justify-content-center ion-align-items-center">
<ion-col
*ngFor="let card of cards"
responsiveCol
sizeLg="4"
sizeSm="6"
sizeXs="12"
class="ion-align-self-center"
>
<ion-col *ngFor="let card of cards" sizeXs="12">
<widget-card
[cardDetails]="card"
[containerDimensions]="containerDimensions"

View File

@@ -3,11 +3,17 @@ ion-col {
--ion-grid-column-padding: 1rem;
}
@media (min-width: 1800px) {
@media (min-width: 1700px) {
div {
padding: 0 20%;
padding: 0 7%;
}
ion-col {
max-width: 24rem !important;
}
}
@media (min-width: 2000px) {
div {
padding: 0 12%;
}
}

View File

@@ -14,8 +14,8 @@ import { Card, Dimension } from './widget-card/widget-card.component'
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WidgetListComponent {
@ViewChild('gridContent') gridContent: ElementRef<HTMLElement> =
{} as ElementRef<HTMLElement>
@ViewChild('gridContent')
gridContent: ElementRef<HTMLElement> = {} as ElementRef<HTMLElement>
@HostListener('window:resize', ['$event'])
onResize() {
this.setContainerDimensions()
@@ -38,46 +38,46 @@ export class WidgetListComponent {
cards: Card[] = [
{
title: 'Visit the Marketplace',
title: 'Server Info',
icon: 'information-circle-outline',
color: 'var(--alt-green)',
description: 'View information about your server',
link: '/system/specs',
},
{
title: 'Browse',
icon: 'storefront-outline',
color: 'var(--alt-blue)',
description: 'Shop for your favorite open source services',
color: 'var(--alt-purple)',
description: 'Browse for services to install',
link: '/marketplace',
qp: { back: 'true' },
},
{
title: 'Root CA',
icon: 'ribbon-outline',
color: 'var(--alt-orange)',
description: `Download and trust your server's root certificate authority`,
link: '/system/root-ca',
},
{
title: 'Create Backup',
icon: 'duplicate-outline',
color: 'var(--alt-purple)',
color: 'var(--alt-blue)',
description: 'Back up StartOS and service data',
link: '/system/backup',
},
{
title: 'Server Info',
icon: 'information-circle-outline',
color: 'var(--alt-green)',
description: 'View basic information about your server',
link: '/system/specs',
title: 'Monitor',
icon: 'pulse-outline',
color: 'var(--alt-orange)',
description: `View your system resource usage`,
link: '/system/metrics',
},
{
title: 'User Manual',
icon: 'map-outline',
color: 'var(--alt-yellow)',
description: 'Discover what StartOS can do',
link: 'https://docs.start9.com/latest/user-manual/index',
link: 'https://docs.start9.com/0.3.5.x/user-manual/index',
},
{
title: 'Contact Support',
icon: 'chatbubbles-outline',
color: 'var(--alt-red)',
description: 'Get help from the Start9 team and community',
description: 'Get help from the Start9 community',
link: 'https://start9.com/contact',
},
]

View File

@@ -1,5 +1,4 @@
import {
DependencyErrorType,
HealthResult,
PackageDataEntry,
PackageMainStatus,
@@ -32,12 +31,14 @@ export module Mock {
'current-backup': null,
'update-progress': null,
updated: true,
restarting: false,
'shutting-down': false,
}
export const MarketplaceEos: RR.GetMarketplaceEosRes = {
version: '0.3.4.4',
version: '0.3.5',
headline: 'Our biggest release ever.',
'release-notes': {
'0.3.5': 'Some **Markdown** release _notes_ for 0.3.5',
'0.3.4.4': 'Some **Markdown** release _notes_ for 0.3.4.4',
'0.3.4.3': 'Some **Markdown** release _notes_ for 0.3.4.3',
'0.3.4.2': 'Some **Markdown** release _notes_ for 0.3.4.2',
@@ -381,29 +382,80 @@ export module Mock {
export function getMetrics(): Metrics {
return {
general: {
temperature: (Math.random() * 100).toFixed(1),
temperature: {
value: '66.8',
unit: '°C',
},
},
memory: {
'percentage-used': '20',
total: (Math.random() * 100).toFixed(2),
available: '18000',
used: '4000',
'swap-total': '1000',
'swap-free': Math.random().toFixed(2),
'swap-used': '0',
'percentage-used': {
value: '30.7',
unit: '%',
},
total: {
value: '31971.10',
unit: 'MiB',
},
available: {
value: '22150.66',
unit: 'MiB',
},
used: {
value: '8784.97',
unit: 'MiB',
},
'zram-total': {
value: '7992.00',
unit: 'MiB',
},
'zram-available': {
value: '7882.50',
unit: 'MiB',
},
'zram-used': {
value: '109.50',
unit: 'MiB',
},
},
cpu: {
'user-space': '100',
'kernel-space': '50',
'io-wait': String(Math.random() * 50),
idle: '80',
usage: '30',
'percentage-used': {
value: '8.4',
unit: '%',
},
'user-space': {
value: '7.0',
unit: '%',
},
'kernel-space': {
value: '1.4',
unit: '%',
},
wait: {
value: '0.5',
unit: '%',
},
idle: {
value: '91.1',
unit: '%',
},
},
disk: {
size: '1000',
used: '900',
available: '100',
'percentage-used': '90',
capacity: {
value: '1851.60',
unit: 'GB',
},
used: {
value: '859.02',
unit: 'GB',
},
available: {
value: '992.59',
unit: 'GB',
},
'percentage-used': {
value: '46.4',
unit: '%',
},
},
}
}
@@ -1258,7 +1310,7 @@ export module Mock {
},
},
},
'dependency-errors': {},
'dependency-config-errors': {},
},
interfaceInfo: {
rpc: {
@@ -1297,6 +1349,7 @@ export module Mock {
},
},
'current-dependencies': {},
'current-dependents': {},
'dependency-info': {},
'marketplace-url': 'https://registry.start9.com/',
'developer-key': 'developer-key',
@@ -1350,7 +1403,7 @@ export module Mock {
main: {
status: PackageMainStatus.Stopped,
},
'dependency-errors': {},
'dependency-config-errors': {},
},
interfaceInfo: {
rpc: {
@@ -1371,6 +1424,7 @@ export module Mock {
type: 'api',
},
},
'current-dependents': {},
'current-dependencies': {
bitcoind: {
'health-checks': [],
@@ -1378,8 +1432,8 @@ export module Mock {
},
'dependency-info': {
bitcoind: {
title: 'Bitcoin Core',
icon: 'assets/img/service-icons/bitcoind.png',
title: Mock.MockManifestBitcoind.title,
icon: 'assets/img/service-icons/bitcoind.svg',
},
},
'marketplace-url': 'https://registry.start9.com/',
@@ -1402,11 +1456,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',
},
},
interfaceInfo: {
@@ -1453,13 +1504,14 @@ export module Mock {
'health-checks': [],
},
},
'current-dependents': {},
'dependency-info': {
bitcoind: {
title: 'Bitcoin Core',
title: Mock.MockManifestBitcoind.title,
icon: 'assets/img/service-icons/bitcoind.svg',
},
'btc-rpc-proxy': {
title: 'Bitcoin Proxy',
title: Mock.MockManifestBitcoinProxy.title,
icon: 'assets/img/service-icons/btc-rpc-proxy.png',
},
},

View File

@@ -3,11 +3,11 @@ import { MarketplacePkg, StoreInfo, Manifest } from '@start9labs/marketplace'
import { InputSpec } from '@start9labs/start-sdk/lib/config/configTypes'
import {
DataModel,
DependencyError,
DomainInfo,
NetworkStrategy,
OsOutboundProxy,
ServiceOutboundProxy,
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'
@@ -25,7 +25,7 @@ export module RR {
// auth
export type LoginReq = {
password: Encrypted | string
password: string
metadata: SessionMetadata
} // auth.login - unauthed
export type loginRes = null
@@ -41,11 +41,14 @@ export module RR {
// server
export type EchoReq = { message: string } // server.echo
export type EchoReq = { message: string; timeout?: number } // server.echo
export type EchoRes = string
export type GetSystemTimeReq = {} // server.time
export type GetSystemTimeRes = string
export type GetSystemTimeRes = {
now: string
uptime: number // seconds
}
export type GetServerLogsReq = ServerLogsReq // server.logs & server.kernel-logs
export type GetServerLogsRes = LogsRes
@@ -309,9 +312,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 }
@@ -421,31 +421,36 @@ export interface ActionResponse {
qr: boolean
}
interface MetricData {
value: string
unit: string
}
export interface Metrics {
general: {
temperature: string
temperature: MetricData | null
}
memory: {
'percentage-used': string
total: string
available: string
used: string
'swap-total': string
'swap-free': string
'swap-used': string
total: MetricData
'percentage-used': MetricData
used: MetricData
available: MetricData
'zram-total': MetricData
'zram-used': MetricData
'zram-available': MetricData
}
cpu: {
'user-space': string
'kernel-space': string
'io-wait': string
idle: string
usage: string
'percentage-used': MetricData
idle: MetricData
'user-space': MetricData
'kernel-space': MetricData
wait: MetricData
}
disk: {
size: string
used: string
available: string
'percentage-used': string
capacity: MetricData
'percentage-used': MetricData
used: MetricData
available: MetricData
}
}
@@ -621,3 +626,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,29 +1,11 @@
import { BehaviorSubject, Observable } from 'rxjs'
import { 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
// for getting static files: ex icons, instructions, licenses
@@ -43,8 +25,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 +39,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>>
@@ -286,10 +266,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

@@ -1,6 +1,5 @@
import { Inject, Injectable } from '@angular/core'
import {
decodeBase64,
HttpOptions,
HttpService,
isRpcError,
@@ -14,7 +13,7 @@ import { ApiService } from './embassy-api.service'
import { BackupTargetType, Metrics, RR } from './api.types'
import { ConfigService } from '../config.service'
import { webSocket, WebSocketSubjectConfig } from 'rxjs/webSocket'
import { Observable } from 'rxjs'
import { Observable, filter, firstValueFrom } from 'rxjs'
import { AuthService } from '../auth.service'
import { DOCUMENT } from '@angular/common'
import { DataModel } from '../patch-db/data-model'
@@ -77,20 +76,8 @@ 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)
return this.rpcRequest({ method: 'auth.login', params })
}
async logout(params: RR.LogoutReq): Promise<RR.LogoutRes> {
@@ -113,8 +100,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 }, urlOverride)
}
openPatchWebsocket$(): Observable<Update<DataModel>> {
@@ -483,12 +470,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> {
@@ -588,41 +569,28 @@ export class LiveApiService extends ApiService {
private async rpcRequest<T>(
options: RPCOptions,
addHeader = true,
urlOverride?: string,
): Promise<T> {
if (addHeader) {
options.headers = {
'x-patch-sequence': String(this.patch.cache$.value.sequence),
...(options.headers || {}),
}
}
const res = await this.http.rpcRequest<T>(options, urlOverride)
const body = res.body
const res = await this.http.rpcRequest<T>(options)
const encodedUpdates = res.headers.get('x-patch-updates')
const encodedError = res.headers.get('x-patch-error')
if (encodedUpdates) {
const decoded = decodeBase64(encodedUpdates)
const updates: Update<DataModel>[] = JSON.parse(decoded)
this.patchStream$.next(updates)
}
if (encodedError) {
const error = decodeBase64(encodedError)
console.error(error)
}
const rpcRes = res.body
if (isRpcError(rpcRes)) {
if (rpcRes.error.code === 34) {
if (isRpcError(body)) {
if (body.error.code === 34) {
console.error('Unauthenticated, logging out')
this.auth.setUnverified()
}
throw new RpcError(rpcRes.error)
throw new RpcError(body.error)
}
return rpcRes.result
const patchSequence = res.headers.get('x-patch-sequence')
if (patchSequence)
await firstValueFrom(
this.patch.cache$.pipe(
filter(({ sequence }) => sequence >= Number(patchSequence)),
),
)
return body.result
}
private async httpRequest<T>(opts: HttpOptions): Promise<T> {

View File

@@ -10,7 +10,6 @@ import {
} from 'patch-db-client'
import {
DataModel,
DependencyErrorType,
InstallProgress,
PackageDataEntry,
PackageMainStatus,
@@ -26,7 +25,8 @@ import {
interval,
map,
Observable,
ReplaySubject,
shareReplay,
Subject,
switchMap,
tap,
timer,
@@ -50,8 +50,8 @@ const PROGRESS: InstallProgress = {
@Injectable()
export class MockApiService extends ApiService {
readonly mockWsSource$ = new ReplaySubject<Update<DataModel>>()
private readonly revertTime = 2000
readonly mockWsSource$ = new Subject<Update<DataModel>>()
private readonly revertTime = 1800
sequence = 0
constructor(
@@ -64,7 +64,6 @@ export class MockApiService extends ApiService {
.pipe(
tap(() => {
this.sequence = 0
this.patchStream$.next([])
}),
switchMap(verified =>
iif(
@@ -111,29 +110,13 @@ export class MockApiService extends ApiService {
value: params.value,
},
]
return this.withRevision(patch)
this.mockRevision(patch)
return null
}
// 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)
@@ -168,13 +151,20 @@ 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
if (num > 8) return params.message
throw new Error()
}
await pauseFor(2000)
return params.message
}
openPatchWebsocket$(): Observable<Update<DataModel>> {
return this.mockWsSource$
return this.mockWsSource$.pipe(
shareReplay({ bufferSize: 1, refCount: true }),
)
}
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
@@ -205,7 +195,10 @@ export class MockApiService extends ApiService {
params: RR.GetSystemTimeReq,
): Promise<RR.GetSystemTimeRes> {
await pauseFor(2000)
return new Date().toUTCString()
return {
now: new Date().toUTCString(),
uptime: 1234567,
}
}
async getServerLogs(
@@ -312,7 +305,9 @@ export class MockApiService extends ApiService {
value: initialProgress,
},
]
return this.withRevision(patch, 'updating')
this.mockRevision(patch)
return 'updating'
}
async setServerClearnetAddress(
@@ -326,13 +321,37 @@ export class MockApiService extends ApiService {
value: params.domainInfo,
},
]
return this.withRevision(patch, null)
this.mockRevision(patch)
return null
}
async restartServer(
params: RR.RestartServerReq,
): Promise<RR.RestartServerRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/server-info/status-info/restarting',
value: true,
},
]
this.mockRevision(patch)
setTimeout(() => {
const patch2 = [
{
op: PatchOp.REPLACE,
path: '/server-info/status-info/restarting',
value: false,
},
]
this.mockRevision(patch2)
}, 2000)
return null
}
@@ -340,14 +359,34 @@ export class MockApiService extends ApiService {
params: RR.ShutdownServerReq,
): Promise<RR.ShutdownServerRes> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/server-info/status-info/shutting-down',
value: true,
},
]
this.mockRevision(patch)
setTimeout(() => {
const patch2 = [
{
op: PatchOp.REPLACE,
path: '/server-info/status-info/shutting-down',
value: false,
},
]
this.mockRevision(patch2)
}, 2000)
return null
}
async systemRebuild(
params: RR.RestartServerReq,
): Promise<RR.RestartServerRes> {
await pauseFor(2000)
return null
params: RR.SystemRebuildReq,
): Promise<RR.SystemRebuildRes> {
return this.restartServer(params)
}
async repairDisk(params: RR.RestartServerReq): Promise<RR.RestartServerRes> {
@@ -369,7 +408,9 @@ export class MockApiService extends ApiService {
value: params.enable,
},
]
return this.withRevision(patch, null)
this.mockRevision(patch)
return null
}
async setOsOutboundProxy(
@@ -384,7 +425,9 @@ export class MockApiService extends ApiService {
value: params.proxy,
},
]
return this.withRevision(patch, null)
this.mockRevision(patch)
return null
}
// marketplace URLs
@@ -437,7 +480,9 @@ export class MockApiService extends ApiService {
value: 0,
},
]
return this.withRevision(patch, Mock.Notifications)
this.mockRevision(patch)
return Mock.Notifications
}
async deleteNotification(
@@ -485,7 +530,9 @@ export class MockApiService extends ApiService {
],
},
]
return this.withRevision(patch, null)
this.mockRevision(patch)
return null
}
async updateProxy(params: RR.UpdateProxyReq): Promise<RR.UpdateProxyRes> {
@@ -500,7 +547,9 @@ export class MockApiService extends ApiService {
value,
},
]
return this.withRevision(patch, null)
this.mockRevision(patch)
return null
}
async deleteProxy(params: RR.DeleteProxyReq): Promise<RR.DeleteProxyRes> {
@@ -512,7 +561,9 @@ export class MockApiService extends ApiService {
value: [],
},
]
return this.withRevision(patch, null)
this.mockRevision(patch)
return null
}
// domains
@@ -534,7 +585,9 @@ export class MockApiService extends ApiService {
},
},
]
return this.withRevision(patch, null)
this.mockRevision(patch)
return null
}
async deleteStart9ToDomain(
@@ -548,7 +601,9 @@ export class MockApiService extends ApiService {
value: null,
},
]
return this.withRevision(patch, null)
this.mockRevision(patch)
return null
}
async addDomain(params: RR.AddDomainReq): Promise<RR.AddDomainRes> {
@@ -569,7 +624,9 @@ export class MockApiService extends ApiService {
],
},
]
return this.withRevision(patch, null)
this.mockRevision(patch)
return null
}
async deleteDomain(params: RR.DeleteDomainReq): Promise<RR.DeleteDomainRes> {
@@ -581,7 +638,9 @@ export class MockApiService extends ApiService {
value: [],
},
]
return this.withRevision(patch, null)
this.mockRevision(patch)
return null
}
// port forwards
@@ -598,7 +657,9 @@ export class MockApiService extends ApiService {
value: params.port,
},
]
return this.withRevision(patch, null)
this.mockRevision(patch)
return null
}
// wifi
@@ -612,7 +673,9 @@ export class MockApiService extends ApiService {
value: params.enable,
},
]
return this.withRevision(patch, null)
this.mockRevision(patch)
return null
}
async getWifi(params: RR.GetWifiReq): Promise<RR.GetWifiRes> {
@@ -653,8 +716,9 @@ export class MockApiService extends ApiService {
value: params,
},
]
this.mockRevision(patch)
return this.withRevision(patch)
return null
}
// ssh
@@ -838,7 +902,9 @@ export class MockApiService extends ApiService {
},
]
return this.withRevision(originalPatch)
this.mockRevision(originalPatch)
return null
}
// package
@@ -905,23 +971,9 @@ export class MockApiService extends ApiService {
},
},
]
return this.withRevision(patch)
}
this.mockRevision(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,
},
},
}
return null
}
async getPackageConfig(
@@ -952,7 +1004,9 @@ export class MockApiService extends ApiService {
value: true,
},
]
return this.withRevision(patch)
this.mockRevision(patch)
return null
}
async restorePackages(
@@ -976,7 +1030,9 @@ export class MockApiService extends ApiService {
}
})
return this.withRevision(patch)
this.mockRevision(patch)
return null
}
async executePackageAction(
@@ -1026,7 +1082,9 @@ export class MockApiService extends ApiService {
},
]
return this.withRevision(originalPatch)
this.mockRevision(originalPatch)
return null
}
async restartPackage(
@@ -1103,7 +1161,9 @@ export class MockApiService extends ApiService {
},
]
return this.withRevision(patch)
this.mockRevision(patch)
return null
}
async stopPackage(params: RR.StopPackageReq): Promise<RR.StopPackageRes> {
@@ -1129,7 +1189,9 @@ export class MockApiService extends ApiService {
},
]
return this.withRevision(patch)
this.mockRevision(patch)
return null
}
async uninstallPackage(
@@ -1155,7 +1217,9 @@ export class MockApiService extends ApiService {
},
]
return this.withRevision(patch)
this.mockRevision(patch)
return null
}
async dryConfigureDependency(
@@ -1196,7 +1260,9 @@ export class MockApiService extends ApiService {
value: params.domainInfo,
},
]
return this.withRevision(patch, null)
this.mockRevision(patch)
return null
}
async setServiceOutboundProxy(
@@ -1210,7 +1276,9 @@ export class MockApiService extends ApiService {
value: params.proxy,
},
]
return this.withRevision(patch, null)
this.mockRevision(patch)
return null
}
private async updateProgress(id: string): Promise<void> {
@@ -1337,23 +1405,4 @@ export class MockApiService extends ApiService {
}
this.mockWsSource$.next(revision)
}
private async withRevision<T>(
patch: Operation<unknown>[],
response: T | null = null,
): Promise<T> {
if (!this.sequence) {
const { sequence } = this.bootstrapper.init()
this.sequence = sequence
}
this.patchStream$.next([
{
id: ++this.sequence,
patch,
},
])
return response as T
}
}

View File

@@ -36,7 +36,7 @@ export const mockPatchData: DataModel = {
},
'server-info': {
id: 'abcdefgh',
version: '0.3.4',
version: '0.3.5',
country: 'us',
ui: {
lanHostname: 'adjective-noun.local',
@@ -94,11 +94,12 @@ export const mockPatchData: DataModel = {
'current-backup': null,
updated: false,
'update-progress': null,
restarting: false,
'shutting-down': false,
},
pubkey: 'npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m',
'ca-fingerprint': 'SHA-256: 63 2B 11 99 44 40 17 DF 37 FC C3 DF 0F 3D 15',
'system-start-time': new Date(new Date().valueOf() - 360042).toUTCString(),
'ntp-synced': false,
zram: false,
smtp: {
server: '',
@@ -109,6 +110,7 @@ export const mockPatchData: DataModel = {
},
'password-hash':
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
platform: 'x86_64-nonfree',
},
'package-data': {
bitcoind: {

View File

@@ -1,14 +1,9 @@
import { DOCUMENT } from '@angular/common'
import { Inject, Injectable } from '@angular/core'
import { WorkspaceConfig } from '@start9labs/shared'
import {
InstalledPackageInfo,
InterfaceInfo,
} from 'src/app/services/patch-db/data-model'
import { InterfaceInfo } from 'src/app/services/patch-db/data-model'
const {
packageArch,
osArch,
gitHash,
useMocks,
ui: { api, marketplace, mocks },
@@ -21,20 +16,20 @@ 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
packageArch = packageArch
osArch = osArch
gitHash = gitHash
api = api
marketplace = marketplace
skipStartupAlerts = useMocks && mocks.skipStartupAlerts
isTor(): boolean {
return (
this.hostname.endsWith('.onion') || (useMocks && mocks.maskAs === 'tor')
)
return useMocks ? mocks.maskAs === 'tor' : this.hostname.endsWith('.onion')
}
isLocal(): boolean {
@@ -69,6 +64,14 @@ export class ConfigService {
)
}
isTorHttp(): boolean {
return this.isTor() && !this.isHttps()
}
isLanHttp(): boolean {
return !this.isTor() && !this.isLocalhost() && !this.isHttps()
}
isSecure(): boolean {
return window.isSecureContext || this.isTor()
}
@@ -84,6 +87,14 @@ export class ConfigService {
? `https://${info.addressInfo.domainInfo.subdomain}${info.addressInfo.domainInfo.domain}`
: `https://${info.addressInfo.domainInfo?.domain}`
}
getHost(): string {
return this.host
}
private isHttps(): boolean {
return useMocks ? mocks.maskAsHttps : this.protocol === 'https:'
}
}
export function isValidIpv4(address: string): boolean {

View File

@@ -0,0 +1,222 @@
import { Injectable } from '@angular/core'
import { Emver } from '@start9labs/shared'
import { distinctUntilChanged, map, shareReplay } from 'rxjs/operators'
import { PatchDB } from 'patch-db-client'
import {
DataModel,
HealthResult,
InstalledPackageInfo,
PackageMainStatus,
} from './patch-db/data-model'
import * as deepEqual from 'fast-deep-equal'
import { Manifest } from '@start9labs/marketplace'
import { Observable } from 'rxjs'
export type AllDependencyErrors = Record<string, PkgDependencyErrors>
export type PkgDependencyErrors = Record<string, DependencyError | null>
@Injectable({
providedIn: 'root',
})
export class DepErrorService {
readonly depErrors$: Observable<AllDependencyErrors> = 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 }): AllDependencyErrors => ({
...errors,
[id]: this.getDepErrors(pkgs, id, errors),
}),
{} as AllDependencyErrors,
),
),
distinctUntilChanged(deepEqual),
shareReplay({ bufferSize: 1, refCount: true }),
)
constructor(
private readonly emver: Emver,
private readonly patch: PatchDB<DataModel>,
) {}
getPkgDepErrors$(pkgId: string): Observable<PkgDependencyErrors> {
return this.depErrors$.pipe(
map(depErrors => depErrors[pkgId]),
distinctUntilChanged(deepEqual),
)
}
private getDepErrors(
pkgs: DataModel['package-data'],
pkgId: string,
outerErrors: AllDependencyErrors,
): PkgDependencyErrors {
const pkgInstalled = pkgs[pkgId].installed
if (!pkgInstalled) return {}
return currentDeps(pkgs, pkgId).reduce(
(innerErrors, depId): PkgDependencyErrors => ({
...innerErrors,
[depId]: this.getDepError(
pkgs,
pkgInstalled,
pkgs[pkgId].manifest,
depId,
outerErrors,
),
}),
{} as PkgDependencyErrors,
)
}
private getDepError(
pkgs: DataModel['package-data'],
pkgInstalled: InstalledPackageInfo,
pkgManifest: Manifest,
depId: string,
outerErrors: AllDependencyErrors,
): DependencyError | null {
const depInstalled = pkgs[depId]?.installed
const depManifest = pkgs[depId]?.manifest
// not installed
if (!depInstalled) {
return {
type: DependencyErrorType.NotInstalled,
}
}
// 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
) {
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,
}
}
}
}
// 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
}
export interface DependencyErrorTransitive {
type: DependencyErrorType.Transitive
}

View File

@@ -75,7 +75,7 @@ export class MarketplaceService implements AbstractMarketplaceService {
map(({ 'selected-url': url, 'known-hosts': hosts }) =>
toStoreIdentity(url, hosts[url]),
),
shareReplay(1),
shareReplay({ bufferSize: 1, refCount: true }),
)
private readonly marketplace$ = this.knownHosts$.pipe(
@@ -103,7 +103,7 @@ export class MarketplaceService implements AbstractMarketplaceService {
},
{},
),
shareReplay(1),
shareReplay({ bufferSize: 1, refCount: true }),
)
private readonly filteredMarketplace$ = combineLatest([

View File

@@ -4,6 +4,8 @@ import { Manifest } from '@start9labs/marketplace'
import { BackupJob } from '../api/api.types'
import { customSmtp } from '@start9labs/start-sdk/lib/config/configConstants'
import { NetworkInterfaceType } from '@start9labs/start-sdk/lib/util/utils'
import { DependencyInfo } from 'src/app/apps/portal/routes/service/types/dependency-info'
import { PackageStatus } from '../pkg-status-rendering.service'
export interface DataModel {
'server-info': ServerInfo
@@ -64,10 +66,11 @@ export interface ServerInfo {
'eos-version-compat': string
pubkey: string
'ca-fingerprint': string
'system-start-time': string
'ntp-synced': boolean
zram: boolean
smtp: typeof customSmtp.validator._TYPE
'password-hash': string
platform: string
}
export type NetworkInfo = {
@@ -156,9 +159,16 @@ export interface ServerStatusInfo {
}
updated: boolean
'update-progress': { size: number | null; downloaded: number } | null
restarting: boolean
'shutting-down': boolean
}
export enum ServerStatus {
Running = 'running',
Updated = 'updated',
BackingUp = 'backing-up',
}
export interface PackageDataEntry {
state: PackageState
manifest: Manifest
@@ -168,6 +178,12 @@ export interface PackageDataEntry {
'install-progress'?: InstallProgress // when: installing, updating, restoring
}
export type PackagePlus = {
pkg: PackageDataEntry
status: PackageStatus
dependencies: DependencyInfo[]
}
// export type PackageDataEntry =
// | PackageDataEntryInstalled
// | PackageDataEntryNeedsUpdate
@@ -224,6 +240,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 }>
interfaceInfo: Record<string, InterfaceInfo>
'marketplace-url': string | null
@@ -262,7 +279,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 =
@@ -354,51 +371,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

@@ -11,13 +11,13 @@ import {
EMPTY,
from,
interval,
merge,
Observable,
} from 'rxjs'
import { DataModel } from './data-model'
import { AuthService } from '../auth.service'
import { ConnectionService } from '../connection.service'
import { ApiService } from '../api/embassy-api.service'
import { ConfigService } from '../config.service'
export const PATCH_SOURCE = new InjectionToken<Observable<Update<DataModel>[]>>(
'',
@@ -31,6 +31,9 @@ export function sourceFactory(
const api = injector.get(ApiService)
const authService = injector.get(AuthService)
const connectionService = injector.get(ConnectionService)
const configService = injector.get(ConfigService)
const isTor = configService.isTor()
const timeout = isTor ? 16000 : 4000
const websocket$ = api.openPatchWebsocket$().pipe(
bufferTime(250),
@@ -38,9 +41,11 @@ export function sourceFactory(
catchError((_, watch$) => {
connectionService.websocketConnected$.next(false)
return interval(4000).pipe(
return interval(timeout).pipe(
switchMap(() =>
from(api.echo({ message: 'ping' })).pipe(catchError(() => EMPTY)),
from(api.echo({ message: 'ping', timeout })).pipe(
catchError(() => EMPTY),
),
),
take(1),
switchMap(() => watch$),
@@ -50,9 +55,7 @@ export function sourceFactory(
)
return authService.isVerified$.pipe(
switchMap(verified =>
verified ? merge(websocket$, api.patchStream$) : EMPTY,
),
switchMap(verified => (verified ? websocket$ : EMPTY)),
)
})
}

View File

@@ -12,13 +12,9 @@ import { LocalStorageBootstrap } from './patch-db/local-storage-bootstrap'
export class PatchMonitorService extends Observable<any> {
// @TODO not happy with Observable<void>
private readonly stream$ = this.authService.isVerified$.pipe(
tap(verified => {
if (verified) {
this.patch.start(this.bootstrapper)
} else {
this.patch.stop()
}
}),
tap(verified =>
verified ? this.patch.start(this.bootstrapper) : this.patch.stop(),
),
)
constructor(

View File

@@ -1,11 +1,11 @@
import { isEmptyObject } from '@start9labs/shared'
import {
InstalledPackageInfo,
PackageDataEntry,
PackageMainStatus,
PackagePlus,
PackageState,
Status,
} from 'src/app/services/patch-db/data-model'
import { PkgDependencyErrors } from './dep-error.service'
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: PkgDependencyErrors,
): 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(depErrors)
health = getHealthStatus(pkg.installed.status)
} else {
primary = pkg.state
@@ -37,15 +40,10 @@ 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
function getDependencyStatus(depErrors: PkgDependencyErrors): DependencyStatus {
return Object.values(depErrors).some(err => !!err)
? DependencyStatus.Warning
: DependencyStatus.Satisfied
}
function getHealthStatus(status: Status): HealthStatus | null {

View File

@@ -1,85 +1,59 @@
import { Injectable } from '@angular/core'
import { map, shareReplay, startWith, switchMap } from 'rxjs/operators'
import { PatchDB } from 'patch-db-client'
import {
map,
startWith,
switchMap,
combineLatest,
from,
Observable,
timer,
} from 'rxjs'
import { DataModel } from './patch-db/data-model'
import { ApiService } from './api/embassy-api.service'
export interface TimeInfo {
systemStartTime: number
systemCurrentTime: number
systemUptime: {
days: number
hours: number
minutes: number
seconds: number
}
}
import { combineLatest, interval, of } from 'rxjs'
@Injectable({
providedIn: 'root',
})
export class TimeService {
private readonly systemStartTime$ = this.patch
.watch$('server-info', 'system-start-time')
.pipe(map(startTime => new Date(startTime).valueOf()))
private readonly time$ = of({}).pipe(
switchMap(() => this.apiService.getSystemTime({})),
switchMap(({ now, uptime }) => {
const current = new Date(now).valueOf()
return interval(1000).pipe(
map(index => {
const incremented = index + 1
return {
now: current + 1000 * incremented,
uptime: uptime + incremented,
}
}),
startWith({
now: current,
uptime,
}),
)
}),
shareReplay({ bufferSize: 1, refCount: true }),
)
readonly now$ = combineLatest([
this.time$,
this.patch.watch$('server-info', 'ntp-synced'),
]).pipe(
map(([time, synced]) => ({
value: time.now,
synced,
})),
)
readonly uptime$ = this.time$.pipe(
map(({ uptime }) => {
const days = Math.floor(uptime / (24 * 60 * 60))
const daysSec = uptime % (24 * 60 * 60)
const hours = Math.floor(daysSec / (60 * 60))
const hoursSec = uptime % (60 * 60)
const minutes = Math.floor(hoursSec / 60)
const seconds = uptime % 60
return { days, hours, minutes, seconds }
}),
)
constructor(
private readonly patch: PatchDB<DataModel>,
private readonly apiService: ApiService,
) {}
getTimeInfo$(): Observable<TimeInfo> {
return combineLatest([
this.systemStartTime$.pipe(),
this.getSystemCurrentTime$(),
]).pipe(
map(([systemStartTime, systemCurrentTime]) => ({
systemStartTime,
systemCurrentTime,
systemUptime: this.getSystemUptime(systemStartTime, systemCurrentTime),
})),
)
}
private getSystemCurrentTime$() {
return from(this.apiService.getSystemTime({})).pipe(
switchMap(utcStr => {
const dateObj = new Date(utcStr)
const current = dateObj.valueOf()
return timer(0, 1000).pipe(
map(index => {
const incremented = index + 1
const msToAdd = 1000 * incremented
return current + msToAdd
}),
startWith(current),
)
}),
)
}
private getSystemUptime(systemStartTime: number, systemCurrentTime: number) {
const ms = systemCurrentTime - systemStartTime
const days = Math.floor(ms / (24 * 60 * 60 * 1000))
const daysms = ms % (24 * 60 * 60 * 1000)
const hours = Math.floor(daysms / (60 * 60 * 1000))
const hoursms = ms % (60 * 60 * 1000)
const minutes = Math.floor(hoursms / (60 * 1000))
const minutesms = ms % (60 * 1000)
const seconds = Math.floor(minutesms / 1000)
return { days, hours, minutes, seconds }
}
}

View File

@@ -1,27 +0,0 @@
import { Inject, Injectable } from '@angular/core'
import { DOCUMENT } from '@angular/common'
import { InstalledPackageInfo } from 'src/app/services/patch-db/data-model'
@Injectable({
providedIn: 'root',
})
export class UiLauncherService {
constructor(@Inject(DOCUMENT) private readonly document: Document) {}
launch(addressInfo: InstalledPackageInfo['address-info']): void {
const UIs = Object.values(addressInfo)
.filter(info => info.ui)
.map(info => ({
name: info.name,
addresses: info.addresses,
}))
if (UIs.length === 1 && UIs[0].addresses.length === 1) {
this.document.defaultView?.open(
UIs[0].addresses[0],
'_blank',
'noreferrer',
)
}
}
}

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 { PkgDependencyErrors } from '../services/dep-error.service'
export function getPackageInfo(entry: PackageDataEntry): PkgInfo {
const statuses = renderPkgStatus(entry)
export function getPackageInfo(
entry: PackageDataEntry,
depErrors: PkgDependencyErrors,
): 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

@@ -5,11 +5,11 @@
"background_color": "#1e1e1e",
"display": "standalone",
"scope": ".",
"start_url": "/?version=0344",
"id": "/?version=0344",
"start_url": "/?version=035",
"id": "/?version=035",
"icons": [
{
"src": "assets/img/icon_pwa.png",
"src": "assets/img/icon.png",
"sizes": "256x256",
"type": "image/png",
"purpose": "any"

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

@@ -474,3 +474,11 @@ button.g-action {
@include scrollbar-hidden;
overflow: auto !important;
}
p {
font-size: 1rem;
}
svg:not(:root) {
overflow: auto;
}