update/alpha.9 (#2988)

* import marketplac preview for sideload

* fix: improve state service (#2977)

* fix: fix sideload DI

* fix: update Angular

* fix: cleanup

* fix: fix version selection

* Bump node version to fix build for Angular

* misc fixes
- update node to v22
- fix chroot-and-upgrade access to prune-images
- don't self-migrate legacy packages
- #2985
- move dataVersion to volume folder
- remove "instructions.md" from s9pk
- add "docsUrl" to manifest

* version bump

* include flavor when clicking view listing from updates tab

* closes #2980

* fix: fix select button

* bring back ssh keys

* fix: drop 'portal' from all routes

* fix: implement longtap action to select table rows

* fix description for ssh page

* replace instructions with docsLink and refactor marketplace preview

* delete unused translations

* fix patchdb diffing algorithm

* continue refactor of marketplace lib show components

* Booting StartOS instead of Setting up your server on init

* misc fixes
- closes #2990
- closes #2987

* fix build

* docsUrl and clickable service headers

* don't cleanup after update until new service install succeeds

* update types

* misc fixes

* beta.35

* sdkversion, githash for sideload, correct logs for init, startos pubkey display

* bring back reboot button on install

* misc fixes

* beta.36

* better handling of setup and init for websocket errors

* reopen init and setup logs even on graceful closure

* better logging, misc fixes

* fix build

* dont let package stats hang

* dont show docsurl in marketplace if no docsurl

* re-add needs-config

* show error if init fails, shorten hover state on header icons

* fix operator precedemce

---------

Co-authored-by: Matt Hill <mattnine@protonmail.com>
Co-authored-by: Alex Inkin <alexander@inkin.ru>
Co-authored-by: Mariusz Kogen <k0gen@pm.me>
This commit is contained in:
Aiden McClelland
2025-07-18 18:31:12 +00:00
committed by GitHub
parent ba2906a42e
commit 377b7b12ce
237 changed files with 5953 additions and 4777 deletions

View File

@@ -16,11 +16,7 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
(tuiAlertChange)="onDismiss()"
>
{{ 'New notifications' | i18n }}
<a
tuiLink
routerLink="/portal/notifications"
[queryParams]="{ toast: true }"
>
<a tuiLink routerLink="/notifications" [queryParams]="{ toast: true }">
{{ 'View' | i18n }}
</a>
</ng-template>

View File

@@ -1,30 +1,31 @@
<ng-container *ngIf="!restarted; else refresh">
@if (!restarted) {
<h1 class="title">StartOS - {{ 'Diagnostic Mode' | i18n }}</h1>
<ng-container *ngIf="error">
@if (error) {
<h2 class="subtitle">StartOS {{ 'launch error' | i18n }}:</h2>
<code class="code warning">
<p>{{ error.problem }}</p>
<p *ngIf="error.details">{{ error.details }}</p>
@if (error.details) {
<p>{{ error.details }}</p>
}
</code>
<a tuiButton routerLink="logs">{{ 'View logs' | i18n }}</a>
<h2 class="subtitle">{{ 'Possible solutions' | i18n }}:</h2>
<code class="code"><p>{{ error.solution }}</p></code>
<code class="code">
<p>{{ error.solution }}</p>
</code>
<div class="buttons">
<button tuiButton (click)="restart()">{{ 'Restart server' | i18n }}</button>
<button
*ngIf="error.code === 15 || error.code === 25"
tuiButton
appearance="secondary"
(click)="forgetDrive()"
>
{{ error.code === 15 ? ('Setup current drive' | i18n) : ('Enter recovery mode' | i18n) }}
<button tuiButton (click)="restart()">
{{ 'Restart server' | i18n }}
</button>
@if (error.code === 15 || error.code === 25) {
<button tuiButton appearance="secondary" (click)="forgetDrive()">
{{
error.code === 15
? ('Setup current drive' | i18n)
: ('Enter recovery mode' | i18n)
}}
</button>
}
<button
tuiButton
appearance="secondary-destructive"
@@ -33,13 +34,11 @@
{{ 'Repair drive' | i18n }}
</button>
</div>
</ng-container>
</ng-container>
<ng-template #refresh>
}
} @else {
<h1 class="title">{{ 'Server is restarting' | i18n }}</h1>
<h2 class="subtitle">
{{ 'Wait for the server to restart, then refresh this page.' | i18n }}
</h2>
<button tuiButton (click)="refreshPage()">{{ 'Refresh' | i18n }}</button>
</ng-template>
}

View File

@@ -7,7 +7,7 @@ import { ConfigService } from 'src/app/services/config.service'
@Component({
selector: 'diagnostic-home',
templateUrl: 'home.page.html',
templateUrl: 'home.component.html',
styleUrls: ['home.page.scss'],
standalone: false,
})

View File

@@ -6,7 +6,16 @@ import {
provideSetupLogsService,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { catchError, defer, from, map, startWith, switchMap, tap } from 'rxjs'
import {
catchError,
defer,
from,
map,
startWith,
switchMap,
tap,
timer,
} from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { StateService } from 'src/app/services/state.service'
@@ -29,7 +38,7 @@ export default class InitializingPage {
.openWebsocket$<T.FullProgress>(guid, {
closeObserver: {
next: () => {
this.state.syncState()
this.state.retrigger(true)
},
},
})
@@ -38,13 +47,12 @@ export default class InitializingPage {
map(formatProgress),
tap(({ total }) => {
if (total === 1) {
this.state.syncState()
this.state.retrigger(true)
}
}),
catchError((e, caught$) => {
console.error(e)
this.state.syncState()
return caught$
catchError((_, caught$) => {
this.state.retrigger(true)
return timer(500).pipe(switchMap(() => caught$))
}),
),
{ initialValue: { total: 0, message: 'waiting...' } },

View File

@@ -1,97 +1,92 @@
<div
*ngIf="!caTrusted; else trusted"
tuiCardLarge
tuiSurface="floating"
class="card"
>
<tui-icon icon="@tui.lock" [style.font-size.rem]="4" />
<h1>{{ 'Trust your Root CA' | i18n }}</h1>
<p>
{{
'Download and trust your 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.'
| i18n
}}
</p>
<ol>
<li>
<b>{{ 'Bookmark this page' | i18n }}</b>
-
@if (!caTrusted) {
<div tuiCardLarge tuiSurface="floating" class="card">
<tui-icon icon="@tui.lock" [style.font-size.rem]="4" />
<h1>{{ 'Trust your Root CA' | i18n }}</h1>
<p>
{{
'Save this page so you can access it later. You can also find this address in the file downloaded at the end of initial setup.'
'Download and trust your 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.'
| i18n
}}
</li>
<li>
<b>{{ 'Download your Root CA' | i18n }}</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.'
| i18n
}}
<br />
<a
tuiButton
size="s"
iconEnd="@tui.download"
href="/static/local-root-ca.crt"
>
{{ 'Download' | i18n }}
</a>
</li>
<li>
<b>{{ 'Trust your Root CA' | i18n }}</b>
-
{{
'Follow instructions for your OS. By trusting your Root CA, your device can verify the authenticity of encrypted communications with your server.'
| i18n
}}
<br />
<a
tuiButton
docsLink
size="s"
href="/user-manual/trust-ca.html"
iconEnd="@tui.external-link"
>
{{ 'View instructions' | i18n }}
</a>
</li>
<li>
<b>{{ 'Test' | i18n }}</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.'
| i18n
}}
<br />
<button
tuiButton
size="s"
class="refresh"
appearance="positive"
iconEnd="@tui.refresh-cw"
(click)="refresh()"
>
{{ 'Refresh' | i18n }}
</button>
</li>
</ol>
<button
tuiButton
size="s"
appearance="flat-grayscale"
iconEnd="@tui.external-link"
(click)="launchHttps()"
[disabled]="caTrusted"
>
{{ 'Skip' | i18n }}
</button>
<div>
<small>({{ 'not recommended' | i18n }})</small>
</p>
<ol>
<li>
<b>{{ 'Bookmark this page' | i18n }}</b>
-
{{
'Save this page so you can access it later. You can also find this address in the file downloaded at the end of initial setup.'
| i18n
}}
</li>
<li>
<b>{{ 'Download your Root CA' | i18n }}</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.'
| i18n
}}
<br />
<a
tuiButton
size="s"
iconEnd="@tui.download"
href="/static/local-root-ca.crt"
>
{{ 'Download' | i18n }}
</a>
</li>
<li>
<b>{{ 'Trust your Root CA' | i18n }}</b>
-
{{
'Follow instructions for your OS. By trusting your Root CA, your device can verify the authenticity of encrypted communications with your server.'
| i18n
}}
<br />
<a
tuiButton
docsLink
size="s"
href="/user-manual/trust-ca.html"
iconEnd="@tui.external-link"
>
{{ 'View instructions' | i18n }}
</a>
</li>
<li>
<b>{{ 'Test' | i18n }}</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.'
| i18n
}}
<br />
<button
tuiButton
size="s"
class="refresh"
appearance="positive"
iconEnd="@tui.refresh-cw"
(click)="refresh()"
>
{{ 'Refresh' | i18n }}
</button>
</li>
</ol>
<button
tuiButton
size="s"
appearance="flat-grayscale"
iconEnd="@tui.external-link"
(click)="launchHttps()"
[disabled]="caTrusted"
>
{{ 'Skip' | i18n }}
</button>
<div>
<small>({{ 'not recommended' | i18n }})</small>
</div>
</div>
</div>
<ng-template #trusted>
} @else {
<div tuiCardLarge tuiSurface="floating" class="card">
<tui-icon icon="@tui.shield" class="g-positive" [style.font-size.rem]="4" />
<h1>{{ 'Root CA Trusted!' | i18n }}</h1>
@@ -105,4 +100,4 @@
{{ 'Go to login' | i18n }}
</button>
</div>
</ng-template>
}

View File

