mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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...' } },
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -10,7 +10,9 @@
|
||||
(focusedChange)="onFocus($event)"
|
||||
>
|
||||
{{ spec.name }}
|
||||
<span *ngIf="spec.required">*</span>
|
||||
@if (spec.required) {
|
||||
<span>*</span>
|
||||
}
|
||||
<textarea
|
||||
tuiTextfieldLegacy
|
||||
[placeholder]="spec.placeholder || ''"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]',
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -68,7 +68,7 @@ export class BackupsRestoreService {
|
||||
),
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.router.navigate(['/portal/services'])
|
||||
this.router.navigate(['services'])
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)"
|
||||
>
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -162,7 +162,7 @@ export class ServiceComponent implements OnChanges {
|
||||
}
|
||||
|
||||
get routerLink() {
|
||||
return `/portal/services/${this.manifest.id}`
|
||||
return `/services/${this.manifest.id}`
|
||||
}
|
||||
|
||||
ngOnChanges() {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -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.',
|
||||
)!,
|
||||
}
|
||||
|
||||
|
||||
@@ -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'])
|
||||
}
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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()),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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: `
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: `
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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/'
|
||||
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
export function toRouterLink(id: string): string {
|
||||
return id.includes('/') ? id : `/portal/services/${id}`
|
||||
return id.includes('/') ? id : `/services/${id}`
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user