@@ -1,5 +1,4 @@
import { CommonModule, DOCUMENT } from '@angular/common'
import { Component, inject } from '@angular/core'
import { Component, inject, DOCUMENT } from '@angular/core'
import { DocsLinkDirective, i18nPipe, RELATIVE_URL } from '@start9labs/shared'
import { TuiButton, TuiIcon, TuiSurface } from '@taiga-ui/core'
import { TuiCardLarge } from '@taiga-ui/layout'
@@ -11,7 +10,6 @@ import { ConfigService } from 'src/app/services/config.service'
templateUrl: './ca-wizard.component.html',
styleUrls: ['./ca-wizard.component.scss'],
imports: [
CommonModule,
TuiIcon,
TuiButton,
TuiCardLarge,

View File

@@ -1,11 +1,11 @@
<!-- Local HTTP -->
<ca-wizard *ngIf="config.isLanHttp(); else notLanHttp"></ca-wizard>
<!-- not Local HTTP -->
<ng-template #notLanHttp>
@if (config.isLanHttp()) {
<!-- Local HTTP -->
<ca-wizard />
} @else {
<!-- not Local HTTP -->
<div tuiCardLarge class="card">
<img alt="StartOS Icon" class="logo" src="assets/img/icon.png" />
<h1 class="header">{{'Login to StartOS' | i18n}}</h1>
<h1 class="header">{{ 'Login to StartOS' | i18n }}</h1>
<form (submit)="submit()">
<tui-input-password
tuiTextfieldIconLeft="@tui.key"
@@ -13,10 +13,10 @@
[(ngModel)]="password"
(ngModelChange)="error = null"
>
{{'Password' | i18n}}
{{ 'Password' | i18n }}
</tui-input-password>
<tui-error class="error" [error]="error || null" />
<button tuiButton class="button">{{'Login' | i18n}}</button>
<button tuiButton class="button">{{ 'Login' | i18n }}</button>
</form>
</div>
</ng-template>
}

View File

@@ -1,15 +1,14 @@
import { Router } from '@angular/router'
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
import { Component, Inject, DestroyRef, inject } from '@angular/core'
import { Component, Inject, DestroyRef, inject, DOCUMENT } from '@angular/core'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { AuthService } from 'src/app/services/auth.service'
import { ConfigService } from 'src/app/services/config.service'
import { i18nKey, LoadingService } from '@start9labs/shared'
import { DOCUMENT } from '@angular/common'
@Component({
selector: 'login',
templateUrl: './login.page.html',
templateUrl: './login.component.html',
styleUrls: ['./login.page.scss'],
providers: [],
standalone: false,

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
@@ -46,17 +45,17 @@ export interface FormContext<T> {
<form-group [spec]="spec" />
<footer>
<ng-content />
<ng-container *ngFor="let button of buttons; let last = last">
<button
*ngIf="button.handler; else link"
tuiButton
[appearance]="last ? 'primary' : 'flat-grayscale'"
[type]="last ? 'submit' : 'button'"
(click)="onClick(button.handler)"
>
{{ button.text }}
</button>
<ng-template #link>
@for (button of buttons; track $index) {
@if (button.handler) {
<button
tuiButton
[appearance]="$last ? 'primary' : 'flat-grayscale'"
[type]="$last ? 'submit' : 'button'"
(click)="onClick(button.handler)"
>
{{ button.text }}
</button>
} @else {
<a
tuiButton
appearance="flat-grayscale"
@@ -65,8 +64,8 @@ export interface FormContext<T> {
>
{{ button.text }}
</a>
</ng-template>
</ng-container>
}
}
</footer>
</form>
`,
@@ -85,7 +84,6 @@ export interface FormContext<T> {
}
`,
imports: [
CommonModule,
ReactiveFormsModule,
RouterModule,
TuiValueChanges,

View File

@@ -1,9 +1,8 @@
<div class="label">
{{ spec.name }}
<tui-icon
*ngIf="spec.description || spec.disabled"
[tuiTooltip]="spec | hint"
/>
@if (spec.description || spec.disabled) {
<tui-icon [tuiTooltip]="spec | hint" />
}
<button
tuiLink
type="button"
@@ -14,24 +13,24 @@
+ {{ 'Add' | i18n }}
</button>
</div>
<tui-error [error]="order | tuiFieldError | async"></tui-error>
<tui-error [error]="order | tuiFieldError | async" />
<ng-container *ngFor="let item of array.control.controls; let index = index">
<form-object
*ngIf="spec.spec.type === 'object'; else control"
class="object"
[class.object_open]="!!open.get(item)"
[formGroup]="$any(item)"
[spec]="$any(spec.spec)"
[@tuiHeightCollapse]="animation"
[@tuiFadeIn]="animation"
[open]="!!open.get(item)"
(openChange)="open.set(item, $event)"
>
{{ item.value | mustache: $any(spec.spec).displayAs }}
<ng-container *ngTemplateOutlet="remove"></ng-container>
</form-object>
<ng-template #control>
@for (item of array.control.controls; track item) {
@if (spec.spec.type === 'object') {
<form-object
class="object"
[class.object_open]="!!open.get(item)"
[formGroup]="$any(item)"
[spec]="$any(spec.spec)"
[@tuiHeightCollapse]="animation"
[@tuiFadeIn]="animation"
[open]="!!open.get(item)"
(openChange)="open.set(item, $event)"
>
{{ item.value | mustache: $any(spec.spec).displayAs }}
<ng-container *ngTemplateOutlet="remove" />
</form-object>
} @else {
<form-control
class="control"
tuiTextfieldSize="m"
@@ -41,8 +40,8 @@
[spec]="$any(spec.spec)"
[@tuiHeightCollapse]="animation"
[@tuiFadeIn]="animation"
></form-control>
</ng-template>
/>
}
<ng-template #remove>
<button
tuiIconButton
@@ -52,7 +51,7 @@
appearance="icon"
size="m"
title="Remove"
(click.stop)="removeAt(index)"
(click.stop)="removeAt($index)"
></button>
</ng-template>
</ng-container>
}

View File

@@ -10,18 +10,21 @@
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
@if (spec.required) {
<span>*</span>
}
</tui-input>
<ng-template #color>
<div class="wrapper" [style.color]="value">
<input
*ngIf="!readOnly && !spec.disabled"
type="color"
class="color"
tabindex="-1"
[(ngModel)]="value"
(click.stop)="(0)"
/>
@if (!readOnly && !spec.disabled) {
<input
type="color"
class="color"
tabindex="-1"
[(ngModel)]="value"
(click.stop)="(0)"
/>
}
<tui-icon icon="@tui.paint-bucket" tuiAppearance="icon" class="icon" />
</div>
</ng-template>

View File

@@ -1,40 +1,58 @@
<ng-container [ngSwitch]="spec.type">
<form-color *ngSwitchCase="'color'"></form-color>
<form-datetime *ngSwitchCase="'datetime'"></form-datetime>
<form-file *ngSwitchCase="'file'"></form-file>
<form-multiselect *ngSwitchCase="'multiselect'"></form-multiselect>
<form-number *ngSwitchCase="'number'"></form-number>
<form-select *ngSwitchCase="'select'"></form-select>
<form-text *ngSwitchCase="'text'"></form-text>
<form-textarea *ngSwitchCase="'textarea'"></form-textarea>
<form-toggle *ngSwitchCase="'toggle'"></form-toggle>
</ng-container>
<tui-error [error]="order | tuiFieldError | async"></tui-error>
<ng-template
*ngIf="spec.warning || immutable"
#warning
let-completeWith="completeWith"
>
{{ spec.warning }}
<p *ngIf="immutable">{{ 'This value cannot be changed once set' | i18n }}!</p>
<div class="buttons">
<button
tuiButton
type="button"
appearance="secondary"
size="s"
(click)="completeWith(true)"
>
{{ 'Cancel' | i18n }}
</button>
<button
tuiButton
type="button"
appearance="flat-grayscale"
size="s"
(click)="completeWith(false)"
>
{{'Continue' | i18n}}
</button>
</div>
</ng-template>
@switch (spec.type) {
@case ('color') {
<form-color />
}
@case ('datetime') {
<form-datetime />
}
@case ('file') {
<form-file />
}
@case ('multiselect') {
<form-multiselect />
}
@case ('number') {
<form-number />
}
@case ('select') {
<form-select />
}
@case ('text') {
<form-text />
}
@case ('textarea') {
<form-textarea />
}
@case ('toggle') {
<form-toggle />
}
}
<tui-error [error]="order | tuiFieldError | async" />
@if (spec.warning || immutable) {
<ng-template #warning let-completeWith="completeWith">
{{ spec.warning }}
@if (immutable) {
<p>{{ 'This value cannot be changed once set' | i18n }}!</p>
}
<div class="buttons">
<button
tuiButton
type="button"
appearance="secondary"
size="s"
(click)="completeWith(true)"
>
{{ 'Cancel' | i18n }}
</button>
<button
tuiButton
type="button"
appearance="flat-grayscale"
size="s"
(click)="completeWith(false)"
>
{{ 'Continue' | i18n }}
</button>
</div>
</ng-template>
}

View File

@@ -1,43 +1,54 @@
<ng-container [ngSwitch]="spec.inputmode" [tuiHintContent]="spec.description">
<tui-input-time
*ngSwitchCase="'time'"
[tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[pseudoInvalid]="invalid"
[ngModel]="getTime(value)"
(ngModelChange)="value = $event?.toString() || null"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
</tui-input-time>
<tui-input-date
*ngSwitchCase="'date'"
[tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[pseudoInvalid]="invalid"
[min]="spec.min ? (spec.min | tuiMapper: getLimit)[0] : min"
[max]="spec.max ? (spec.max | tuiMapper: getLimit)[0] : max"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
</tui-input-date>
<tui-input-date-time
*ngSwitchCase="'datetime-local'"
[tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[pseudoInvalid]="invalid"
[min]="spec.min ? (spec.min | tuiMapper: getLimit) : min"
[max]="spec.max ? (spec.max | tuiMapper: getLimit) : max"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
</tui-input-date-time>
<ng-container [tuiHintContent]="spec.description">
@switch (spec.inputmode) {
@case ('time') {
<tui-input-time
[tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[pseudoInvalid]="invalid"
[ngModel]="getTime(value)"
(ngModelChange)="value = $event?.toString() || null"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
@if (spec.required) {
<span>*</span>
}
</tui-input-time>
}
@case ('date') {
<tui-input-date
[tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[pseudoInvalid]="invalid"
[min]="spec.min ? (spec.min | tuiMapper: getLimit)[0] : min"
[max]="spec.max ? (spec.max | tuiMapper: getLimit)[0] : max"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
@if (spec.required) {
<span>*</span>
}
</tui-input-date>
}
@case ('datetime-local') {
<tui-input-date-time
[tuiHintContent]="spec | hint"
[disabled]="!!spec.disabled"
[readOnly]="readOnly"
[pseudoInvalid]="invalid"
[min]="spec.min ? (spec.min | tuiMapper: getLimit) : min"
[max]="spec.max ? (spec.max | tuiMapper: getLimit) : max"
[(ngModel)]="value"
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
@if (spec.required) {
<span>*</span>
}
</tui-input-date-time>
}
}
</ng-container>

View File

@@ -1,29 +1,30 @@
<ng-container
*ngFor="let entry of spec | keyvalue: asIsOrder | filterHidden"
[ngSwitch]="entry.value.type"
[tuiTextfieldCleaner]="true"
>
<form-object
*ngSwitchCase="'object'"
class="g-form-control"
[formGroupName]="entry.key"
[spec]="$any(entry.value)"
></form-object>
<form-union
*ngSwitchCase="'union'"
class="g-form-control"
[formGroupName]="entry.key"
[spec]="$any(entry.value)"
></form-union>
<form-array
*ngSwitchCase="'list'"
[formArrayName]="entry.key"
[spec]="$any(entry.value)"
></form-array>
<form-control
*ngSwitchDefault
class="g-form-control"
[formControlName]="entry.key"
[spec]="entry.value"
></form-control>
</ng-container>
@for (entry of spec | keyvalue: asIsOrder | filterHidden; track entry) {
<ng-container [tuiTextfieldCleaner]="true">
@switch (entry.value.type) {
@case ('object') {
<form-object
class="g-form-control"
[formGroupName]="entry.key"
[spec]="$any(entry.value)"
/>
}
@case ('union') {
<form-union
class="g-form-control"
[formGroupName]="entry.key"
[spec]="$any(entry.value)"
/>
}
@case ('list') {
<form-array [formArrayName]="entry.key" [spec]="$any(entry.value)" />
}
@default {
<form-control
class="g-form-control"
[formControlName]="entry.key"
[spec]="entry.value"
/>
}
}
</ng-container>
}

View File

@@ -6,7 +6,7 @@
[pseudoInvalid]="invalid"
[tuiNumberFormat]="{
precision: spec.integer ? 0 : Infinity,
decimalMode: 'not-zero'
decimalMode: 'not-zero',
}"
[min]="spec.min ?? -Infinity"
[max]="spec.max ?? Infinity"
@@ -15,6 +15,8 @@
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
@if (spec.required) {
<span>*</span>
}
<input tuiTextfieldLegacy [placeholder]="spec.placeholder || ''" />
</tui-input-number>

View File

@@ -8,7 +8,9 @@
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
@if (spec.required) {
<span>*</span>
}
<input
tuiTextfieldLegacy
[class.masked]="spec.masked && masked"
@@ -19,26 +21,28 @@
/>
</tui-input>
<ng-template #toggle>
<button
*ngIf="spec.generate"
tuiIconButton
type="button"
appearance="icon"
title="Generate"
size="xs"
class="button"
iconStart="@tui.refresh-ccw"
(click)="generate()"
></button>
<button
*ngIf="spec.masked"
tuiIconButton
type="button"
appearance="icon"
title="Toggle masking"
size="xs"
class="button"
[iconStart]="masked ? '@tui.eye' : '@tui.eye-off'"
(click)="masked = !masked"
></button>
@if (spec.generate) {
<button
tuiIconButton
type="button"
appearance="icon"
title="Generate"
size="xs"
class="button"
iconStart="@tui.refresh-ccw"
(click)="generate()"
></button>
}
@if (spec.masked) {
<button
tuiIconButton
type="button"
appearance="icon"
title="Toggle masking"
size="xs"
class="button"
[iconStart]="masked ? '@tui.eye' : '@tui.eye-off'"
(click)="masked = !masked"
></button>
}
</ng-template>

View File

@@ -10,7 +10,9 @@
(focusedChange)="onFocus($event)"
>
{{ spec.name }}
<span *ngIf="spec.required">*</span>
@if (spec.required) {
<span>*</span>
}
<textarea
tuiTextfieldLegacy
[placeholder]="spec.placeholder || ''"

View File

@@ -1,6 +1,7 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { CopyService, i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiButton, TuiTitle } from '@taiga-ui/core'
import { TuiFade } from '@taiga-ui/kit'
import { TuiCell } from '@taiga-ui/layout'
@@ -46,6 +47,20 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
{{ 'Copy' | i18n }}
</button>
</div>
<div tuiCell>
<div tuiTitle>
<strong>Public Key</strong>
<div tuiSubtitle tuiFade>{{ getPubkey(server) }}</div>
</div>
<button
tuiIconButton
appearance="icon"
iconStart="@tui.copy"
(click)="copyService.copy(getPubkey(server))"
>
{{ 'Copy' | i18n }}
</button>
</div>
}
`,
styles: '[tuiCell] { padding-inline: 0; white-space: nowrap }',
@@ -58,6 +73,10 @@ export class AboutComponent {
readonly server = toSignal(
inject<PatchDB<DataModel>>(PatchDB).watch$('serverInfo'),
)
getPubkey(server: T.ServerInfo) {
return `${server.pubkey} startos@${server.hostname}`
}
}
export const ABOUT = new PolymorpheusComponent(AboutComponent)

View File

@@ -72,7 +72,7 @@ import { ABOUT } from './about.component'
<a
tuiOption
iconStart="@tui.settings"
routerLink="/portal/system"
routerLink="/system"
(click)="open = false"
>
{{ 'System Settings' | i18n }}

View File

@@ -22,9 +22,9 @@ import { getMenu } from 'src/app/utils/system-utilities'
class="link"
routerLinkActive="link_active"
tuiHintDirection="bottom"
[tuiHintShowDelay]="750"
[routerLink]="item.routerLink"
[class.link_system]="item.routerLink === '/portal/system'"
[tuiHintShowDelay]="250"
[routerLink]="['/', item.routerLink]"
[class.link_system]="item.routerLink === 'system'"
[tuiHint]="rla.isActive ? '' : (item.name | i18n)"
>
<tui-badged-content

View File

@@ -3,6 +3,7 @@ import {
Component,
inject,
input,
DOCUMENT,
} from '@angular/core'
import { CopyService, DialogService, i18nPipe } from '@start9labs/shared'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
@@ -15,7 +16,6 @@ import {
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { QRModal } from 'src/app/routes/portal/modals/qr.component'
import { InterfaceComponent } from './interface.component'
import { DOCUMENT } from '@angular/common'
@Component({
selector: 'td[actions]',

View File

@@ -74,7 +74,7 @@ export class LogsPipe implements PipeTransform {
this.logs.status$.next(v ? 'reconnecting' : 'disconnected'),
),
filter(Boolean),
delay(1000),
delay(1000), // @TODO Alex why delay here?
take(1),
ignoreElements(),
),

View File

@@ -14,7 +14,7 @@ import { TuiBadgeNotification } from '@taiga-ui/kit'
import { BadgeService } from 'src/app/services/badge.service'
import { getMenu } from 'src/app/utils/system-utilities'
const FILTER = ['/portal/services', '/portal/system', '/portal/marketplace']
const FILTER = ['/services', '/system', '/marketplace']
@Component({
selector: 'app-tabs',
@@ -23,7 +23,7 @@ const FILTER = ['/portal/services', '/portal/system', '/portal/marketplace']
<a
tuiTabBarItem
icon="@tui.layout-grid"
routerLink="/portal/services"
routerLink="/services"
routerLinkActive
(isActiveChange)="update()"
>
@@ -32,7 +32,7 @@ const FILTER = ['/portal/services', '/portal/system', '/portal/marketplace']
<a
tuiTabBarItem
icon="@tui.shopping-cart"
routerLink="/portal/marketplace"
routerLink="/marketplace"
routerLinkActive
(isActiveChange)="update()"
>
@@ -41,7 +41,7 @@ const FILTER = ['/portal/services', '/portal/system', '/portal/marketplace']
<a
tuiTabBarItem
icon="@tui.settings"
routerLink="/portal/system"
routerLink="/system"
routerLinkActive
[badge]="badge()"
(isActiveChange)="update()"
@@ -60,7 +60,7 @@ const FILTER = ['/portal/services', '/portal/system', '/portal/marketplace']
<a
class="item"
routerLinkActive="item_active"
[routerLink]="item.routerLink"
[routerLink]="['/', item.routerLink]"
(click)="observer.complete()"
>
<tui-icon [icon]="item.icon" />
@@ -124,7 +124,7 @@ export class TabsComponent {
index = 3
readonly menu = getMenu().filter(item => !FILTER.includes(item.routerLink))
readonly badge = toSignal(inject(BadgeService).getCount('/portal/system'), {
readonly badge = toSignal(inject(BadgeService).getCount('system'), {
initialValue: 0,
})

View File

@@ -9,19 +9,22 @@ import { TuiNotification } from '@taiga-ui/core'
import { getValueByPointer, Operation } from 'fast-json-patch'
import { i18nPipe, isObject } from '@start9labs/shared'
import { tuiIsNumber } from '@taiga-ui/cdk'
import { CommonModule } from '@angular/common'
@Component({
selector: 'task-info',
template: `
<tui-notification *ngIf="diff.length">
{{ 'The following modifications were made' | i18n }}:
<ul>
<li *ngFor="let d of diff" [innerHTML]="d"></li>
</ul>
</tui-notification>
@if (diff.length) {
<tui-notification>
{{ 'The following modifications were made' | i18n }}:
<ul>
@for (d of diff; track d) {
<li [innerHTML]="d"></li>
}
</ul>
</tui-notification>
}
`,
imports: [CommonModule, TuiNotification, i18nPipe],
imports: [TuiNotification, i18nPipe],
changeDetection: ChangeDetectionStrategy.OnPush,
styles: `
tui-notification {

View File

@@ -25,50 +25,50 @@ const ROUTES: Routes = [
// title: titleResolver,
// path: 'backups',
// loadComponent: () => import('./routes/backups/backups.component'),
// data: toNavigationItem('/portal/backups'),
// data: toNavigationItem('backups'),
// },
{
title: titleResolver,
path: 'logs',
loadChildren: () => import('./routes/logs/logs.routes'),
data: toNavigationItem('/portal/logs'),
data: toNavigationItem('logs'),
},
{
title: titleResolver,
path: 'marketplace',
loadChildren: () => import('./routes/marketplace/marketplace.routes'),
data: toNavigationItem('/portal/marketplace'),
data: toNavigationItem('marketplace'),
},
{
title: titleResolver,
path: 'system',
loadChildren: () => import('./routes/system/system.routes'),
data: toNavigationItem('/portal/system'),
data: toNavigationItem('system'),
},
{
title: titleResolver,
path: 'notifications',
loadComponent: () =>
import('./routes/notifications/notifications.component'),
data: toNavigationItem('/portal/notifications'),
data: toNavigationItem('notifications'),
},
{
title: titleResolver,
path: 'sideload',
loadComponent: () => import('./routes/sideload/sideload.component'),
data: toNavigationItem('/portal/sideload'),
data: toNavigationItem('sideload'),
},
{
title: titleResolver,
path: 'updates',
loadComponent: () => import('./routes/updates/updates.component'),
data: toNavigationItem('/portal/updates'),
data: toNavigationItem('updates'),
},
{
title: titleResolver,
path: 'metrics',
loadComponent: () => import('./routes/metrics/metrics.component'),
data: toNavigationItem('/portal/metrics'),
data: toNavigationItem('metrics'),
},
],
},

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common'
import { Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { FormsModule } from '@angular/forms'
@@ -58,19 +57,21 @@ import { TARGET, TARGET_CREATE } from './target.component'
Schedule
<input tuiTextfieldLegacy placeholder="* * * * *" />
</tui-input>
<div *ngIf="job.cron | toHumanCron as human" [style.color]="human.color">
{{ human.message }}
</div>
<div *ngIf="!job.job.id" class="g-toggle">
Also Execute Now
<input
tuiSwitch
type="checkbox"
name="now"
[showIcons]="false"
[(ngModel)]="job.now"
/>
</div>
@if (job.cron | toHumanCron; as human) {
<div [style.color]="human.color">{{ human.message }}</div>
}
@if (!job.job.id) {
<div class="g-toggle">
Also Execute Now
<input
tuiSwitch
type="checkbox"
name="now"
[showIcons]="false"
[(ngModel)]="job.now"
/>
</div>
}
<button
tuiButton
class="submit"
@@ -96,7 +97,6 @@ import { TARGET, TARGET_CREATE } from './target.component'
}
`,
imports: [
CommonModule,
FormsModule,
TuiInputModule,
TuiInputNumberModule,

View File

@@ -17,7 +17,7 @@ import { RecoverOption } from '../types/recover-option'
@Component({
template: `
<ng-container *ngIf="packageData$ | toOptions: backups | async as options">
@if (packageData$ | toOptions: backups | async; as options) {
<div
tuiGroup
orientation="vertical"
@@ -30,12 +30,9 @@ import { RecoverOption } from '../types/recover-option'
<strong>{{ option.title }}</strong>
<div>Version {{ option.version }}</div>
<div>Backup made: {{ option.timestamp | date: 'medium' }}</div>
<div
*ngIf="option | tuiMapper: toMessage as message"
[style.color]="message.color"
>
{{ message.text }}
</div>
@if (option | tuiMapper: toMessage; as message) {
<div [style.color]="message.color">{{ message.text }}</div>
}
</div>
<input
type="checkbox"
@@ -46,7 +43,6 @@ import { RecoverOption } from '../types/recover-option'
</label>
}
</div>
<footer class="g-buttons">
<button
tuiButton
@@ -56,7 +52,7 @@ import { RecoverOption } from '../types/recover-option'
Restore Selected
</button>
</footer>
</ng-container>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common'
import { Component, inject, OnInit, signal } from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiButton, TuiLink, TuiNotification } from '@taiga-ui/core'
@@ -64,7 +63,6 @@ import { DocsLinkDirective } from 'projects/shared/src/public-api'
></table>
`,
imports: [
CommonModule,
TuiNotification,
TuiButton,
BackupsPhysicalComponent,

View File

@@ -68,7 +68,7 @@ export class BackupsRestoreService {
),
)
.subscribe(() => {
this.router.navigate(['/portal/services'])
this.router.navigate(['services'])
})
}

View File

@@ -1,199 +0,0 @@
import { KeyValuePipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
signal,
} from '@angular/core'
import { i18nKey, i18nPipe } from '@start9labs/shared'
import { TuiAppearance, TuiButton, TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiCardMedium } from '@taiga-ui/layout'
import { LogsComponent } from 'src/app/routes/portal/components/logs/logs.component'
import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { TitleDirective } from 'src/app/services/title.service'
interface Log {
title: i18nKey
subtitle: i18nKey
icon: string
follow: (params: RR.FollowServerLogsReq) => Promise<RR.FollowServerLogsRes>
fetch: (params: RR.GetServerLogsReq) => Promise<RR.GetServerLogsRes>
}
@Component({
template: `
<ng-container *title>
@if (current(); as key) {
<button
tuiIconButton
iconStart="@tui.arrow-left"
(click)="current.set(null)"
>
{{ 'Back' | i18n }}
</button>
{{ logs[key]?.title | i18n }}
} @else {
{{ 'Logs' | i18n }}
}
</ng-container>
@if (current(); as key) {
<header tuiTitle>
<strong class="title">
<button
tuiIconButton
appearance="secondary-grayscale"
iconStart="@tui.x"
size="s"
class="close"
(click)="current.set(null)"
>
{{ 'Close' | i18n }}
</button>
{{ logs[key]?.title | i18n }}
</strong>
<p tuiSubtitle>{{ logs[key]?.subtitle | i18n }}</p>
</header>
@for (log of logs | keyvalue; track $index) {
@if (log.key === current()) {
<logs
[context]="log.key"
[followLogs]="log.value.follow"
[fetchLogs]="log.value.fetch"
/>
}
}
} @else {
@for (log of logs | keyvalue; track $index) {
<button
tuiCardMedium
tuiAppearance="neutral"
(click)="current.set(log.key)"
>
<tui-icon [icon]="log.value.icon" />
<span tuiTitle>
<strong>{{ log.value.title | i18n }}</strong>
<span tuiSubtitle>{{ log.value.subtitle | i18n }}</span>
</span>
<tui-icon icon="@tui.chevron-right" />
</button>
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'g-page' },
styles: `
:host {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 1rem;
padding: 1rem;
}
header {
width: 100%;
padding: 0 1rem;
}
strong {
font-weight: bold;
}
logs {
height: calc(100% - 4rem);
width: 100%;
}
.close {
position: absolute;
right: 0;
border-radius: 100%;
}
button::before {
margin: 0 -0.25rem 0 -0.375rem;
--tui-icon-size: 1.5rem;
}
[tuiCardMedium] {
height: 14rem;
width: 14rem;
cursor: pointer;
box-shadow:
inset 0 0 0 1px var(--tui-background-neutral-1),
var(--tui-shadow-small);
[tuiSubtitle] {
color: var(--tui-text-secondary);
}
tui-icon:last-child {
align-self: flex-end;
}
}
:host-context(tui-root._mobile) {
flex-direction: column;
justify-content: flex-start;
header {
padding: 0;
}
.title {
display: none;
}
logs {
height: calc(100% - 2rem);
}
[tuiCardMedium] {
width: 100%;
height: auto;
gap: 1rem;
}
}
`,
imports: [
LogsComponent,
TitleDirective,
KeyValuePipe,
TuiTitle,
TuiCardMedium,
TuiIcon,
TuiAppearance,
TuiButton,
i18nPipe,
],
})
export default class SystemLogsComponent {
private readonly api = inject(ApiService)
readonly current = signal<string | null>(null)
readonly logs: Record<string, Log> = {
os: {
title: 'OS Logs',
subtitle: 'Raw, unfiltered operating system logs',
icon: '@tui.square-dashed-bottom-code',
follow: params => this.api.followServerLogs(params),
fetch: params => this.api.getServerLogs(params),
},
kernel: {
title: 'Kernel Logs',
subtitle: 'Diagnostics for drivers and other kernel processes',
icon: '@tui.square-chevron-right',
follow: params => this.api.followKernelLogs(params),
fetch: params => this.api.getKernelLogs(params),
},
tor: {
title: 'Tor Logs',
subtitle: 'Diagnostic logs for the Tor daemon on StartOS',
icon: '@tui.globe',
follow: params => this.api.followTorLogs(params),
fetch: params => this.api.getTorLogs(params),
},
}
}

View File

@@ -2,10 +2,13 @@ import { CommonModule, TitleCasePipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core'
import { toObservable, toSignal } from '@angular/core/rxjs-interop'
import { Router } from '@angular/router'
import { MarketplacePkg } from '@start9labs/marketplace'
import {
ErrorService,
Exver,
@@ -17,29 +20,29 @@ import {
} from '@start9labs/shared'
import { TuiButton } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { firstValueFrom } from 'rxjs'
import { firstValueFrom, switchMap } from 'rxjs'
import { ToManifestPipe } from 'src/app/routes/portal/pipes/to-manifest'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import {
DataModel,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { dryUpdate } from 'src/app/utils/dry-update'
import { getAllPackages, getManifest } from 'src/app/utils/get-package-data'
import { hasCurrentDeps } from 'src/app/utils/has-deps'
import { MarketplacePreviewComponent } from '../modals/preview.component'
import { MarketplaceAlertsService } from '../services/alerts.service'
type KEYS = 'id' | 'version' | 'alerts' | 'flavor'
@Component({
selector: 'marketplace-controls',
template: `
@if (localPkg(); as local) {
@if (sameFlavor() && localPkg(); as local) {
@if (local.stateInfo.state === 'installed') {
@switch ((local | toManifest).version | compareExver: version()) {
@switch ((local | toManifest).version | compareExver: pkg().version) {
@case (1) {
<button
tuiButton
size="m"
type="button"
appearance="warning"
(click)="tryInstall()"
@@ -50,6 +53,7 @@ import { MarketplaceAlertsService } from '../services/alerts.service'
@case (-1) {
<button
tuiButton
size="m"
type="button"
appearance="primary"
(click)="tryInstall()"
@@ -60,6 +64,7 @@ import { MarketplaceAlertsService } from '../services/alerts.service'
@case (0) {
<button
tuiButton
size="m"
type="button"
appearance="secondary-grayscale"
(click)="tryInstall()"
@@ -71,27 +76,34 @@ import { MarketplaceAlertsService } from '../services/alerts.service'
}
<button
tuiButton
size="m"
type="button"
appearance="secondary-grayscale"
(click)="showService()"
>
{{
('View' | i18n) +
' ' +
($any(local.stateInfo.state | titlecase) | i18n)
}}
{{ 'View' | i18n }}
{{ $any(local.stateInfo.state | titlecase) | i18n }}
</button>
} @else {
<button
tuiButton
size="m"
type="button"
appearance="primary"
(click)="tryInstall()"
>
{{ localFlavor() ? ('Switch' | i18n) : ('Install' | i18n) }}
{{ (sameFlavor() ? 'Install' : 'Switch') | i18n }}
</button>
}
`,
styles: `
:host {
display: flex;
justify-content: flex-start;
gap: 0.5rem;
height: 4.5rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
@@ -109,27 +121,35 @@ export class MarketplaceControlsComponent {
private readonly loader = inject(LoadingService)
private readonly exver = inject(Exver)
private readonly router = inject(Router)
private readonly marketplaceService = inject(MarketplaceService)
private readonly marketplace = inject(MarketplaceService)
private readonly api = inject(ApiService)
private readonly preview = inject(MarketplacePreviewComponent)
version = input.required<string>()
installAlert = input.required<string | null>()
localPkg = input.required<PackageDataEntry | null>()
localFlavor = input.required<boolean>()
readonly pkg = input.required<Pick<MarketplacePkg, KEYS>>()
// only present if side loading
file = input<File>()
readonly file = input<File>()
readonly localPkg = toSignal(
toObservable(this.pkg).pipe(
switchMap(({ id }) => this.patch.watch$('packageData', id)),
),
)
readonly sameFlavor = computed(
(pkg = this.localPkg()) =>
!pkg ||
this.exver.getFlavor(getManifest(pkg).version) === this.pkg().flavor,
)
async tryInstall() {
const localPkg = this.localPkg()
const currentUrl = this.file()
? null
: await firstValueFrom(this.marketplaceService.currentRegistryUrl$)
: await firstValueFrom(this.marketplace.currentRegistryUrl$)
const originalUrl = localPkg?.registry || null
if (!localPkg) {
if (await this.alerts.alertInstall(this.installAlert() || '')) {
if (await this.alerts.alertInstall(this.pkg().alerts.install || '')) {
this.installOrUpload(currentUrl)
}
return
@@ -143,11 +163,11 @@ export class MarketplaceControlsComponent {
return
}
const localManifest = getManifest(localPkg)
const { id, version } = getManifest(localPkg)
if (
hasCurrentDeps(localManifest.id, await getAllPackages(this.patch)) &&
this.exver.compareExver(localManifest.version, this.version()) !== 0
hasCurrentDeps(id, await getAllPackages(this.patch)) &&
this.exver.compareExver(version, this.pkg().version) !== 0
) {
this.dryInstall(currentUrl)
} else {
@@ -156,16 +176,13 @@ export class MarketplaceControlsComponent {
}
async showService() {
this.router.navigate(['/portal/services', this.preview.pkgId])
this.router.navigate(['services', this.pkg().id])
}
private async dryInstall(url: string | null) {
const id = this.preview.pkgId
const breakages = dryUpdate(
{ id, version: this.version() },
await getAllPackages(this.patch),
this.exver,
)
const { id, version } = this.pkg()
const packages = await getAllPackages(this.patch)
const breakages = dryUpdate({ id, version }, packages, this.exver)
if (
isEmptyObject(breakages) ||
@@ -178,7 +195,7 @@ export class MarketplaceControlsComponent {
private async installOrUpload(url: string | null) {
if (this.file()) {
await this.upload()
this.router.navigate(['/portal', 'services'])
this.router.navigate(['services'])
} else if (url) {
await this.install(url)
}
@@ -186,10 +203,10 @@ export class MarketplaceControlsComponent {
private async install(url: string) {
const loader = this.loader.open('Beginning install').subscribe()
const id = this.preview.pkgId
const { id, version } = this.pkg()
try {
await this.marketplaceService.installPackage(id, this.version(), url)
await this.marketplace.installPackage(id, version, url)
} catch (e: any) {
this.errorService.handleError(e)
} finally {

View File

@@ -6,30 +6,15 @@ import {
inject,
input,
} from '@angular/core'
import { toObservable, toSignal } from '@angular/core/rxjs-interop'
import { toSignal } from '@angular/core/rxjs-interop'
import { ActivatedRoute, Router } from '@angular/router'
import { ItemModule, MarketplacePkg } from '@start9labs/marketplace'
import { Exver } from '@start9labs/shared'
import { TuiAutoFocus } from '@taiga-ui/cdk'
import { TuiButton, TuiDropdownService, TuiPopup } from '@taiga-ui/core'
import { TuiDrawer } from '@taiga-ui/kit'
import { PatchDB } from 'patch-db-client'
import {
debounceTime,
filter,
map,
Observable,
shareReplay,
switchMap,
} from 'rxjs'
import {
DataModel,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data'
import { debounceTime } from 'rxjs'
import { MarketplacePreviewComponent } from '../modals/preview.component'
import { MarketplaceSidebarService } from '../services/sidebar.service'
import { MarketplaceControlsComponent } from './controls.component'
@Component({
selector: 'marketplace-tile',
@@ -40,7 +25,7 @@ import { MarketplaceControlsComponent } from './controls.component'
[overlay]="true"
(click.self)="toggle(false)"
>
<marketplace-preview [pkgId]="pkg().id" class="preview-wrapper">
<marketplace-preview [pkgId]="pkg().id">
<button
tuiAutoFocus
slot="close"
@@ -53,19 +38,18 @@ import { MarketplaceControlsComponent } from './controls.component'
[tuiAppearanceFocus]="false"
(click)="toggle(false)"
></button>
<marketplace-controls
slot="controls"
class="controls-wrapper"
[version]="pkg().version"
[installAlert]="pkg().alerts.install"
[localPkg]="local$ | async"
[localFlavor]="!!(flavor$ | async)"
/>
</marketplace-preview>
</tui-drawer>
</marketplace-item>
`,
styles: `
@keyframes animateIn {
from {
opacity: 0;
transform: scale(0.6) translateY(-20px);
}
}
:host {
cursor: pointer;
animation: animateIn 400ms calc(var(--animation-order) * 200ms) both;
@@ -77,28 +61,7 @@ import { MarketplaceControlsComponent } from './controls.component'
border-radius: 0;
}
@keyframes animateIn {
from {
opacity: 0;
transform: scale(0.6) translateY(-20px);
}
to {
opacity: 1;
}
}
.preview-wrapper {
overflow-y: auto;
height: 100%;
max-width: 100%;
@media (min-width: 768px) {
max-width: 30rem;
}
}
.close-button {
button {
place-self: end;
margin-bottom: 0;
@@ -106,13 +69,6 @@ import { MarketplaceControlsComponent } from './controls.component'
margin-bottom: 2rem;
}
}
.controls-wrapper {
display: flex;
justify-content: flex-start;
gap: 0.5rem;
height: 4.5rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
providers: [
@@ -128,13 +84,10 @@ import { MarketplaceControlsComponent } from './controls.component'
TuiButton,
TuiPopup,
TuiDrawer,
MarketplaceControlsComponent,
MarketplacePreviewComponent,
],
})
export class MarketplaceTileComponent {
private readonly exver = inject(Exver)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly router = inject(Router)
private readonly params = toSignal(
inject(ActivatedRoute).queryParamMap.pipe(debounceTime(100)),
@@ -147,24 +100,6 @@ export class MarketplaceTileComponent {
this.params()?.get('flavor') === this.pkg()?.flavor,
)
readonly local$: Observable<PackageDataEntry | null> = toObservable(
this.pkg,
).pipe(
switchMap(({ id, flavor }) =>
this.patch.watch$('packageData', id).pipe(
filter(Boolean),
map(pkg =>
this.exver.getFlavor(getManifest(pkg).version) === flavor
? pkg
: null,
),
),
),
shareReplay({ bufferSize: 1, refCount: true }),
)
readonly flavor$ = this.local$.pipe(map(pkg => !pkg))
toggle(open: boolean) {
this.router.navigate([], {
queryParams: {

View File

@@ -9,10 +9,8 @@ import {
} from '@start9labs/marketplace'
import { defaultRegistries, i18nPipe } from '@start9labs/shared'
import { TuiScrollbar } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { tap, withLatestFrom } from 'rxjs'
import { tap } from 'rxjs'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
import { MarketplaceMenuComponent } from './components/menu.component'
import { MarketplaceNotificationComponent } from './components/notification.component'

View File

@@ -3,29 +3,26 @@ import {
ChangeDetectionStrategy,
Component,
inject,
input,
Input,
TemplateRef,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { Router } from '@angular/router'
import {
AboutModule,
AdditionalModule,
FlavorsComponent,
MarketplaceAdditionalItemComponent,
MarketplaceLinksComponent,
MarketplaceFlavorsComponent,
MarketplaceAboutComponent,
MarketplaceDependenciesComponent,
MarketplacePackageHeroComponent,
MarketplacePkg,
MarketplaceVersionsComponent,
MarketplaceReleaseNotesComponent,
} from '@start9labs/marketplace'
import {
DialogService,
Exver,
i18nPipe,
MARKDOWN,
SharedPipesModule,
} from '@start9labs/shared'
import { TuiButton, TuiDialogContext, TuiLoader } from '@taiga-ui/core'
import { TuiRadioList } from '@taiga-ui/kit'
import { TuiLoader } from '@taiga-ui/core'
import {
BehaviorSubject,
combineLatest,
@@ -34,7 +31,9 @@ import {
startWith,
switchMap,
} from 'rxjs'
import { shareReplay, take, tap } from 'rxjs/operators'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import { MarketplaceControlsComponent } from '../components/controls.component'
@Component({
selector: 'marketplace-preview',
@@ -43,50 +42,25 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
<ng-content select="[slot=close]" />
@if (pkg$ | async; as pkg) {
<marketplace-package-hero [pkg]="pkg">
<ng-content select="[slot=controls]" />
<marketplace-controls [pkg]="pkg" />
</marketplace-package-hero>
<div class="inner-container">
<marketplace-about [pkg]="pkg" (static)="onStatic()" />
<marketplace-release-notes [pkg]="pkg" />
@if (flavors$ | async; as flavors) {
<marketplace-flavors [pkgs]="flavors" />
}
<marketplace-about [pkg]="pkg" />
@if (!(pkg.dependencyMetadata | empty)) {
<marketplace-dependencies [pkg]="pkg" (open)="open($event)" />
}
<marketplace-additional [pkg]="pkg" (static)="onStatic($event)">
@if (versions$ | async; as versions) {
<marketplace-additional-item
(click)="selectVersion(pkg, version)"
[data]="('Click to view all versions' | i18n) || ''"
icon="@tui.chevron-right"
label="All versions"
class="versions"
/>
<ng-template
#version
let-data="data"
let-completeWith="completeWith"
>
<tui-radio-list [items]="versions" [(ngModel)]="data.version" />
<footer class="buttons">
<button
tuiButton
appearance="secondary"
(click)="completeWith(null)"
>
{{ 'Cancel' | i18n }}
</button>
<button
tuiButton
appearance="secondary"
(click)="completeWith(data.version)"
>
{{ 'Ok' | i18n }}
</button>
</footer>
</ng-template>
}
</marketplace-additional>
<marketplace-links [pkg]="pkg" />
@if (versions$ | async; as versions) {
<marketplace-versions
[version]="pkg.version"
[versions]="versions"
(onVersion)="selectedVersion$.next($event)"
/>
}
</div>
} @else {
<tui-loader textContent="Loading" [style.height.%]="100" />
@@ -96,6 +70,13 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
styles: `
:host {
pointer-events: auto;
overflow-y: auto;
height: 100%;
max-width: 100%;
@media (min-width: 768px) {
max-width: 30rem;
}
}
.outer-container {
@@ -125,20 +106,7 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
}
}
.versions {
border: 0;
border-top-width: 1px;
border-bottom-width: 1px;
border-color: rgb(113 113 122);
border-style: solid;
cursor: pointer;
::ng-deep label {
cursor: pointer;
}
}
marketplace-additional {
marketplace-versions {
padding-bottom: 2rem;
}
`,
@@ -147,37 +115,39 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
CommonModule,
MarketplacePackageHeroComponent,
MarketplaceDependenciesComponent,
MarketplaceAdditionalItemComponent,
TuiButton,
AdditionalModule,
AboutModule,
SharedPipesModule,
FormsModule,
TuiRadioList,
TuiLoader,
FlavorsComponent,
i18nPipe,
MarketplaceLinksComponent,
MarketplaceFlavorsComponent,
MarketplaceAboutComponent,
MarketplaceControlsComponent,
MarketplaceVersionsComponent,
MarketplaceReleaseNotesComponent,
],
})
export class MarketplacePreviewComponent {
@Input({ required: true })
pkgId!: string
private readonly dialog = inject(DialogService)
private readonly exver = inject(Exver)
private readonly router = inject(Router)
private readonly marketplaceService = inject(MarketplaceService)
readonly pkgId = input.required<string>()
private readonly flavor$ = this.router.routerState.root.queryParamMap.pipe(
map(paramMap => paramMap.get('flavor')),
take(1),
)
readonly version$ = new BehaviorSubject<string | null>(null)
readonly pkg$ = combineLatest([this.version$, this.flavor$]).pipe(
readonly selectedVersion$ = new BehaviorSubject<string | null>(null)
readonly pkg$ = combineLatest([this.selectedVersion$, this.flavor$]).pipe(
tap(console.error),
switchMap(([version, flavor]) =>
this.marketplaceService
.getPackage$(this.pkgId, version, flavor)
.getPackage$(this.pkgId(), version, flavor)
.pipe(startWith(null)),
),
shareReplay({ bufferSize: 1, refCount: false }),
)
readonly flavors$ = this.flavor$.pipe(
@@ -185,7 +155,7 @@ export class MarketplacePreviewComponent {
this.marketplaceService.currentRegistry$.pipe(
map(({ packages }) =>
packages.filter(
({ id, flavor }) => id === this.pkgId && flavor !== current,
({ id, flavor }) => id === this.pkgId() && flavor !== current,
),
),
filter(p => p.length > 0),
@@ -211,34 +181,18 @@ export class MarketplacePreviewComponent {
this.router.navigate([], { queryParams: { id } })
}
onStatic(asset: 'license' | 'instructions') {
const label = asset === 'license' ? 'License' : 'Instructions'
onStatic() {
const content = this.pkg$.pipe(
filter(Boolean),
switchMap(pkg =>
this.marketplaceService.fetchStatic$(
pkg,
asset === 'license' ? 'LICENSE.md' : 'instructions.md',
),
),
switchMap(pkg => this.marketplaceService.fetchStatic$(pkg)),
)
this.dialog
.openComponent(MARKDOWN, { label, size: 'l', data: { content } })
.openComponent(MARKDOWN, {
label: 'License',
size: 'l',
data: { content },
})
.subscribe()
}
selectVersion(
{ version }: MarketplacePkg,
template: TemplateRef<TuiDialogContext>,
) {
this.dialog
.openComponent<string>(template, {
label: 'All versions',
size: 's',
data: { version },
})
.pipe(filter(Boolean))
.subscribe(selected => this.version$.next(selected))
}
}

View File

@@ -42,7 +42,7 @@ export class MarketplaceAlertsService {
async alertBreakages(breakages: string[]): Promise<boolean> {
let content =
`${this.i18n.transform('As a result of this update, the following services will no longer work properly and may crash')}:<ul>'` as i18nKey
`${this.i18n.transform('As a result of this update, the following services will no longer work properly and may crash')}:<ul>` as i18nKey
const bullets = breakages.map(title => `<li><b>${title}</b></li>`)
content = `${content}${bullets.join('')}</ul>` as i18nKey

View File

@@ -7,7 +7,6 @@ import {
input,
} from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { TuiLet } from '@taiga-ui/cdk'
import { TuiButton } from '@taiga-ui/core'
import { map } from 'rxjs'
import { ControlsService } from 'src/app/services/controls.service'
@@ -41,11 +40,11 @@ import { getManifest } from 'src/app/utils/get-package-data'
}
@if (status() === 'stopped') {
@let unmet = hasUnmet() | async;
<button
*tuiLet="hasUnmet() | async as hasUnmet"
tuiButton
iconStart="@tui.play"
(click)="controls.start(manifest(), !!hasUnmet)"
(click)="controls.start(manifest(), !!unmet)"
>
{{ 'Start' | i18n }}
</button>
@@ -83,7 +82,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, i18nPipe, TuiLet, AsyncPipe],
imports: [TuiButton, i18nPipe, AsyncPipe],
})
export class ServiceControlsComponent {
private readonly errors = inject(DepErrorService)

View File

@@ -16,7 +16,7 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
@for (d of pkg.currentDependencies | keyvalue; track $index) {
<a
tuiCell
[routerLink]="services[d.key] ? ['..', d.key] : ['/portal/marketplace']"
[routerLink]="services[d.key] ? ['..', d.key] : ['/marketplace']"
[queryParams]="services[d.key] ? {} : { id: d.key }"
[class.error]="getError(d.key)"
>

View File

@@ -1,9 +1,9 @@
import { DOCUMENT } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
DOCUMENT,
} from '@angular/core'
import { RouterLink } from '@angular/router'
import { T } from '@start9labs/start-sdk'

View File

@@ -6,7 +6,6 @@ import {
} from '@angular/core'
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiLet } from '@taiga-ui/cdk'
import { TuiButton } from '@taiga-ui/core'
import { TuiProgress } from '@taiga-ui/kit'
import { InstallingProgressPipe } from 'src/app/routes/portal/routes/services/pipes/install-progress.pipe'
@@ -34,7 +33,8 @@ import { getManifest } from 'src/app/utils/get-package-data'
phase of pkg.stateInfo.installingInfo?.progress?.phases;
track $index
) {
<div *tuiLet="phase.progress | installingProgress as percent">
@let percent = phase.progress | installingProgress;
<div>
{{ $any(phase.name) | i18n }}:
@if (phase.progress === null) {
<span>{{ 'waiting' | i18n }}</span>
@@ -76,7 +76,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
`,
host: { class: 'g-card' },
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiProgress, TuiLet, InstallingProgressPipe, i18nPipe, TuiButton],
imports: [TuiProgress, InstallingProgressPipe, i18nPipe, TuiButton],
})
export class ServiceInstallProgressComponent {
@Input({ required: true })

View File

@@ -6,7 +6,7 @@ import {
inject,
input,
} from '@angular/core'
import { TuiLet } from '@taiga-ui/cdk'
import { i18nPipe } from '@start9labs/shared'
import { TuiButton, tuiButtonOptionsProvider } from '@taiga-ui/core'
import { map } from 'rxjs'
import { ControlsService } from 'src/app/services/controls.service'
@@ -15,7 +15,6 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { renderPkgStatus } from 'src/app/services/pkg-status-rendering.service'
import { getManifest } from 'src/app/utils/get-package-data'
import { UILaunchComponent } from './ui-launch.component'
import { i18nPipe } from '@start9labs/shared'
const RUNNING = ['running', 'starting', 'restarting']
@@ -32,19 +31,19 @@ const RUNNING = ['running', 'starting', 'restarting']
{{ 'Stop' | i18n }}
</button>
} @else {
@let unmet = hasUnmet() | async;
<button
*tuiLet="hasUnmet() | async as hasUnmet"
tuiIconButton
iconStart="@tui.play"
[disabled]="status().primary !== 'stopped'"
(click)="controls.start(manifest(), !!hasUnmet)"
(click)="controls.start(manifest(), !!unmet)"
>
{{ 'Start' | i18n }}
</button>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, UILaunchComponent, TuiLet, AsyncPipe, i18nPipe],
imports: [TuiButton, UILaunchComponent, AsyncPipe, i18nPipe],
providers: [tuiButtonOptionsProvider({ size: 's', appearance: 'none' })],
styles: `
:host {

View File

@@ -162,7 +162,7 @@ export class ServiceComponent implements OnChanges {
}
get routerLink() {
return `/portal/services/${this.manifest.id}`
return `/services/${this.manifest.id}`
}
ngOnChanges() {

View File

@@ -1,9 +1,9 @@
import { DOCUMENT } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
DOCUMENT,
} from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'

View File

@@ -82,12 +82,8 @@ export default class ServiceAboutRoute {
action: () => this.copyService.copy(manifest.version),
},
{
name: 'SDK Version',
value: manifest.sdkVersion || '-',
icon: manifest.sdkVersion ? '@tui.copy' : '',
action: () =>
manifest.sdkVersion &&
this.copyService.copy(manifest.sdkVersion),
name: 'Installed From',
value: pkg.registry || NOT_PROVIDED,
},
{
name: 'Git Hash',
@@ -96,6 +92,14 @@ export default class ServiceAboutRoute {
action: () =>
manifest.gitHash && this.copyService.copy(manifest.gitHash),
},
{
name: 'SDK Version',
value: manifest.sdkVersion || '-',
icon: manifest.sdkVersion ? '@tui.copy' : '',
action: () =>
manifest.sdkVersion &&
this.copyService.copy(manifest.sdkVersion),
},
{
name: 'License',
value: manifest.license,
@@ -105,30 +109,35 @@ export default class ServiceAboutRoute {
],
},
{
header: 'Links',
header: 'Source Code',
items: [
{
name: 'Installed From',
value: pkg.registry || NOT_PROVIDED,
},
{
name: 'Service Repository',
name: 'Upstream service',
value: manifest.upstreamRepo,
},
{
name: 'Package Repository',
name: 'StartOS package',
value: manifest.wrapperRepo,
},
],
},
{
header: 'Links',
items: [
{
name: 'Marketing Site',
name: 'Marketing',
value: manifest.marketingSite || NOT_PROVIDED,
},
{
name: 'Support Site',
name: 'Documentation',
value: manifest.docsUrl || NOT_PROVIDED,
},
{
name: 'Support',
value: manifest.supportSite || NOT_PROVIDED,
},
{
name: 'Donation Link',
name: 'Donations',
value: manifest.donationUrl || NOT_PROVIDED,
},
],

View File

@@ -1,5 +1,10 @@
import { KeyValuePipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { getPkgId, i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
@@ -12,29 +17,24 @@ import { StandardActionsService } from 'src/app/services/standard-actions.servic
import { getManifest } from 'src/app/utils/get-package-data'
import { ServiceActionComponent } from '../components/action.component'
const OTHER = 'Custom Actions'
@Component({
template: `
@if (package(); as pkg) {
@for (group of pkg.actions | keyvalue; track $index) {
@if (group.value.length) {
<section class="g-card">
<header>{{ group.key }}</header>
@for (a of group.value; track $index) {
@if (a.visibility !== 'hidden') {
<button
tuiCell
[action]="a"
(click)="handle(pkg.mainStatus, pkg.icon, pkg.manifest, a)"
></button>
}
}
</section>
}
<section class="g-card">
<header>{{ group.key }}</header>
@for (a of group.value; track $index) {
<button
tuiCell
[action]="a"
(click)="handle(pkg.mainStatus, pkg.icon, pkg.manifest, a)"
></button>
}
</section>
}
<section class="g-card">
<header>{{ 'Standard Actions' | i18n }}</header>
<header>StartOS</header>
<button
tuiCell
[action]="rebuild"
@@ -58,37 +58,56 @@ const OTHER = 'Custom Actions'
`,
host: { class: 'g-subpage' },
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ServiceActionComponent, TuiCell, KeyValuePipe, i18nPipe],
imports: [ServiceActionComponent, TuiCell, KeyValuePipe],
})
export default class ServiceActionsRoute {
private readonly actions = inject(ActionService)
private readonly i18n = inject(i18nPipe)
ungrouped: 'General' | 'Other' = 'General'
readonly service = inject(StandardActionsService)
readonly package = toSignal(
inject<PatchDB<DataModel>>(PatchDB)
.watch$('packageData', getPkgId())
.pipe(
filter(pkg => pkg.stateInfo.state === 'installed'),
map(pkg => ({
mainStatus: pkg.status.main,
icon: pkg.icon,
manifest: getManifest(pkg),
actions: Object.entries(pkg.actions)
.filter(([_, val]) => val.visibility !== 'hidden')
.reduce<
Record<string, ReadonlyArray<T.ActionMetadata & { id: string }>>
>(
(acc, [id]) => {
const action = { id, ...pkg.actions[id]! }
const group = pkg.actions[id]?.group || OTHER
const current = acc[group] || []
return { ...acc, [group]: current.concat(action) }
},
{ [OTHER]: [] },
),
})),
map(pkg => {
const specialGroup = Object.values(pkg.actions).some(
pkg => !!pkg.group,
)
? 'Other'
: 'General'
return {
mainStatus: pkg.status.main,
icon: pkg.icon,
manifest: getManifest(pkg),
actions: Object.entries(pkg.actions)
.map(([id, action]) => ({
...action,
id,
group: action.group || specialGroup,
}))
.sort((a, b) => {
if (a.group === specialGroup) return 1
if (b.group === specialGroup) return -1
return a.group.localeCompare(b.group) // Optional: sort others alphabetically
})
.reduce<
Record<
string,
Array<T.ActionMetadata & { id: string; group: string }>
>
>((acc, action) => {
const key = action.group
if (!acc[key]) {
acc[key] = []
}
acc[key].push(action)
return acc
}, {}),
}
}),
),
)
@@ -102,7 +121,7 @@ export default class ServiceActionsRoute {
readonly uninstall = {
name: this.i18n.transform('Uninstall')!,
description: this.i18n.transform(
'Uninstalls this service from StartOS and delete all data permanently.',
'Uninstalls this service from StartOS and deletes all data permanently.',
)!,
}

View File

@@ -33,13 +33,15 @@ const INACTIVE: PrimaryStatus[] = [
@if (service()) {
<div *title class="title">
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">Back</a>
<tui-avatar size="xs" [style.margin-inline-end.rem]="0.75">
<img alt="" [src]="service()?.icon" />
</tui-avatar>
<span tuiFade>{{ manifest()?.title }}</span>
<div routerLink="./" class="m-header">
<tui-avatar size="xs" [style.margin-inline-end.rem]="0.75">
<img alt="" [src]="service()?.icon" />
</tui-avatar>
<span tuiFade>{{ manifest()?.title }}</span>
</div>
</div>
<aside class="g-aside">
<header tuiCell>
<header tuiCell routerLink="./">
<tui-avatar><img alt="" [src]="service()?.icon" /></tui-avatar>
<span tuiTitle>
<strong tuiFade>{{ manifest()?.title }}</strong>
@@ -88,6 +90,7 @@ const INACTIVE: PrimaryStatus[] = [
header {
margin: 0 -0.5rem;
cursor: pointer;
}
nav[inert] a:not(:first-child) {
@@ -107,6 +110,11 @@ const INACTIVE: PrimaryStatus[] = [
}
}
.m-header {
cursor: pointer;
display: flex;
}
:host-context(tui-root._mobile) {
flex-direction: column;
padding: 0;
@@ -171,7 +179,6 @@ export class ServiceOutletComponent {
protected readonly nav: { title: i18nKey; icon: string }[] = [
{ title: 'dashboard', icon: '@tui.layout-dashboard' },
{ title: 'actions', icon: '@tui.clapperboard' },
{ title: 'instructions', icon: '@tui.book-open-text' },
{ title: 'logs', icon: '@tui.logs' },
{ title: 'about', icon: '@tui.info' },
]
@@ -186,7 +193,7 @@ export class ServiceOutletComponent {
tap(pkg => {
// if package disappears, navigate to list page
if (!pkg) {
this.router.navigate(['./portal/services'])
this.router.navigate(['services'])
}
}),
),

View File

@@ -1,6 +1,5 @@
import { inject } from '@angular/core'
import { ActivatedRouteSnapshot, ResolveFn, Routes } from '@angular/router'
import { MarkdownComponent } from '@start9labs/shared'
import { defer, map, Observable, of } from 'rxjs'
import { share } from 'rxjs/operators'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@@ -23,20 +22,6 @@ export const ROUTES: Routes = [
path: 'actions',
loadComponent: () => import('./routes/actions.component'),
},
{
path: 'instructions',
component: MarkdownComponent,
resolve: { content: getStatic('instructions.md') },
canActivate: [
({ paramMap }: ActivatedRouteSnapshot) => {
inject(ApiService)
.setDbValue(['ackInstructions', paramMap.get('pkgId')!], true)
.catch(e => console.error('Failed to mark as seen', e))
return true
},
],
},
{
path: 'interface/:interfaceId',
loadComponent: () => import('./routes/interface.component'),
@@ -48,7 +33,7 @@ export const ROUTES: Routes = [
{
path: 'about',
loadComponent: () => import('./routes/about.component'),
resolve: { content: getStatic('LICENSE.md') },
resolve: { content: getStatic() },
},
],
},
@@ -59,15 +44,13 @@ export const ROUTES: Routes = [
},
]
function getStatic(
path: 'LICENSE.md' | 'instructions.md',
): ResolveFn<Observable<string>> {
function getStatic(): ResolveFn<Observable<string>> {
return ({ paramMap }: ActivatedRouteSnapshot) =>
of(inject(ApiService)).pipe(
map(api =>
defer(() => api.getStaticInstalled(paramMap.get('pkgId')!, path)).pipe(
share(),
),
defer(() =>
api.getStaticInstalled(paramMap.get('pkgId')!, 'LICENSE.md'),
).pipe(share()),
),
)
}

View File

@@ -1,22 +1,14 @@
import { CommonModule } from '@angular/common'
import { Component, inject, input } from '@angular/core'
import { toObservable } from '@angular/core/rxjs-interop'
import {
AboutModule,
AdditionalModule,
MarketplaceAboutComponent,
MarketplaceLinksComponent,
MarketplaceDependenciesComponent,
MarketplacePackageHeroComponent,
MarketplaceReleaseNotesComponent,
} from '@start9labs/marketplace'
import {
DialogService,
Exver,
MARKDOWN,
SharedPipesModule,
} from '@start9labs/shared'
import { PatchDB } from 'patch-db-client'
import { filter, first, map, of, switchMap } from 'rxjs'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data'
import { DialogService, MARKDOWN, SharedPipesModule } from '@start9labs/shared'
import { of } from 'rxjs'
import { MarketplaceControlsComponent } from '../marketplace/components/controls.component'
import { MarketplacePkgSideload } from './sideload.utils'
@@ -26,25 +18,18 @@ import { MarketplacePkgSideload } from './sideload.utils'
<div class="outer-container">
<ng-content />
<marketplace-package-hero [pkg]="pkg()">
<marketplace-controls
slot="controls"
class="controls-wrapper"
[version]="pkg().version"
[installAlert]="pkg().alerts.install"
[localPkg]="local$ | async"
[localFlavor]="!!(flavor$ | async)"
[file]="file()"
/>
<marketplace-controls [pkg]="pkg()" [file]="file()" />
</marketplace-package-hero>
<div class="package-details">
<div class="package-details-main">
<marketplace-about [pkg]="pkg()" />
<marketplace-release-notes [pkg]="pkg()" />
@if (!(pkg().dependencyMetadata | empty)) {
<marketplace-dependencies [pkg]="pkg()" />
}
</div>
<div class="package-details-additional">
<marketplace-additional [pkg]="pkg()" (static)="onStatic($event)" />
<marketplace-links [pkg]="pkg()" (static)="onStatic()" />
</div>
</div>
</div>
@@ -62,7 +47,6 @@ import { MarketplacePkgSideload } from './sideload.utils'
}
.package-details {
-moz-column-gap: 2rem;
column-gap: 2rem;
&-main {
@@ -80,62 +64,36 @@ import { MarketplacePkgSideload } from './sideload.utils'
}
&-additional {
grid-column: span 4 / span 4;
margin-top: 0px;
margin-top: 0;
}
}
}
.controls-wrapper {
display: flex;
justify-content: flex-start;
gap: 0.5rem;
height: 4.5rem;
}
`,
imports: [
CommonModule,
SharedPipesModule,
AboutModule,
AdditionalModule,
MarketplaceAboutComponent,
MarketplaceLinksComponent,
MarketplacePackageHeroComponent,
MarketplaceDependenciesComponent,
MarketplaceControlsComponent,
MarketplaceReleaseNotesComponent,
],
})
export class SideloadPackageComponent {
private readonly exver = inject(Exver)
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
private readonly dialog = inject(DialogService)
readonly pkg = input.required<MarketplacePkgSideload>()
readonly file = input.required<File>()
readonly local$ = toObservable(this.pkg).pipe(
filter(Boolean),
switchMap(({ id, flavor }) =>
this.patch.watch$('packageData', id).pipe(
filter(Boolean),
map(pkg =>
this.exver.getFlavor(getManifest(pkg).version) === flavor
? pkg
: null,
),
),
),
first(),
)
readonly flavor$ = this.local$.pipe(map(pkg => !pkg))
onStatic(type: 'license' | 'instructions') {
const label = type === 'license' ? 'License' : 'Instructions'
const key = type === 'license' ? 'fullLicense' : 'instructions'
onStatic() {
const content = of(this.pkg()['license'])
this.dialog
.openComponent(MARKDOWN, {
label,
label: 'License',
size: 'l',
data: { content: of(this.pkg()[key]) },
data: { content },
})
.subscribe()
}

View File

@@ -5,6 +5,7 @@ import {
signal,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { i18nKey, i18nPipe } from '@start9labs/shared'
import { tuiIsString } from '@taiga-ui/cdk'
import { TuiButton } from '@taiga-ui/core'
import {
@@ -14,9 +15,9 @@ import {
} from '@taiga-ui/kit'
import { ConfigService } from 'src/app/services/config.service'
import { TitleDirective } from 'src/app/services/title.service'
import { SideloadPackageComponent } from './package.component'
import { MarketplacePkgSideload, validateS9pk } from './sideload.utils'
import { i18nKey, i18nPipe } from '@start9labs/shared'
@Component({
template: `

View File

@@ -37,12 +37,10 @@ async function parseS9pk(file: File): Promise<MarketplacePkgSideload> {
return {
...s9pk.manifest,
dependencyMetadata: await s9pk.dependencyMetadata(),
gitHash: '',
icon: await s9pk.icon(),
sourceVersion: s9pk.manifest.canMigrateFrom,
flavor: ExtendedVersion.parse(s9pk.manifest.version).flavor,
fullLicense: await s9pk.license(),
instructions: await s9pk.instructions(),
}
}
@@ -77,6 +75,5 @@ function compare(a: Uint8Array, b: Uint8Array) {
}
export type MarketplacePkgSideload = MarketplacePkgBase & {
instructions: string
fullLicense: string
}

View File

@@ -156,7 +156,7 @@ export class BackupsRecoverComponent {
await this.api.restorePackages(params)
this.context.$implicit.complete()
this.router.navigate(['portal', 'services'])
this.router.navigate(['services'])
} catch (e: any) {
this.errorService.handleError(e)
} finally {

View File

@@ -19,25 +19,20 @@ import { DomainsTableComponent } from './table.component'
@Component({
template: `
<domains-info />
<ng-container *ngIf="domains$ | async as domains">
@if (domains$ | async; as domains) {
<h3 class="g-title">
Start9.to
<button
*ngIf="!domains.start9To.length"
tuiButton
size="xs"
iconStart="@tui.plus"
(click)="claim()"
>
Claim
</button>
@if (!domains.start9To.length) {
<button tuiButton size="xs" iconStart="@tui.plus" (click)="claim()">
Claim
</button>
}
</h3>
<table
class="g-table"
[domains]="domains.start9To"
(delete)="delete()"
></table>
<h3 class="g-title">
Custom Domains
<button tuiButton size="xs" iconStart="@tui.plus" (click)="add()">
@@ -49,7 +44,7 @@ import { DomainsTableComponent } from './table.component'
[domains]="domains.custom"
(delete)="delete($event.value)"
></table>
</ng-container>
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
@@ -102,7 +97,7 @@ export default class SystemDomainsComponent {
buttons: [
{
text: 'Manage proxies',
link: '/portal/system/proxies',
link: '/system/proxies',
},
{
text: 'Save',
@@ -127,7 +122,7 @@ export default class SystemDomainsComponent {
buttons: [
{
text: 'Manage proxies',
link: '/portal/system/proxies',
link: '/system/proxies',
},
{
text: 'Save',

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
@@ -24,7 +23,7 @@ import { Domain } from 'src/app/services/patch-db/data-model'
</thead>
<tbody>
@for (domain of domains; track $index) {
<tr *ngFor="let domain of domains">
<tr>
<td class="title">{{ domain.value }}</td>
<td class="provider">{{ domain.provider }}</td>
<td class="strategy">{{ getStrategy(domain) }}</td>
@@ -105,7 +104,7 @@ import { Domain } from 'src/app/services/patch-db/data-model'
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, TuiButton, TuiLink],
imports: [TuiButton, TuiLink],
})
export class DomainsTableComponent {
private readonly dialogs = inject(TuiDialogService)

View File

@@ -1,9 +1,10 @@
import { AsyncPipe, DOCUMENT } from '@angular/common'
import { AsyncPipe } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
INJECTOR,
DOCUMENT,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { FormsModule } from '@angular/forms'
@@ -130,6 +131,7 @@ import { SystemWipeComponent } from './wipe.component'
{{ 'Change' | i18n }}
<tui-data-list-wrapper
*tuiTextfieldDropdown
new
size="l"
[items]="languages"
[itemContent]="translation"

View File

@@ -1,10 +1,10 @@
import { DOCUMENT } from '@angular/common'
import {
AfterViewInit,
Component,
HostListener,
inject,
OnDestroy,
DOCUMENT,
} from '@angular/core'
import { i18nPipe, pauseFor } from '@start9labs/shared'
import { TuiButton, TuiDialogContext } from '@taiga-ui/core'

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
@@ -121,7 +120,7 @@ import { Proxy } from 'src/app/services/patch-db/data-model'
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, TuiLink, TuiButton, TuiSkeleton],
imports: [TuiLink, TuiButton, TuiSkeleton],
})
export class ProxiesTableComponent {
private readonly dialogs = inject(TuiDialogService)

View File

@@ -9,35 +9,37 @@ import { RouterPortComponent } from './table.component'
@Component({
template: `
<ng-container *ngIf="server$ | async as server">
@if (server$ | async; as server) {
<router-info [enabled]="!server.network.wanConfig.upnp" />
<table
*ngIf="server.host.hostnameInfo[80] | primaryIp as ip"
tuiTextfieldAppearance="unstyled"
tuiTextfieldSize="m"
[tuiTextfieldLabelOutside]="true"
>
<thead>
<tr>
<th [style.width.rem]="2.5"></th>
<th [style.padding-left.rem]="0.75">
<div class="g-title">Port</div>
</th>
<th>
<div class="g-title">Target</div>
</th>
<th [style.width.rem]="3"></th>
</tr>
</thead>
<tbody>
<tr
*ngFor="let portForward of server.network.wanConfig.forwards"
[portForward]="portForward"
[ip]="ip"
></tr>
</tbody>
</table>
</ng-container>
@if (server.host.hostnameInfo[80] | primaryIp; as ip) {
<table
tuiTextfieldAppearance="unstyled"
tuiTextfieldSize="m"
[tuiTextfieldLabelOutside]="true"
>
<thead>
<tr>
<th [style.width.rem]="2.5"></th>
<th [style.padding-left.rem]="0.75">
<div class="g-title">Port</div>
</th>
<th>
<div class="g-title">Target</div>
</th>
<th [style.width.rem]="3"></th>
</tr>
</thead>
<tbody>
@for (
portForward of server.network.wanConfig.forwards;
track portForward
) {
<tr [portForward]="portForward" [ip]="ip"></tr>
}
</tbody>
</table>
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
styles: `

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
@@ -21,17 +20,14 @@ import { PortForward } from 'src/app/services/patch-db/data-model'
selector: 'tr[portForward]',
template: `
<td [style.text-align]="'right'">
<tui-icon
*ngIf="portForward.error; else noError"
icon="@tui.x"
[style.color]="'var(--tui-text-negative)'"
/>
<ng-template #noError>
@if (portForward.error) {
<tui-icon icon="@tui.x" [style.color]="'var(--tui-text-negative)'" />
} @else {
<tui-icon
icon="@tui.check"
[style.color]="'var(--tui-text-positive)'"
/>
</ng-template>
}
</td>
<td>
<tui-input-number
@@ -44,17 +40,17 @@ import { PortForward } from 'src/app/services/patch-db/data-model'
<input tuiTextfieldLegacy type="text" />
</tui-input-number>
<ng-template #buttons>
<button
*ngIf="!editing; else actions"
tuiIconButton
appearance="icon"
iconStart="@tui.pencil"
size="s"
(click)="toggle(true)"
>
Edit
</button>
<ng-template #actions>
@if (!editing) {
<button
tuiIconButton
appearance="icon"
iconStart="@tui.pencil"
size="s"
(click)="toggle(true)"
>
Edit
</button>
} @else {
<button
tuiIconButton
appearance="icon"
@@ -74,7 +70,7 @@ import { PortForward } from 'src/app/services/patch-db/data-model'
>
Save
</button>
</ng-template>
}
</ng-template>
</td>
<td>{{ ip }}:{{ portForward.target }}</td>
@@ -97,7 +93,6 @@ import { PortForward } from 'src/app/services/patch-db/data-model'
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
FormsModule,
TuiIcon,
TuiInputModule,

View File

@@ -7,7 +7,6 @@ import {
} from '@angular/core'
import { RouterLink } from '@angular/router'
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
import { TuiLet } from '@taiga-ui/cdk'
import { TuiButton, TuiTitle } from '@taiga-ui/core'
import { TuiHeader } from '@taiga-ui/layout'
import { from, map, merge, Observable, Subject } from 'rxjs'
@@ -38,7 +37,8 @@ import { SessionsTableComponent } from './table.component'
<div [single]="true" [sessions]="current$ | async"></div>
</section>
<section *tuiLet="other$ | async as others" class="g-card">
@let others = other$ | async;
<section class="g-card">
<header>
{{ 'Other sessions' | i18n }}
<button
@@ -60,7 +60,6 @@ import { SessionsTableComponent } from './table.component'
CommonModule,
TuiButton,
SessionsTableComponent,
TuiLet,
RouterLink,
TitleDirective,
TuiHeader,

View File

@@ -4,16 +4,15 @@ import {
Component,
computed,
input,
Input,
OnChanges,
signal,
} from '@angular/core'
import { FormsModule } from '@angular/forms'
import { TuiIcon } from '@taiga-ui/core'
import { TuiCheckbox, TuiFade, TuiSkeleton } from '@taiga-ui/kit'
import { BehaviorSubject } from 'rxjs'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { Session } from 'src/app/services/api/api.types'
import { toAcmeName } from 'src/app/utils/acme'
import { PlatformInfoPipe } from './platform-info.pipe'
import { i18nPipe } from '@start9labs/shared'
@@ -41,20 +40,18 @@ import { i18nPipe } from '@start9labs/shared'
</th>
}
@for (session of sessions(); track $index) {
<tr>
<tr (longtap)="!selected().length && onToggle(session)">
<td [style.padding-left.rem]="single() ? null : 2.5">
<label>
@if (!single()) {
<input
tuiCheckbox
size="s"
type="checkbox"
[ngModel]="selected().includes(session)"
(ngModelChange)="onToggle(session)"
/>
}
<div tuiFade class="agent">{{ session.userAgent || '-' }}</div>
</label>
@if (!single()) {
<input
tuiCheckbox
size="s"
type="checkbox"
[ngModel]="selected().includes(session)"
(ngModelChange)="onToggle(session)"
/>
}
<div tuiFade class="agent">{{ session.userAgent || '-' }}</div>
</td>
@if (session.userAgent | platformInfo; as platform) {
<td class="platform">
@@ -109,23 +106,21 @@ import { i18nPipe } from '@start9labs/shared'
}
:host-context(tui-root._mobile) {
table:has(:checked) .platform {
visibility: hidden;
}
table:not(:has(:checked)) input {
visibility: hidden;
}
tr {
grid-template-columns: 2.5rem 1fr;
&:has(:checked) .platform {
visibility: hidden;
}
user-select: none;
}
input {
left: 0.25rem;
&:not(:checked) {
@include taiga.fullsize();
z-index: 1;
visibility: hidden;
transform: none;
}
}
td {
@@ -187,4 +182,6 @@ export class SessionsTableComponent<T extends Session> implements OnChanges {
this.selected.update(selected => [...selected, session])
}
}
protected readonly toAcmeName = toAcmeName
}

View File

@@ -1,55 +1,93 @@
import { RouterLink } from '@angular/router'
import { TuiTable } from '@taiga-ui/addon-table'
import { TuiButton, TuiLink, TuiTitle } from '@taiga-ui/core'
import { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ErrorService } from '@start9labs/shared'
import {
ChangeDetectionStrategy,
Component,
inject,
viewChild,
} from '@angular/core'
import { RouterLink } from '@angular/router'
import {
DialogService,
DocsLinkDirective,
ErrorService,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { ISB } from '@start9labs/start-sdk'
import { TuiButton, TuiLink, TuiTitle } from '@taiga-ui/core'
import { TuiHeader } from '@taiga-ui/layout'
import { catchError, defer, of } from 'rxjs'
import { filter, from, merge, Subject } from 'rxjs'
import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { SSHKey } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { TitleDirective } from 'src/app/services/title.service'
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
import { SSHTableComponent } from './table.component'
import { DocsLinkDirective } from 'projects/shared/src/public-api'
@Component({
template: `
<ng-container *title>
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">Back</a>
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
{{ 'Back' | i18n }}
</a>
SSH
</ng-container>
<header tuiHeader>
<hgroup tuiTitle>
<h3>SSH</h3>
<p tuiSubtitle>
Manage your SSH keys to access your server from the command line
{{
'By default, you can SSH into your server from any device using your master password. Optionally add SSH public keys to grant specific devices access without needing to enter a password.'
| i18n
}}
<a
tuiLink
docsLink
href="/@TODO"
href="/user-manual/ssh"
appearance="action-grayscale"
iconEnd="@tui.external-link"
[pseudo]="true"
[textContent]="'View instructions'"
[textContent]="'View instructions' | i18n"
></a>
</p>
</hgroup>
</header>
@let keys = keys$ | async;
<section class="g-card">
<header>
Saved Keys
<button
tuiButton
size="xs"
iconStart="@tui.trash"
appearance="primary-destructive"
[style.margin]="'0 0.5rem 0 auto'"
[disabled]="!tableKeys()?.selected()?.length"
(click)="remove(keys || [])"
>
{{ 'Delete selected' | i18n }}
</button>
<button
tuiButton
size="xs"
iconStart="@tui.plus"
[style.margin-inline-start]="'auto'"
(click)="table.add.call(table)"
(click)="add(keys || [])"
>
Add Key
</button>
</header>
<div #table [keys]="keys$ | async"></div>
<div #table [keys]="keys"></div>
</section>
`,
styles: `
:host-context(tui-root._mobile) {
[tuiButton] {
font-size: 0;
gap: 0;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
@@ -57,22 +95,90 @@ import { DocsLinkDirective } from 'projects/shared/src/public-api'
SSHTableComponent,
RouterLink,
TitleDirective,
TuiTable,
TuiHeader,
TuiTitle,
TuiLink,
i18nPipe,
DocsLinkDirective,
],
})
export default class SystemSSHComponent {
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly loader = inject(LoadingService)
private readonly formDialog = inject(FormDialogService)
private readonly i18n = inject(i18nPipe)
private readonly dialogs = inject(DialogService)
readonly keys$ = defer(() => this.api.getSshKeys({})).pipe(
catchError(e => {
this.errorService.handleError(e)
private readonly local$ = new Subject<readonly SSHKey[]>()
return of([])
}),
)
readonly keys$ = merge(from(this.api.getSshKeys({})), this.local$)
protected tableKeys = viewChild<SSHTableComponent<SSHKey>>('table')
async add(all: readonly SSHKey[]) {
this.formDialog.open(FormComponent, {
label: 'Add SSH Public Key',
data: {
spec: await configBuilderToSpec(SSHSpec),
buttons: [
{
text: this.i18n.transform('Save'),
handler: async ({ key }: typeof SSHSpec._TYPE) => {
const loader = this.loader.open('Saving').subscribe()
try {
const newKey = await this.api.addSshKey({ key })
this.local$.next([newKey, ...all])
return true
} catch (e: any) {
this.errorService.handleError(e)
return false
} finally {
loader.unsubscribe()
}
},
},
],
},
})
}
remove(all: readonly SSHKey[]) {
this.dialogs
.openConfirm({ label: 'Are you sure?', size: 's' })
.pipe(filter(Boolean))
.subscribe(async () => {
const selected = this.tableKeys()?.selected() || []
const fingerprints = selected.map(s => s.fingerprint) || []
const loader = this.loader.open('Deleting').subscribe()
try {
await this.api.deleteSshKey({ fingerprint: '' })
this.local$.next(
all.filter(s => !fingerprints.includes(s.fingerprint)),
)
this.tableKeys()?.selected.set([])
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
})
}
}
const SSHSpec = ISB.InputSpec.of({
key: ISB.Value.text({
name: 'Public Key',
required: true,
default: null,
patterns: [
{
regex:
'^(ssh-(rsa|ed25519|dss|ecdsa)|ecdsa-sha2-nistp(256|384|521))\\s+[A-Za-z0-9+/=]+(\\s[^\\s]+)?$',
description: 'must be a valid SSH public key',
},
],
}),
})

View File

@@ -1,56 +1,60 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
inject,
Input,
computed,
input,
OnChanges,
signal,
} from '@angular/core'
import { ErrorService, LoadingService } from '@start9labs/shared'
import { TuiButton, TuiDialogOptions, TuiDialogService } from '@taiga-ui/core'
import {
TUI_CONFIRM,
TuiConfirmData,
TuiFade,
TuiSkeleton,
} from '@taiga-ui/kit'
import { filter, take } from 'rxjs'
import { FormsModule } from '@angular/forms'
import { i18nPipe } from '@start9labs/shared'
import { TuiCheckbox, TuiFade, TuiSkeleton } from '@taiga-ui/kit'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { PROMPT } from 'src/app/routes/portal/modals/prompt.component'
import { SSHKey } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Component({
selector: '[keys]',
template: `
<table
[appTable]="['Hostname', 'Created At', 'Algorithm', 'Fingerprint', null]"
>
@for (key of keys; track $index) {
<tr>
<td class="title">{{ key.hostname }}</td>
<table [appTable]="['Created At', 'Algorithm', 'Fingerprint']">
<th [style.text-indent.rem]="1.75">
<input
tuiCheckbox
size="s"
type="checkbox"
[disabled]="!keys()"
[ngModel]="all()"
(ngModelChange)="selected.set(($event && keys()) || [])"
/>
{{ 'Hostname' | i18n }}
</th>
@for (key of keys(); track $index) {
<tr (longtap)="!selected().length && onToggle(key)">
<td [style.padding-left.rem]="2.5">
<input
tuiCheckbox
size="s"
type="checkbox"
[ngModel]="selected().includes(key)"
(ngModelChange)="onToggle(key)"
/>
<div tuiFade class="hostname">{{ key.hostname }}</div>
</td>
<td class="date">{{ key.createdAt | date: 'medium' }}</td>
<td class="algorithm">{{ key.alg }}</td>
<td class="fingerprint" tuiFade>{{ key.fingerprint }}</td>
<td class="actions">
<button
tuiIconButton
size="xs"
appearance="icon"
iconStart="@tui.trash-2"
(click)="delete(key)"
>
Delete
</button>
</td>
</tr>
} @empty {
@if (keys) {
<tr><td colspan="5">No keys added</td></tr>
@if (keys()) {
<tr>
<td colspan="5">{{ 'No keys' | i18n }}</td>
</tr>
} @else {
@for (i of ['', '']; track $index) {
<tr>
<td colspan="5"><div [tuiSkeleton]="true">Loading</div></td>
<td colspan="5">
<div [tuiSkeleton]="true">{{ 'Loading' | i18n }}</div>
</td>
</tr>
}
}
@@ -58,111 +62,104 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
</table>
`,
styles: `
:host-context(tui-root._mobile) {
tr {
grid-template-columns: 3fr 2fr;
}
@use '@taiga-ui/core/styles/taiga-ui-local' as taiga;
td:only-child {
td {
position: relative;
&[colspan] {
grid-column: span 2;
}
}
.title {
input {
position: absolute;
top: 50%;
left: 0.75rem;
transform: translateY(-50%);
}
:host-context(tui-root._mobile) {
table {
&:has(:checked) tr {
padding-inline-start: 2rem;
}
&:not(:has(:checked)) input {
visibility: hidden;
}
}
tr {
grid-template-columns: 1fr 5rem;
user-select: none;
}
input {
left: 0.25rem;
}
td {
width: 100%;
&:first-child {
padding: 0 !important;
}
}
.hostname {
order: 1;
grid-column: span 2;
font-weight: bold;
text-transform: uppercase;
}
.actions {
order: 2;
padding: 0;
text-align: right;
}
.fingerprint {
order: 3;
order: 2;
grid-column: span 2;
}
.date {
order: 4;
order: 3;
color: var(--tui-text-secondary);
}
.algorithm {
order: 5;
text-align: right;
&::before {
content: 'Algorithm: ';
color: var(--tui-text-secondary);
}
order: 4;
text-align: end;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, TuiButton, TuiFade, TuiSkeleton, TableComponent],
imports: [
CommonModule,
FormsModule,
TuiCheckbox,
TuiFade,
TuiSkeleton,
TableComponent,
i18nPipe,
],
})
export class SSHTableComponent {
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly dialogs = inject(TuiDialogService)
private readonly cdr = inject(ChangeDetectorRef)
export class SSHTableComponent<T extends SSHKey> implements OnChanges {
readonly keys = input<readonly T[] | null>(null)
@Input()
keys: SSHKey[] | null = null
readonly selected = signal<readonly T[]>([])
readonly all = computed(
() =>
!!this.selected()?.length &&
(this.selected().length === this.keys()?.length || null),
)
add() {
this.dialogs
.open<string>(PROMPT, ADD_OPTIONS)
.pipe(take(1))
.subscribe(async key => {
const loader = this.loader.open('Saving').subscribe()
try {
this.keys?.push(await this.api.addSshKey({ key }))
} finally {
loader.unsubscribe()
this.cdr.markForCheck()
}
})
ngOnChanges() {
this.selected.set([])
}
delete(key: SSHKey) {
this.dialogs
.open(TUI_CONFIRM, DELETE_OPTIONS)
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open('Deleting').subscribe()
try {
await this.api.deleteSshKey({ fingerprint: key.fingerprint })
this.keys?.splice(this.keys?.indexOf(key), 1)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
this.cdr.markForCheck()
}
})
onToggle(key: T) {
if (this.selected().includes(key)) {
this.selected.update(selected => selected.filter(s => s !== key))
} else {
this.selected.update(selected => [...selected, key])
}
}
}
const ADD_OPTIONS: Partial<TuiDialogOptions<{ message: string }>> = {
label: 'SSH Key',
data: {
message:
'Enter the SSH public key you would like to authorize for root access to your Embassy.',
},
}
const DELETE_OPTIONS: Partial<TuiDialogOptions<TuiConfirmData>> = {
label: 'Confirm',
size: 's',
data: {
content: 'Delete key? This action cannot be undone.',
yes: 'Delete',
no: 'Cancel',
},
}

View File

@@ -1,4 +1,3 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
@@ -89,16 +88,7 @@ import { wifiSpec } from './wifi.const'
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
CommonModule,
TuiCell,
TuiTitle,
TuiBadge,
TuiButton,
TuiIcon,
TuiFade,
i18nPipe,
],
imports: [TuiCell, TuiTitle, TuiBadge, TuiButton, TuiIcon, TuiFade, i18nPipe],
})
export class WifiTableComponent {
private readonly loader = inject(LoadingService)

View File

@@ -125,7 +125,7 @@ import { map } from 'rxjs'
})
export class SystemComponent {
readonly menu = SYSTEM_MENU
readonly badge = toSignal(inject(BadgeService).getCount('/portal/system'))
readonly badge = toSignal(inject(BadgeService).getCount('system'))
readonly wifiEnabled$ = inject<PatchDB<DataModel>>(PatchDB)
.watch$('serverInfo', 'network', 'wifi')
.pipe(map(wifi => !!wifi.interface && wifi.enabled))

View File

@@ -48,6 +48,11 @@ export const SYSTEM_MENU = [
item: 'Active Sessions',
link: 'sessions',
},
{
icon: '@tui.terminal',
item: 'SSH' as i18nKey,
link: 'ssh',
},
{
icon: '@tui.key',
item: 'Change Password',

View File

@@ -62,6 +62,11 @@ export default [
title: titleResolver,
loadComponent: () => import('./routes/sessions/sessions.component'),
},
{
path: 'ssh',
title: titleResolver,
loadComponent: () => import('./routes/ssh/ssh.component'),
},
{
path: 'password',
title: titleResolver,

View File

@@ -123,8 +123,12 @@ import UpdatesComponent from './updates.component'
<a
tuiLink
iconEnd="@tui.external-link"
routerLink="/portal/marketplace"
[queryParams]="{ url: parent.current()?.url, id: item().id }"
routerLink="/marketplace"
[queryParams]="{
url: parent.current()?.url,
id: item().id,
flavor: item().flavor,
}"
[textContent]="'View listing' | i18n"
></a>
)

View File

@@ -22,13 +22,13 @@ const routes: Routes = [
import('./routes/login/login.module').then(m => m.LoginPageModule),
},
{
path: 'portal',
path: '',
canActivate: [AuthGuard, stateNot(['error', 'initializing'])],
loadChildren: () => import('./routes/portal/portal.routes'),
},
{
path: '**',
redirectTo: 'portal',
redirectTo: '',
pathMatch: 'full',
},
]

View File

@@ -110,7 +110,7 @@ export namespace Mock {
squashfs: {
aarch64: {
publishedAt: '2025-04-21T20:58:48.140749883Z',
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.8/startos-0.4.0-alpha.8-33ae46f~dev_aarch64.squashfs',
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.9/startos-0.4.0-alpha.9-33ae46f~dev_aarch64.squashfs',
commitment: {
hash: '4elBFVkd/r8hNadKmKtLIs42CoPltMvKe2z3LRqkphk=',
size: 1343500288,
@@ -122,7 +122,7 @@ export namespace Mock {
},
'aarch64-nonfree': {
publishedAt: '2025-04-21T21:07:00.249285116Z',
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.8/startos-0.4.0-alpha.8-33ae46f~dev_aarch64-nonfree.squashfs',
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.9/startos-0.4.0-alpha.9-33ae46f~dev_aarch64-nonfree.squashfs',
commitment: {
hash: 'MrCEi4jxbmPS7zAiGk/JSKlMsiuKqQy6RbYOxlGHOIQ=',
size: 1653075968,
@@ -134,7 +134,7 @@ export namespace Mock {
},
raspberrypi: {
publishedAt: '2025-04-21T21:16:12.933319237Z',
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.8/startos-0.4.0-alpha.8-33ae46f~dev_raspberrypi.squashfs',
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.9/startos-0.4.0-alpha.9-33ae46f~dev_raspberrypi.squashfs',
commitment: {
hash: '/XTVQRCqY3RK544PgitlKu7UplXjkmzWoXUh2E4HCw0=',
size: 1490731008,
@@ -146,7 +146,7 @@ export namespace Mock {
},
x86_64: {
publishedAt: '2025-04-21T21:14:20.246908903Z',
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.8/startos-0.4.0-alpha.8-33ae46f~dev_x86_64.squashfs',
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.9/startos-0.4.0-alpha.9-33ae46f~dev_x86_64.squashfs',
commitment: {
hash: '/6romKTVQGSaOU7FqSZdw0kFyd7P+NBSYNwM3q7Fe44=',
size: 1411657728,
@@ -158,7 +158,7 @@ export namespace Mock {
},
'x86_64-nonfree': {
publishedAt: '2025-04-21T21:15:17.955265284Z',
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.8/startos-0.4.0-alpha.8-33ae46f~dev_x86_64-nonfree.squashfs',
url: 'https://alpha-registry-x.start9.com/startos/v0.4.0-alpha.9/startos-0.4.0-alpha.9-33ae46f~dev_x86_64-nonfree.squashfs',
commitment: {
hash: 'HCRq9sr/0t85pMdrEgNBeM4x11zVKHszGnD1GDyZbSE=',
size: 1731035136,
@@ -217,6 +217,7 @@ export namespace Mock {
supportSite: 'https://bitcoin.org',
marketingSite: 'https://bitcoin.org',
donationUrl: 'https://start9.com',
docsUrl: 'https://docs.start9.com',
alerts: {
install: 'Bitcoin can take over a week to sync.',
uninstall:
@@ -262,6 +263,7 @@ export namespace Mock {
supportSite: 'https://lightning.engineering/',
marketingSite: 'https://lightning.engineering/',
donationUrl: null,
docsUrl: 'https://docs.start9.com',
alerts: {
install: null,
uninstall: null,
@@ -319,6 +321,7 @@ export namespace Mock {
supportSite: '',
marketingSite: '',
donationUrl: 'https://start9.com',
docsUrl: 'https://docs.start9.com',
alerts: {
install: 'Testing install alert',
uninstall: null,
@@ -379,8 +382,10 @@ export namespace Mock {
upstreamRepo: 'https://github.com/bitcoin/bitcoin',
supportSite: 'https://bitcoin.org',
marketingSite: 'https://bitcoin.org',
docsUrl: 'https://bitcoin.org',
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.36',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -412,8 +417,10 @@ export namespace Mock {
upstreamRepo: 'https://github.com/bitcoinknots/bitcoin',
supportSite: 'https://bitcoinknots.org',
marketingSite: 'https://bitcoinknots.org',
docsUrl: 'https://bitcoinknots.org',
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.36',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -455,8 +462,10 @@ export namespace Mock {
upstreamRepo: 'https://github.com/bitcoin/bitcoin',
supportSite: 'https://bitcoin.org',
marketingSite: 'https://bitcoin.org',
docsUrl: 'https://bitcoin.org',
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.36',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -488,8 +497,10 @@ export namespace Mock {
upstreamRepo: 'https://github.com/bitcoinknots/bitcoin',
supportSite: 'https://bitcoinknots.org',
marketingSite: 'https://bitcoinknots.org',
docsUrl: 'https://bitcoinknots.org',
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.36',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -533,8 +544,10 @@ export namespace Mock {
upstreamRepo: 'https://github.com/lightningnetwork/lnd',
supportSite: 'https://lightning.engineering/slack.html',
marketingSite: 'https://lightning.engineering/',
docsUrl: 'https://lightning.engineering/',
releaseNotes: 'Upstream release to 0.17.5',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.36',
gitHash: 'fakehash',
icon: LND_ICON,
sourceVersion: null,
@@ -589,8 +602,10 @@ export namespace Mock {
upstreamRepo: 'https://github.com/lightningnetwork/lnd',
supportSite: 'https://lightning.engineering/slack.html',
marketingSite: 'https://lightning.engineering/',
docsUrl: 'https://lightning.engineering/',
releaseNotes: 'Upstream release to 0.17.4',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.36',
gitHash: 'fakehash',
icon: LND_ICON,
sourceVersion: null,
@@ -649,8 +664,10 @@ export namespace Mock {
upstreamRepo: 'https://github.com/bitcoin/bitcoin',
supportSite: 'https://bitcoin.org',
marketingSite: 'https://bitcoin.org',
docsUrl: 'https://bitcoin.org',
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.36',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -682,8 +699,10 @@ export namespace Mock {
upstreamRepo: 'https://github.com/bitcoinknots/bitcoin',
supportSite: 'https://bitcoinknots.org',
marketingSite: 'https://bitcoinknots.org',
docsUrl: 'https://bitcoinknots.org',
releaseNotes: 'Even better support for Bitcoin and wallets!',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.36',
gitHash: 'fakehash',
icon: BTC_ICON,
sourceVersion: null,
@@ -725,8 +744,10 @@ export namespace Mock {
upstreamRepo: 'https://github.com/lightningnetwork/lnd',
supportSite: 'https://lightning.engineering/slack.html',
marketingSite: 'https://lightning.engineering/',
docsUrl: 'https://lightning.engineering/',
releaseNotes: 'Upstream release and minor fixes.',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.36',
gitHash: 'fakehash',
icon: LND_ICON,
sourceVersion: null,
@@ -780,9 +801,11 @@ export namespace Mock {
wrapperRepo: 'https://github.com/Start9Labs/btc-rpc-proxy-wrappers',
upstreamRepo: 'https://github.com/Kixunil/btc-rpc-proxy',
supportSite: 'https://github.com/Kixunil/btc-rpc-proxy/issues',
docsUrl: 'https://github.com/Kixunil/btc-rpc-proxy',
marketingSite: '',
releaseNotes: 'Upstream release and minor fixes.',
osVersion: '0.3.6',
sdkVersion: '0.4.0-beta.36',
gitHash: 'fakehash',
icon: PROXY_ICON,
sourceVersion: null,
@@ -1930,7 +1953,7 @@ export namespace Mock {
state: 'installed',
manifest: MockManifestBitcoind,
},
dataVersion: MockManifestBitcoind.version,
s9pk: '/media/startos/data/package-data/archive/installed/asdfasdf.s9pk',
icon: '/assets/img/service-icons/bitcoind.svg',
lastBackup: null,
status: {
@@ -2204,7 +2227,7 @@ export namespace Mock {
state: 'installed',
manifest: MockManifestBitcoinProxy,
},
dataVersion: MockManifestBitcoinProxy.version,
s9pk: '/media/startos/data/package-data/archive/installed/asdfasdf.s9pk',
icon: '/assets/img/service-icons/btc-rpc-proxy.png',
lastBackup: null,
status: {
@@ -2249,7 +2272,7 @@ export namespace Mock {
state: 'installed',
manifest: MockManifestLnd,
},
dataVersion: MockManifestLnd.version,
s9pk: '/media/startos/data/package-data/archive/installed/asdfasdf.s9pk',
icon: '/assets/img/service-icons/lnd.png',
lastBackup: null,
status: {
@@ -2272,7 +2295,7 @@ export namespace Mock {
visibility: 'enabled',
allowedStatuses: 'any',
hasInput: true,
group: null,
group: 'Connecting',
},
},
serviceInterfaces: {

View File

@@ -9,15 +9,15 @@ export abstract class ApiService {
// for sideloading packages
abstract uploadPackage(guid: string, body: Blob): Promise<void>
// for getting static files: ex icons, instructions, licenses
// for getting static files: ex license
abstract getStaticProxy(
pkg: MarketplacePkg,
path: 'LICENSE.md' | 'instructions.md',
path: 'LICENSE.md',
): Promise<string>
abstract getStaticInstalled(
id: T.PackageId,
path: 'LICENSE.md' | 'instructions.md',
path: 'LICENSE.md',
): Promise<string>
// websocket

View File

@@ -1,4 +1,6 @@
import { Inject, Injectable } from '@angular/core'
import { DOCUMENT, Inject, Injectable } from '@angular/core'
import { blake3 } from '@noble/hashes/blake3'
import { MarketplacePkg } from '@start9labs/marketplace'
import {
HttpOptions,
HttpService,
@@ -7,18 +9,15 @@ import {
RpcError,
RPCOptions,
} from '@start9labs/shared'
import { PATCH_CACHE } from 'src/app/services/patch-db/patch-db-source'
import { ApiService } from './embassy-api.service'
import { RR } from './api.types'
import { webSocket, WebSocketSubject } from 'rxjs/webSocket'
import { Observable, filter, firstValueFrom } from 'rxjs'
import { AuthService } from '../auth.service'
import { DOCUMENT } from '@angular/common'
import { DataModel } from '../patch-db/data-model'
import { Dump, pathFromArray } from 'patch-db-client'
import { T } from '@start9labs/start-sdk'
import { MarketplacePkg } from '@start9labs/marketplace'
import { blake3 } from '@noble/hashes/blake3'
import { Dump, pathFromArray } from 'patch-db-client'
import { filter, firstValueFrom, Observable } from 'rxjs'
import { webSocket, WebSocketSubject } from 'rxjs/webSocket'
import { PATCH_CACHE } from 'src/app/services/patch-db/patch-db-source'
import { AuthService } from '../auth.service'
import { DataModel } from '../patch-db/data-model'
import { RR } from './api.types'
import { ApiService } from './embassy-api.service'
@Injectable()
export class LiveApiService extends ApiService {
@@ -45,11 +44,11 @@ export class LiveApiService extends ApiService {
})
}
// for getting static files: ex. instructions, licenses
// for getting static files: ex: license
async getStaticProxy(
pkg: MarketplacePkg,
path: 'LICENSE.md' | 'instructions.md',
path: 'LICENSE.md',
): Promise<string> {
const encodedUrl = encodeURIComponent(pkg.s9pk.url)
@@ -66,7 +65,7 @@ export class LiveApiService extends ApiService {
async getStaticInstalled(
id: T.PackageId,
path: 'LICENSE.md' | 'instructions.md',
path: 'LICENSE.md',
): Promise<string> {
return this.httpRequest({
method: Method.GET,

View File

@@ -77,7 +77,7 @@ export class MockApiService extends ApiService {
async getStaticProxy(
pkg: MarketplacePkg,
path: 'LICENSE.md' | 'instructions.md',
path: 'LICENSE.md',
): Promise<string> {
await pauseFor(2000)
return markdown
@@ -85,7 +85,7 @@ export class MockApiService extends ApiService {
async getStaticInstalled(
id: T.PackageId,
path: 'LICENSE.md' | 'instructions.md',
path: 'LICENSE.md',
): Promise<string> {
await pauseFor(2000)
return markdown

View File

@@ -12,7 +12,6 @@ export const mockPatchData: DataModel = {
},
startosRegistry: 'https://registry.start9.com/',
snakeHighScore: 0,
ackInstructions: {},
language: 'english',
},
serverInfo: {
@@ -170,7 +169,7 @@ export const mockPatchData: DataModel = {
passwordHash:
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
packageVersionCompat: '>=0.3.0 <=0.3.6',
postInitMigrationTodos: [],
postInitMigrationTodos: {},
statusInfo: {
// currentBackup: null,
updated: false,
@@ -200,7 +199,7 @@ export const mockPatchData: DataModel = {
version: '0.20.0:0-alpha.1',
},
},
dataVersion: '0.20.0:0',
s9pk: '/media/startos/data/package-data/archive/installed/asdfasdf.s9pk',
icon: '/assets/img/service-icons/bitcoind.svg',
lastBackup: new Date(new Date().valueOf() - 604800001).toISOString(),
status: {
@@ -480,7 +479,7 @@ export const mockPatchData: DataModel = {
version: '0.11.0:0.0.1',
},
},
dataVersion: '0.11.0:0.0.1',
s9pk: '/media/startos/data/package-data/archive/installed/asdfasdf.s9pk',
icon: '/assets/img/service-icons/lnd.png',
lastBackup: null,
status: {
@@ -503,7 +502,7 @@ export const mockPatchData: DataModel = {
visibility: 'enabled',
allowedStatuses: 'any',
hasInput: true,
group: null,
group: 'Connecting',
},
},
serviceInterfaces: {

View File

@@ -77,13 +77,13 @@ export class BadgeService {
getCount(id: string): Observable<number> {
switch (id) {
case '/portal/updates':
case 'updates':
return this.updates$
case '/portal/system':
case 'system':
return this.system$
case '/portal/metrics':
case 'metrics':
return this.metrics$
case '/portal/notifications':
case 'notifications':
return this.notifications.unreadCount$
default:
return EMPTY

View File

@@ -1,5 +1,4 @@
import { DOCUMENT } from '@angular/common'
import { Inject, Injectable } from '@angular/core'
import { Inject, Injectable, DOCUMENT } from '@angular/core'
import { WorkspaceConfig } from '@start9labs/shared'
import { T, utils } from '@start9labs/start-sdk'
import { PackageDataEntry } from './patch-db/data-model'

View File

@@ -115,6 +115,7 @@ export class MarketplaceService {
flavor: string | null,
registryUrl?: string,
): Observable<MarketplacePkg> {
console.log('HERE')
return this.currentRegistry$.pipe(
switchMap(registry => {
const url = registryUrl || registry.url
@@ -141,11 +142,8 @@ export class MarketplaceService {
)
}
fetchStatic$(
pkg: MarketplacePkg,
type: 'LICENSE.md' | 'instructions.md',
): Observable<string> {
return from(this.api.getStaticProxy(pkg, type))
fetchStatic$(pkg: MarketplacePkg): Observable<string> {
return from(this.api.getStaticProxy(pkg, 'LICENSE.md'))
}
private fetchRegistry$(url: string): Observable<StoreDataWithUrl | null> {

View File

@@ -6,7 +6,6 @@ export type DataModel = T.Public & { ui: UIData; packageData: AllPackageData }
export type UIData = {
name: string | null
registries: Record<string, string | null>
ackInstructions: Record<string, boolean>
snakeHighScore: number
startosRegistry: string
language: Languages

View File

@@ -33,7 +33,7 @@ export class StandardActionsService {
try {
await this.api.rebuildPackage({ id })
await this.router.navigate(['portal', 'services', id])
await this.router.navigate(['services', id])
} catch (e: any) {
this.errorService.handleError(e)
} finally {
@@ -80,8 +80,7 @@ export class StandardActionsService {
try {
await this.api.uninstallPackage(options)
await this.api.setDbValue<boolean>(['ackInstructions', options.id], false)
await this.router.navigate(['portal'])
await this.router.navigate([''])
} catch (e: any) {
this.errorService.handleError(e)
} finally {

View File

@@ -1,19 +1,18 @@
import { inject, Injectable } from '@angular/core'
import { Component, inject, Injectable } from '@angular/core'
import { CanActivateFn, IsActiveMatchOptions, Router } from '@angular/router'
import { i18nPipe } from '@start9labs/shared'
import { TUI_TRUE_HANDLER } from '@taiga-ui/cdk'
import { TuiAlertService } from '@taiga-ui/core'
import { TuiAlertService, TuiLoader, TuiTitle } from '@taiga-ui/core'
import { TuiCell } from '@taiga-ui/layout'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import {
BehaviorSubject,
combineLatest,
concat,
EMPTY,
exhaustMap,
from,
merge,
Observable,
startWith,
Subject,
timer,
} from 'rxjs'
import {
@@ -38,6 +37,22 @@ const OPTIONS: IsActiveMatchOptions = {
matrixParams: 'ignored',
}
@Component({
template: `
<tui-loader size="m" [inheritColor]="true" />
<div tuiTitle>
{{ 'State unknown' | i18n }}
<span tuiSubtitle>
{{ 'Trying to reach server' | i18n }}
</span>
</div>
`,
host: { style: 'padding: 0 0.25rem' },
imports: [i18nPipe, TuiLoader, TuiTitle],
hostDirectives: [TuiCell],
})
class DisconnectedToast {}
@Injectable({
providedIn: 'root',
})
@@ -47,83 +62,75 @@ export class StateService extends Observable<RR.ServerState | null> {
private readonly api = inject(ApiService)
private readonly router = inject(Router)
private readonly network$ = inject(NetworkService)
private readonly single$ = new Subject<RR.ServerState>()
private readonly trigger$ = new BehaviorSubject<void>(undefined)
private readonly poll$ = this.trigger$.pipe(
private readonly trigger$ = new BehaviorSubject(true)
private readonly disconnected$ = this.alerts.open(
new PolymorpheusComponent(DisconnectedToast),
{ closeable: false, appearance: 'negative', icon: '' },
)
private readonly reconnected$ = this.alerts.open(
this.i18n.transform('Connection restored'),
{ label: this.i18n.transform('Server connected'), appearance: 'positive' },
)
private readonly stream$ = this.trigger$.pipe(
switchMap(() => this.network$.pipe(filter(Boolean))),
switchMap(() =>
timer(0, 2000).pipe(
switchMap(() =>
exhaustMap(() =>
from(this.api.getState()).pipe(catchError(() => EMPTY)),
),
take(1),
),
),
)
private readonly stream$ = merge(this.single$, this.poll$).pipe(
tap(state => {
switch (state) {
case 'initializing':
this.router.navigate(['initializing'], { replaceUrl: true })
break
case 'error':
this.router.navigate(['diagnostic'], { replaceUrl: true })
break
case 'running':
if (
this.router.isActive('initializing', OPTIONS) ||
this.router.isActive('diagnostic', OPTIONS)
) {
this.router.navigate([''], { replaceUrl: true })
}
break
}
}),
tap(state => this.handleState(state)),
startWith(null),
shareReplay(1),
)
private readonly alert = merge(
this.trigger$.pipe(skip(1)),
this.network$.pipe(filter(v => !v)),
)
.pipe(
exhaustMap(() =>
concat(
this.alerts
.open(this.i18n.transform('Trying to reach server'), {
label: this.i18n.transform('State unknown'),
closeable: false,
appearance: 'negative',
})
.pipe(
takeUntil(
combineLatest([this.stream$.pipe(skip(1)), this.network$]).pipe(
filter(state => state.every(Boolean)),
),
),
),
this.alerts.open(this.i18n.transform('Connection restored'), {
label: this.i18n.transform('Server connected'),
appearance: 'positive',
}),
),
),
)
.subscribe()
constructor() {
super(subscriber => this.stream$.subscribe(subscriber))
// Retrigger on offline
this.network$.pipe(filter(v => !v)).subscribe(() => this.retrigger())
// Show toasts
this.trigger$
.pipe(
filter(v => !v),
exhaustMap(() =>
concat(
this.disconnected$.pipe(takeUntil(this.stream$.pipe(skip(1)))),
this.reconnected$,
),
),
)
.subscribe()
}
retrigger() {
this.trigger$.next()
retrigger(gracefully = false) {
this.trigger$.next(gracefully)
}
async syncState() {
const state = await this.api.getState()
this.single$.next(state)
private handleState(state: RR.ServerState): void {
switch (state) {
case 'initializing':
this.router.navigate(['initializing'], { replaceUrl: true })
break
case 'error':
this.router.navigate(['diagnostic'], { replaceUrl: true })
break
case 'running':
if (
this.router.isActive('initializing', OPTIONS) ||
this.router.isActive('diagnostic', OPTIONS)
) {
this.router.navigate([''], { replaceUrl: true })
}
break
}
}
}

View File

@@ -1,5 +1,4 @@
import { Inject, Injectable } from '@angular/core'
import { DOCUMENT } from '@angular/common'
import { Inject, Injectable, DOCUMENT } from '@angular/core'
const PREFIX = '_startos/'

View File

@@ -7,40 +7,40 @@ export const SYSTEM_UTILITIES: Record<
string,
{ icon: string; title: i18nKey }
> = {
'/portal/services': {
services: {
icon: '@tui.layout-grid',
title: 'Services',
},
'/portal/marketplace': {
marketplace: {
icon: '@tui.shopping-cart',
title: 'Marketplace',
},
'/portal/sideload': {
sideload: {
icon: '@tui.upload',
title: 'Sideload',
},
'/portal/updates': {
updates: {
icon: '@tui.globe',
title: 'Updates',
},
// @TODO 041
// '/portal/backups': {
// backups: {
// icon: '@tui.save',
// title: 'Backups',
// },
'/portal/metrics': {
metrics: {
icon: '@tui.activity',
title: 'Metrics',
},
'/portal/logs': {
logs: {
icon: '@tui.file-text',
title: 'Logs',
},
'/portal/system': {
system: {
icon: '@tui.settings',
title: 'System',
},
'/portal/notifications': {
notifications: {
icon: '@tui.bell',
title: 'Notifications',
},

View File

@@ -1,3 +1,3 @@
export function toRouterLink(id: string): string {
return id.includes('/') ? id : `/portal/services/${id}`
return id.includes('/') ? id : `/services/${id}`
}

View File

@@ -338,9 +338,6 @@ hr {
.g-stretch,
.g-table {
// @TODO drop after fixed and merged: https://github.com/evanw/esbuild/issues/4184
width: -webkit-fill-available;
width: -moz-available;
width: stretch;
}