Fix/fe bugs 3 (#2943)

* fix typeo in patch db seed

* show all registries in updates tab, fix required dependnecy display in marketplace, update browser tab title desc

* always show pointer for version select

* chore: fix comments

* support html in action desc and marketplace long desc, only show qr in action res if qr is true

* disable save if smtp creds not edited, show better smtp success message

* dont dismiss login spinner until patchDB returns

* feat: redesign of service dashboard and interface (#2946)

* feat: redesign of service dashboard and interface

* chore: comments

* re-add setup complete

* dibale launch UI when not running, re-style things, rename things

* back to 1000

* fix clearnet docs link and require password retype in setup wiz

* faster hint display

* display dependency ID if title not available

* fix migration

* better init progress view

* fix setup success page by providing VERSION and notifications page fixes

* force uninstall from service error page, soft or hard

* handle error state better

* chore: fixed for install and setup wizards

* chore: fix issues (#2949)

* enable and disable kiosk mode

* minor fixes

* fix dependency mounts

* dismissable tasks

* provide replayId

* default if health check success message is null

* look for wifi interface too

* dash for null user agent in sessions

* add disk repair to diagnostic api

---------

Co-authored-by: waterplea <alexander@inkin.ru>
Co-authored-by: Aiden McClelland <me@drbonez.dev>
This commit is contained in:
Matt Hill
2025-05-21 19:04:26 -06:00
committed by GitHub
parent 44560c8da8
commit b40849f672
123 changed files with 1662 additions and 964 deletions

View File

@@ -4,7 +4,7 @@
"https://registry.start9.com/": "Start9 Registry",
"https://community-registry.start9.com/": "Community Registry"
},
"startosRegisrty": "https://registry.start9.com/",
"startosRegistry": "https://registry.start9.com/",
"snakeHighScore": 0,
"ackInstructions": {}
}

View File

@@ -61,3 +61,8 @@ main {
margin-left: -100%;
}
}
[tuiCell]:not(:last-of-type) {
box-shadow: 0 calc(0.5rem + 1px) 0 -0.5rem var(--tui-border-normal);
}

View File

@@ -62,7 +62,12 @@ export class AppComponent {
this.dialogs
.open(
'Please wait for StartOS to restart, then refresh this page',
{ label: 'Rebooting', size: 's' },
{
label: 'Rebooting',
size: 's',
closeable: false,
dismissible: false,
},
)
.subscribe()
} catch (e: any) {

View File

@@ -12,7 +12,7 @@
Past Release Notes
</button>
<h2 class="additional-detail-title" [style.margin-top.rem]="2">About</h2>
<p>{{ pkg.description.long }}</p>
<p [innerHTML]="pkg.description.long"></p>
<a
*ngIf="pkg.marketingSite as url"
tuiButton

View File

@@ -20,8 +20,8 @@ import { MarketplacePkgBase } from '../../../types'
</span>
<p>
<ng-container [ngSwitch]="dep.value.optional">
<span *ngSwitchCase="true">(required)</span>
<span *ngSwitchCase="false">(optional)</span>
<span *ngSwitchCase="true">(optional)</span>
<span *ngSwitchCase="false">(required)</span>
</ng-container>
</p>
</div>

View File

@@ -2,6 +2,8 @@ import { Component, inject } from '@angular/core'
import { Router } from '@angular/router'
import { ErrorService } from '@start9labs/shared'
import { ApiService } from 'src/app/services/api.service'
import { StateService } from './services/state.service'
import { DOCUMENT } from '@angular/common'
@Component({
selector: 'app-root',
@@ -11,9 +13,15 @@ export class AppComponent {
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
private readonly router = inject(Router)
private readonly stateService = inject(StateService)
private readonly document = inject(DOCUMENT)
async ngOnInit() {
try {
this.stateService.kiosk = ['localhost', '127.0.0.1'].includes(
this.document.location.hostname,
)
const inProgress = await this.api.getStatus()
let route = 'home'

View File

@@ -5,6 +5,7 @@ import { PreloadAllModules, RouterModule } from '@angular/router'
import {
provideSetupLogsService,
RELATIVE_URL,
VERSION,
WorkspaceConfig,
} from '@start9labs/shared'
import { tuiButtonOptionsProvider, TuiRoot } from '@taiga-ui/core'
@@ -20,6 +21,8 @@ const {
ui: { api },
} = require('../../../../config.json') as WorkspaceConfig
const version = require('../../../../package.json').version
@NgModule({
declarations: [AppComponent],
imports: [
@@ -43,6 +46,10 @@ const {
provide: RELATIVE_URL,
useValue: `/${api.url}/${api.version}`,
},
{
provide: VERSION,
useValue: version,
},
],
bootstrap: [AppComponent],
})

View File

@@ -8,7 +8,7 @@ const FADE_FACTOR = 0.07
standalone: true,
selector: 'canvas[matrix]',
template: 'Your browser does not support the canvas element.',
styles: ':host { position: fixed; }',
styles: ':host { position: fixed; top: 0 }',
})
export class MatrixComponent implements OnInit {
private readonly ngZone = inject(NgZone)

View File

@@ -1,9 +1,27 @@
import { AsyncPipe } from '@angular/common'
import { Component, inject } from '@angular/core'
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
AbstractControl,
FormControl,
FormGroup,
ReactiveFormsModule,
Validators,
} from '@angular/forms'
import * as argon2 from '@start9labs/argon2'
import { ErrorService } from '@start9labs/shared'
import { TuiButton, TuiDialogContext, TuiError } from '@taiga-ui/core'
import { TuiInputPasswordModule } from '@taiga-ui/legacy'
import { TuiAutoFocus, TuiMapperPipe, TuiValidator } from '@taiga-ui/cdk'
import {
TuiButton,
TuiDialogContext,
TuiError,
TuiIcon,
TuiTextfield,
} from '@taiga-ui/core'
import {
TuiFieldErrorPipe,
TuiPassword,
tuiValidationErrorsProvider,
} from '@taiga-ui/kit'
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
interface DialogData {
@@ -21,18 +39,38 @@ interface DialogData {
Enter the password that was used to encrypt this drive.
}
<form [style.margin-top.rem]="1" (ngSubmit)="submit()">
<tui-input-password [formControl]="password">
Enter Password
<input tuiTextfieldLegacy maxlength="64" />
</tui-input-password>
<tui-error [error]="passwordError"></tui-error>
<form [formGroup]="form" [style.margin-top.rem]="1" (ngSubmit)="submit()">
<tui-textfield>
<label tuiLabel>Enter Password</label>
<input
tuiTextfield
type="password"
tuiAutoFocus
maxlength="64"
formControlName="password"
/>
<tui-icon tuiPassword />
</tui-textfield>
<tui-error
formControlName="password"
[error]="[] | tuiFieldError | async"
/>
@if (storageDrive) {
<tui-input-password [style.margin-top.rem]="1" [formControl]="confirm">
Retype Password
<input tuiTextfieldLegacy maxlength="64" />
</tui-input-password>
<tui-error [error]="confirmError"></tui-error>
<tui-textfield [style.margin-top.rem]="1">
<label tuiLabel>Retype Password</label>
<input
tuiTextfield
type="password"
maxlength="64"
formControlName="confirm"
[tuiValidator]="form.controls.password.value | tuiMapper: validator"
/>
<tui-icon tuiPassword />
</tui-textfield>
<tui-error
formControlName="confirm"
[error]="[] | tuiFieldError | async"
/>
}
<footer>
<button
@@ -43,22 +81,38 @@ interface DialogData {
>
Cancel
</button>
<button
tuiButton
[disabled]="!password.value || !!confirmError || !!passwordError"
>
<button tuiButton [disabled]="form.invalid">
{{ storageDrive ? 'Finish' : 'Unlock' }}
</button>
</footer>
</form>
`,
styles: ['footer { display: flex; gap: 1rem; margin-top: 1rem }'],
styles: `
footer {
display: flex;
gap: 1rem;
margin-top: 1rem;
justify-content: flex-end;
}
`,
imports: [
FormsModule,
AsyncPipe,
ReactiveFormsModule,
TuiButton,
TuiInputPasswordModule,
TuiError,
TuiAutoFocus,
TuiFieldErrorPipe,
TuiTextfield,
TuiPassword,
TuiValidator,
TuiIcon,
TuiMapperPipe,
],
providers: [
tuiValidationErrorsProvider({
required: 'Required',
minlength: 'Must be 12 characters or greater',
}),
],
})
export class PasswordComponent {
@@ -67,31 +121,29 @@ export class PasswordComponent {
injectContext<TuiDialogContext<string, DialogData>>()
readonly storageDrive = this.context.data.storageDrive
readonly password = new FormControl('', { nonNullable: true })
readonly confirm = new FormControl('', { nonNullable: true })
readonly form = new FormGroup({
password: new FormControl('', [
Validators.required,
Validators.minLength(12),
]),
confirm: new FormControl('', this.storageDrive ? Validators.required : []),
})
get passwordError(): string | null {
return this.password.touched && this.password.value.length < 12
? 'Must be 12 characters or greater'
: null
}
get confirmError(): string | null {
return this.confirm.touched && this.password.value !== this.confirm.value
? 'Passwords do not match'
: null
}
readonly validator = (value: any) => (control: AbstractControl) =>
value === control.value ? null : { match: 'Passwords do not match' }
submit() {
const password = this.form.controls.password.value || ''
if (this.storageDrive) {
this.context.completeWith(this.password.value)
this.context.completeWith(password)
return
}
try {
argon2.verify(this.context.data.passwordHash || '', this.password.value)
this.context.completeWith(this.password.value)
argon2.verify(this.context.data.passwordHash || '', password)
this.context.completeWith(password)
} catch (e) {
this.errorService.handleError('Incorrect password provided')
}

View File

@@ -17,7 +17,7 @@ import { StateService } from 'src/app/services/state.service'
@Component({
standalone: true,
template: `
<section tuiCardLarge>
<section tuiCardLarge="compact">
<header>Use existing drive</header>
<div>Select the physical drive containing your StartOS data</div>
@@ -31,9 +31,11 @@ import { StateService } from 'src/app/services/state.service'
valid StartOS data drive (not a backup) and is firmly connected, then
refresh the page.
}
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
Refresh
</button>
<footer>
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
Refresh
</button>
</footer>
}
</section>
`,

View File

@@ -13,7 +13,7 @@ import { StateService } from 'src/app/services/state.service'
template: `
<img class="logo" src="assets/img/icon.png" alt="Start9" />
@if (!loading) {
<section tuiCardLarge>
<section tuiCardLarge="compact">
<header [style.padding-top.rem]="1.25">
@if (recover) {
<button
@@ -30,7 +30,7 @@ import { StateService } from 'src/app/services/state.service'
</header>
<div class="pages">
<div class="options" [class.options_recover]="recover">
<a tuiCell [routerLink]="error || recover ? null : '/storage'">
<button tuiCell [routerLink]="error || recover ? null : '/storage'">
<tui-icon icon="@tui.plus" />
<span tuiTitle>
<span class="g-positive">Start Fresh</span>
@@ -38,7 +38,7 @@ import { StateService } from 'src/app/services/state.service'
Get started with a brand new Start9 server
</span>
</span>
</a>
</button>
<button
tuiCell
[disabled]="error || recover"

View File

@@ -17,7 +17,7 @@ import { StateService } from 'src/app/services/state.service'
@Component({
standalone: true,
template: `
<section tuiCardLarge>
<section tuiCardLarge="compact">
<header>Restore from Backup</header>
@if (loading) {
<tui-loader />
@@ -26,7 +26,7 @@ import { StateService } from 'src/app/services/state.service'
Restore StartOS data from a folder on another computer that is connected
to the same network as your server.
<button tuiCell (click)="onCifs()">
<button tuiCell [style.box-shadow]="'none'" (click)="onCifs()">
<tui-icon icon="@tui.folder" />
<span tuiTitle>Open</span>
</button>
@@ -49,10 +49,11 @@ import { StateService } from 'src/app/services/state.service'
(password)="select($event, server)"
></button>
}
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
Refresh
</button>
<footer>
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
Refresh
</button>
</footer>
}
</section>
`,

View File

@@ -19,7 +19,7 @@ import { StateService } from 'src/app/services/state.service'
@Component({
standalone: true,
template: `
<section tuiCardLarge>
<section tuiCardLarge="compact">
@if (loading || drives.length) {
<header>Select storage drive</header>
This is the drive where your StartOS data will be stored.
@@ -39,10 +39,11 @@ import { StateService } from 'src/app/services/state.service'
}
</button>
}
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
Refresh
</button>
<footer>
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
Refresh
</button>
</footer>
</section>
`,
imports: [TuiCardLarge, TuiLoader, TuiCell, TuiButton, DriveComponent],

View File

@@ -18,15 +18,13 @@ import { StateService } from 'src/app/services/state.service'
standalone: true,
template: `
<canvas matrix></canvas>
@if (isKiosk) {
@if (stateService.kiosk) {
<section tuiCardLarge>
<h1 class="heading">
<tui-icon icon="@tui.check-square" class="g-positive" />
Setup Complete!
</h1>
<button tuiButton (click)="exitKiosk()" iconEnd="@tui.log-in">
Continue to Login
</button>
<button tuiButton (click)="exitKiosk()">Continue to Login</button>
</section>
} @else if (lanAddress) {
<section tuiCardLarge>
@@ -111,16 +109,12 @@ import { StateService } from 'src/app/services/state.service'
export default class SuccessPage implements AfterViewInit {
@ViewChild(DocumentationComponent, { read: ElementRef })
private readonly documentation?: ElementRef<HTMLElement>
private readonly document = inject(DOCUMENT)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
private readonly downloadHtml = inject(DownloadHTMLService)
readonly stateService = inject(StateService)
readonly isKiosk = ['localhost', '127.0.0.1'].includes(
this.document.location.hostname,
)
torAddresses?: string[]
lanAddress?: string
@@ -157,7 +151,7 @@ export default class SuccessPage implements AfterViewInit {
private async complete() {
try {
const ret = await this.api.complete()
if (!this.isKiosk) {
if (!this.stateService.kiosk) {
this.torAddresses = ret.torAddresses.map(a =>
a.replace(/^https:/, 'http:'),
)

View File

@@ -21,7 +21,7 @@ import { StateService } from 'src/app/services/state.service'
@Component({
standalone: true,
template: `
<section tuiCardLarge>
<section tuiCardLarge="compact">
<header>Transfer</header>
Select the physical drive containing your StartOS data
@if (loading) {
@@ -30,9 +30,11 @@ import { StateService } from 'src/app/services/state.service'
@for (drive of drives; track drive) {
<button tuiCell [drive]="drive" (click)="select(drive)"></button>
}
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
Refresh
</button>
<footer>
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
Refresh
</button>
</footer>
</section>
`,
imports: [TuiCardLarge, TuiCell, TuiButton, TuiLoader, DriveComponent],

View File

@@ -8,6 +8,7 @@ import { T } from '@start9labs/start-sdk'
export class StateService {
private readonly api = inject(ApiService)
kiosk?: boolean
setupType?: 'fresh' | 'restore' | 'attach' | 'transfer'
recoverySource?: T.RecoverySource<string>
@@ -15,6 +16,7 @@ export class StateService {
await this.api.attach({
guid,
startOsPassword: await this.api.encrypt(password),
kiosk: this.kiosk,
})
}
@@ -33,6 +35,7 @@ export class StateService {
password: await this.api.encrypt(this.recoverySource.password),
}
: null,
kiosk: this.kiosk,
})
}
}

View File

@@ -24,7 +24,7 @@ router-outlet + * {
[tuiCardLarge] {
width: 100%;
background: var(--tui-background-base-alt);
background: var(--tui-background-elevation-2);
margin: auto;
}
}
@@ -67,3 +67,11 @@ h2 {
.g-info {
color: var(--tui-status-info);
}
[tuiCardLarge] footer button {
width: 100%;
}
[tuiCell]:not(:last-of-type) {
box-shadow: 0 calc(0.5rem + 1px) 0 -0.5rem var(--tui-border-normal);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 694 KiB

View File

@@ -12,7 +12,7 @@ import { i18nPipe } from '../i18n/i18n.pipe'
<h1 [style.font-size.rem]="2" [style.margin-bottom.rem]="2">
{{ 'Setting up your server' | i18n }}
</h1>
<div *ngIf="progress.total">
<div>
{{ 'Progress' | i18n }}: {{ (progress.total * 100).toFixed(0) }}%
</div>
<progress
@@ -21,7 +21,7 @@ import { i18nPipe } from '../i18n/i18n.pipe'
[style.margin]="'1rem auto'"
[attr.value]="progress.total"
></progress>
<p [innerHTML]="progress.message"></p>
<p [innerHTML]="progress.message || 'Finished'"></p>
</section>
<logs-window />
`,

View File

@@ -21,7 +21,6 @@ import {
&:hover {
text-indent: var(--indent, 0);
text-overflow: clip;
cursor: default;
}
}
`,

View File

@@ -6,7 +6,6 @@ import {
input,
} from '@angular/core'
const HOST = 'https://staging.docs.start9.com'
export const VERSION = new InjectionToken<string>('VERSION')
@Directive({
@@ -26,6 +25,6 @@ export class DocsLinkDirective {
protected readonly url = computed(() => {
const path = this.href()
const relative = path.startsWith('/') ? path : `/${path}`
return `${HOST}${relative}?os=${this.version}`
return `https://docs.start9.com${relative}?os=${this.version}`
})
}

View File

@@ -60,7 +60,7 @@ export default {
57: 'Herunterfahren wird eingeleitet',
58: 'Hinzufügen',
59: 'Ok',
60: 'Möchten Sie diesen Eintrag wirklich löschen?',
60: 'französisch',
61: 'Dieser Wert kann nach dem Festlegen nicht geändert werden',
62: 'Fortfahren',
63: 'Klicken oder Datei hierher ziehen',
@@ -85,7 +85,7 @@ export default {
82: 'Metriken',
83: 'Protokolle',
84: 'Benachrichtigungen',
85: 'UI starten',
85: 'Hartes Deinstallieren',
86: 'QR-Code anzeigen',
87: 'URL kopieren',
88: 'Aktionen',
@@ -230,9 +230,9 @@ export default {
227: 'Unbekannter Fehler',
228: 'Fehler',
229: '"Container neu bauen" ist eine harmlose Aktion, die nur wenige Sekunden dauert. Sie wird dieses Problem wahrscheinlich beheben.',
230: '"Dienst deinstallieren" ist eine gefährliche Aktion, die den Dienst aus StartOS entfernt und alle zugehörigen Daten dauerhaft löscht.',
230: '"Hartes Deinstallieren" ist eine gefährliche Aktion, die den Dienst aus StartOS entfernt und alle zugehörigen Daten dauerhaft löscht.',
231: 'Container neu bauen',
232: 'Dienst deinstallieren',
232: 'Weiches Deinstallieren',
233: 'Vollständige Nachricht anzeigen',
234: 'Dienstfehler',
235: 'Warte auf Ergebnis',
@@ -247,7 +247,6 @@ export default {
244: 'Hosting',
245: 'Installation läuft',
246: 'Siehe unten',
247: 'Steuerelemente',
248: 'Keine Dienste installiert',
249: 'Läuft',
250: 'Gestoppt',
@@ -414,12 +413,12 @@ export default {
411: 'Weitere Netzwerke',
412: 'WiFi ist deaktiviert',
413: 'Keine drahtlose Schnittstelle erkannt',
414: 'WiFi wird aktiviert',
415: 'WiFi wird deaktiviert',
414: 'wird aktiviert',
415: 'wird deaktiviert',
416: 'Verbindung wird hergestellt. Dies kann einen Moment dauern',
417: 'Erneut versuchen',
418: 'Mehr anzeigen',
419: 'Versionshinweise',
419: 'Details anzeigen',
420: 'Eintrag anzeigen',
421: 'Dienste, die von folgendem abhängen:',
422: 'werden nicht mehr ordnungsgemäß funktionieren und könnten abstürzen.',
@@ -503,5 +502,20 @@ export default {
500: 'Marktplatz anzeigen',
501: 'Willkommen bei',
502: 'souveränes computing',
503: 'französisch',
503: 'Passen Sie den Namen an, der in Ihrem Browser-Tab erscheint',
504: 'Verwalten',
505: 'Möchten Sie diese Adresse wirklich löschen?',
506: '"Weiches Deinstallieren" entfernt den Dienst aus StartOS, behält jedoch die Daten bei.',
507: 'Keine gespeicherten Anbieter',
508: 'Kiosk-Modus',
509: 'Aktiviert',
510: 'Deaktiviere den Kiosk-Modus, es sei denn, du musst einen Monitor anschließen',
511: 'Aktiviere den Kiosk-Modus, wenn du einen Monitor anschließen musst',
512: 'Der Kiosk-Modus ist auf diesem Gerät nicht verfügbar',
513: 'Aktivieren',
514: 'Deaktivieren',
515: 'Du verwendest derzeit einen Kiosk. Wenn du den Kiosk-Modus deaktivierst, wird die Verbindung zum Kiosk getrennt.',
516: 'Empfohlen',
517: 'Möchten Sie diese Aufgabe wirklich verwerfen?',
518: 'Verwerfen',
} satisfies i18n

View File

@@ -44,7 +44,7 @@ export const ENGLISH = {
'Beginning restart': 42,
'You are on the latest version of StartOS.': 43,
'Up to date!': 44,
'Release Notes': 45,
'Release notes': 45,
'Begin Update': 46,
'Beginning update': 47,
'You are currently connected over Tor. If you reset the Tor daemon, you will lose connectivity until it comes back online.': 48,
@@ -59,7 +59,7 @@ export const ENGLISH = {
'Beginning shutdown': 57,
'Add': 58,
'Ok': 59,
'Are you sure you want to delete this entry?': 60,
'french': 60,
'This value cannot be changed once set': 61,
'Continue': 62,
'Click or drop file here': 63,
@@ -84,7 +84,7 @@ export const ENGLISH = {
'Metrics': 82, // system info such as CPU, RAM, and storage usage
'Logs': 83, // as in, application logs
'Notifications': 84,
'Launch UI': 85,
'Hard uninstall': 85, // as in, hard reset or hard reboot, except for uninstalling
'Show QR': 86,
'Copy URL': 87,
'Actions': 88, // as in, actions available to the user
@@ -229,9 +229,9 @@ export const ENGLISH = {
'Unknown error': 227,
'Error': 228,
'"Rebuild container" is a harmless action that and only takes a few seconds to complete. It will likely resolve this issue.': 229,
'"Uninstall service" is a dangerous action that will remove the service from StartOS and wipe all its data.': 230,
'"Hard uninstall" is a dangerous action that will remove the service from StartOS and wipe all its data.': 230,
'Rebuild container': 231,
'Uninstall service': 232,
'Soft uninstall': 232, // as in, uninstall the service but preserve its data
'View full message': 233,
'Service error': 234,
'Awaiting result': 235,
@@ -246,7 +246,6 @@ export const ENGLISH = {
'Hosting': 244,
'Installing': 245,
'See below': 246,
'Controls': 247,
'No services installed': 248,
'Running': 249,
'Stopped': 250,
@@ -413,12 +412,12 @@ export const ENGLISH = {
'Other Networks': 411,
'WiFi is disabled': 412,
'No wireless interface detected': 413,
'Enabling WiFi': 414,
'Disabling WiFi': 415,
'Enabling': 414,
'Disabling': 415,
'Connecting. This could take a while': 416,
'Retry': 417,
'Show more': 418,
'Release notes': 419,
'View details': 419,
'View listing': 420,
'Services that depend on': 421,
'will no longer work properly and may crash.': 422,
@@ -502,5 +501,20 @@ export const ENGLISH = {
'View Marketplace': 500,
'Welcome to': 501,
'sovereign computing': 502,
'french': 503,
'Customize the name appearing in your browser tab': 503,
'Manage': 504, // as in, administer
'Are you sure you want to delete this address?': 505, // this address referes to a domain or URL
'"Soft uninstall" will remove the service from StartOS but preserve its data.': 506,
'No saved providers': 507,
'Kiosk Mode': 508, // an OS mode that permits attaching a monitor to the computer
'Enabled': 509,
'Disable Kiosk Mode unless you need to attach a monitor': 510,
'Enable Kiosk Mode if you need to attach a monitor': 511,
'Kiosk Mode is unavailable on this device': 512,
'Enable': 513,
'Disable': 514,
'You are currently using a kiosk. Disabling Kiosk Mode will result in the kiosk disconnecting.': 515,
'Recommended': 516, // as in, we recommend this
'Are you sure you want to dismiss this task?': 517,
'Dismiss': 518, // as in, dismiss or delete a task
} as const

View File

@@ -45,7 +45,7 @@ export default {
42: 'Iniciando reinicio',
43: 'Estás usando la última versión de StartOS.',
44: '¡Actualizado!',
45: 'Notas de la versión',
45: 'notas de la versión',
46: 'Iniciar actualización',
47: 'Iniciando actualización',
48: 'Actualmente estás conectado a través de Tor. Si restableces el servicio Tor, perderás la conexión hasta que vuelva a estar en línea.',
@@ -60,7 +60,7 @@ export default {
57: 'Iniciando apagado',
58: 'Agregar',
59: 'Ok',
60: '¿Estás seguro de que deseas eliminar esta entrada?',
60: 'francés',
61: 'Este valor no se puede cambiar una vez establecido',
62: 'Continuar',
63: 'Haz clic o suelta el archivo aquí',
@@ -85,7 +85,7 @@ export default {
82: 'Métricas',
83: 'Registros',
84: 'Notificaciones',
85: 'Abrir interfaz',
85: 'Desinstalación forzada',
86: 'Mostrar QR',
87: 'Copiar URL',
88: 'Acciones',
@@ -230,9 +230,9 @@ export default {
227: 'Error desconocido',
228: 'Error',
229: '"Reconstruir contenedor" es una acción inofensiva que solo toma unos segundos. Probablemente resolverá este problema.',
230: '"Desinstalar servicio" es una acción peligrosa que eliminará el servicio de StartOS y borrará todos sus datos.',
230: '"Desinstalación forzada" es una acción peligrosa que eliminará el servicio de StartOS y borrará todos sus datos.',
231: 'Reconstruir contenedor',
232: 'Desinstalar servicio',
232: 'Desinstalación suave',
233: 'Ver mensaje completo',
234: 'Error del servicio',
235: 'Esperando resultado',
@@ -247,7 +247,6 @@ export default {
244: 'Alojamiento',
245: 'Instalando',
246: 'Ver abajo',
247: 'Controles',
248: 'No hay servicios instalados',
249: 'En ejecución',
250: 'Detenido',
@@ -414,12 +413,12 @@ export default {
411: 'Otras redes',
412: 'WiFi está deshabilitado',
413: 'No se detectó interfaz inalámbrica',
414: 'Habilitando WiFi',
415: 'Deshabilitando WiFi',
414: 'Habilitando',
415: 'Deshabilitando',
416: 'Conectando. Esto podría tardar un poco',
417: 'Reintentar',
418: 'Mostrar más',
419: 'Notas de la versión',
419: 'Ver detalles',
420: 'Ver listado',
421: 'Servicios que dependen de',
422: 'ya no funcionarán correctamente y podrían fallar.',
@@ -503,5 +502,20 @@ export default {
500: 'Ver Marketplace',
501: 'Bienvenido a',
502: 'computación soberana',
503: 'francés',
503: 'Personaliza el nombre que aparece en la pestaña de tu navegador',
504: 'Administrar',
505: '¿Estás seguro de que deseas eliminar esta dirección?',
506: '"Desinstalación suave" eliminará el servicio de StartOS pero conservará sus datos.',
507: 'No hay proveedores guardados',
508: 'Modo quiosco',
509: 'Activado',
510: 'Desactiva el modo quiosco a menos que necesites conectar un monitor',
511: 'Activa el modo quiosco si necesitas conectar un monitor',
512: 'El modo quiosco no está disponible en este dispositivo',
513: 'Activar',
514: 'Desactivar',
515: 'Actualmente estás utilizando un quiosco. Desactivar el modo quiosco provocará su desconexión.',
516: 'Recomendado',
517: '¿Estás seguro de que deseas descartar esta tarea?',
518: 'Descartar',
} satisfies i18n

View File

@@ -60,7 +60,7 @@ export default {
57: 'Arrêt initié',
58: 'Ajouter',
59: 'OK',
60: 'Voulez-vous vraiment supprimer cette entrée ?',
60: 'français',
61: 'Cette valeur ne peut plus être modifiée une fois définie',
62: 'Continuer',
63: 'Cliquez ou déposez le fichier ici',
@@ -85,7 +85,7 @@ export default {
82: 'Métriques',
83: 'Journaux',
84: 'Notifications',
85: 'Lancer linterface utilisateur',
85: 'Désinstallation forcée',
86: 'Afficher le QR',
87: 'Copier lURL',
88: 'Actions',
@@ -230,9 +230,9 @@ export default {
227: 'Erreur inconnue',
228: 'Erreur',
229:  Reconstruire le conteneur » est une action sans risque qui ne prend que quelques secondes. Cela résoudra probablement ce problème.',
230:  Désinstaller le service » est une action risquée qui supprimera le service de StartOS et effacera toutes ses données.',
230:  Désinstallation forcée » est une action risquée qui supprimera le service de StartOS et effacera toutes ses données.',
231: 'Reconstruire le conteneur',
232: 'Désinstaller le service',
232: 'Désinstallation douce',
233: 'Voir le message complet',
234: 'Erreur du service',
235: 'En attente du résultat',
@@ -247,7 +247,6 @@ export default {
244: 'Hébergement',
245: 'Installation',
246: 'Voir ci-dessous',
247: 'Contrôles',
248: 'Aucun service installé',
249: 'En fonctionnement',
250: 'Arrêté',
@@ -414,12 +413,12 @@ export default {
411: 'Autres réseaux',
412: 'Le WiFi est désactivé',
413: 'Aucune interface sans fil détectée',
414: 'Activation du WiFi',
415: 'Désactivation du WiFi',
414: 'Activation',
415: 'Désactivation',
416: 'Connexion en cours. Cela peut prendre un certain temps',
417: 'Réessayer',
418: 'Afficher plus',
419: 'Notes de version',
419: 'Voir les détails',
420: 'Voir la fiche',
421: 'Services dépendants de',
422: 'ne fonctionneront plus correctement et pourraient planter.',
@@ -503,5 +502,20 @@ export default {
500: 'Voir la bibliothèque de services',
501: 'Bienvenue sur',
502: 'informatique souveraine',
503: 'français',
503: 'Personnalisez le nom qui apparaît dans longlet de votre navigateur',
504: 'Gérer',
505: 'Êtes-vous sûr de vouloir supprimer cette adresse ?',
506:  Désinstallation douce » supprimera le service de StartOS tout en conservant ses données.',
507: 'Aucun fournisseur enregistré',
508: 'Mode kiosque',
509: 'Activé',
510: 'Désactivez le mode kiosque sauf si vous devez connecter un moniteur',
511: 'Activez le mode kiosque si vous devez connecter un moniteur',
512: 'Le mode kiosque nest pas disponible sur cet appareil',
513: 'Activer',
514: 'Désactiver',
515: 'Vous utilisez actuellement un kiosque. Désactiver le mode kiosque entraînera sa déconnexion.',
516: 'Recommandé',
517: 'Êtes-vous sûr de vouloir ignorer cette tâche ?',
518: 'Ignorer',
} satisfies i18n

View File

@@ -60,7 +60,7 @@ export default {
57: 'Rozpoczynanie wyłączania',
58: 'Dodaj',
59: 'OK',
60: 'Czy na pewno chcesz usunąć ten wpis?',
60: 'francuski',
61: 'Ta wartość nie może być zmieniona po jej ustawieniu',
62: 'Kontynuuj',
63: 'Kliknij lub upuść plik tutaj',
@@ -85,7 +85,7 @@ export default {
82: 'Monitorowanie',
83: 'Logi',
84: 'Powiadomienia',
85: 'Uruchom interfejs',
85: 'Twarde odinstalowanie',
86: 'Pokaż kod QR',
87: 'Kopiuj URL',
88: 'Akcje',
@@ -230,9 +230,9 @@ export default {
227: 'Nieznany błąd',
228: 'Błąd',
229: '„Odbuduj kontener” to bezpieczna akcja, która zajmuje tylko kilka sekund. Prawdopodobnie rozwiąże ten problem.',
230: '„Odinstaluj serwis” to niebezpieczna akcja, która usunie serwis ze StartOS i trwale usunie wszystkie jego dane.',
230: '„Twarde odinstalowanie” to niebezpieczna akcja, która usunie serwis ze StartOS i trwale usunie wszystkie jego dane.',
231: 'Odbuduj kontener',
232: 'Odinstaluj serwis',
232: 'Miękkie odinstalowanie',
233: 'Zobacz pełną wiadomość',
234: 'Błąd serwisu',
235: 'Oczekiwanie na wynik',
@@ -247,7 +247,6 @@ export default {
244: 'Hosting',
245: 'Instalowanie',
246: 'Zobacz poniżej',
247: 'Sterowanie',
248: 'Brak zainstalowanych serwisów',
249: 'Uruchomiony',
250: 'Zatrzymany',
@@ -414,12 +413,12 @@ export default {
411: 'Inne sieci',
412: 'WiFi jest wyłączone',
413: 'Nie wykryto interfejsu bezprzewodowego',
414: 'Włączanie WiFi',
415: 'Wyłączanie WiFi',
414: 'Włączanie',
415: 'Wyłączanie',
416: 'Łączenie. To może chwilę potrwać',
417: 'Ponów próbę',
418: 'Pokaż więcej',
419: 'Informacje o wydaniu',
419: 'Zobacz szczegóły',
420: 'Zobacz listę',
421: 'Serwisy zależne od',
422: 'przestaną działać poprawnie i mogą ulec awarii.',
@@ -503,5 +502,20 @@ export default {
500: 'Zobacz Rynek',
501: 'Witamy w',
502: 'suwerenne przetwarzanie',
503: 'francuski',
503: 'Dostosuj nazwę wyświetlaną na karcie przeglądarki',
504: 'Zarządzać',
505: 'Czy na pewno chcesz usunąć ten adres?',
506: '„Miękkie odinstalowanie” usunie usługę z StartOS, ale zachowa jej dane.',
507: 'Brak zapisanych dostawców',
508: 'Tryb kiosku',
509: 'Włączony',
510: 'Wyłącz tryb kiosku, chyba że potrzebujesz podłączyć monitor',
511: 'Włącz tryb kiosku, jeśli potrzebujesz podłączyć monitor',
512: 'Tryb kiosku jest niedostępny na tym urządzeniu',
513: 'Włącz',
514: 'Wyłącz',
515: 'Obecnie używasz kiosku. Wyłączenie trybu kiosku spowoduje jego rozłączenie.',
516: 'Zalecane',
517: 'Czy na pewno chcesz odrzucić to zadanie?',
518: 'Odrzuć',
} satisfies i18n

View File

@@ -11,8 +11,6 @@ import { I18N, i18nKey } from './i18n.providers'
export class i18nPipe implements PipeTransform {
private readonly i18n = inject(I18N)
// @TODO uncomment to make sure translations are present
// transform(englishKey: string | null | undefined): string | undefined {
transform(englishKey: i18nKey | null | undefined): string | undefined {
return englishKey
? this.i18n()?.[ENGLISH[englishKey as i18nKey]] || englishKey

View File

@@ -34,5 +34,11 @@ export class i18nService extends TuiLanguageSwitcherService {
}
}
export const languages = ['english', 'spanish', 'polish', 'german', 'french'] as const
export const languages = [
'english',
'spanish',
'polish',
'german',
'french',
] as const
export type Languages = (typeof languages)[number]

View File

@@ -12,13 +12,15 @@ export function formatProgress({ phases, overall }: T.FullProgress): {
p,
): p is {
name: string
progress: {
done: number
total: number | null
}
progress:
| false
| {
done: number
total: number | null
}
} => p.progress !== true && p.progress !== null,
)
.map(p => `<b>${p.name}</b>${getPhaseBytes(p.progress)}`)
.map(p => `<b>${p.name}</b>: (${getPhaseBytes(p.progress)})`)
.join(', '),
}
}
@@ -33,8 +35,13 @@ function getDecimal(progress: T.Progress): number {
}
}
function getPhaseBytes(progress: T.Progress): string {
return progress === true || !progress
? ''
: `: (${progress.done}/${progress.total})`
function getPhaseBytes(
progress:
| false
| {
done: number
total: number | null
},
): string {
return !progress ? 'unknown' : `${progress.done}/${progress.total}`
}

View File

@@ -277,6 +277,7 @@ body {
vertical-align: bottom;
animation: ellipsis-dot 1s infinite 0.3s;
animation-fill-mode: forwards;
text-align: left;
width: 1em;
}

View File

@@ -95,6 +95,24 @@
}
}
[tuiAppearance][data-appearance='primary-success'] {
color: var(--tui-text-primary-on-accent-1);
background: var(--tui-status-positive);
@include appearance-hover {
filter: brightness(1.2);
}
@include appearance-active {
filter: brightness(0.9);
}
@include appearance-disabled {
background: var(--tui-status-neutral);
color: #333;
}
}
tui-hint[data-appearance='onDark'] {
background: white !important;
color: #222 !important;

View File

@@ -48,6 +48,6 @@ export default class InitializingPage {
return caught$
}),
),
{ initialValue: { total: 0, message: '' } },
{ initialValue: { total: 0, message: 'waiting...' } },
)
}

View File

@@ -51,7 +51,6 @@ export class LoginPage {
} catch (e: any) {
// code 7 is for incorrect password
this.error = e.code === 7 ? 'Invalid password' : (e.message as i18nKey)
} finally {
loader.unsubscribe()
}
}

View File

@@ -73,20 +73,7 @@ export class FormArrayComponent {
}
removeAt(index: number) {
this.dialog
.openConfirm<boolean>({
label: 'Confirm',
size: 's',
data: {
content: 'Are you sure you want to delete this entry?',
yes: 'Delete',
no: 'Cancel',
},
})
.pipe(filter(Boolean), takeUntilDestroyed(this.destroyRef))
.subscribe(() => {
this.removeItem(index)
})
this.removeItem(index)
}
private removeItem(index: number) {

View File

@@ -29,7 +29,7 @@ import { ABOUT } from './about.component'
appearance=""
tuiHintDirection="bottom"
[tuiHint]="open ? '' : ('Start Menu' | i18n)"
[tuiHintShowDelay]="1000"
[tuiHintShowDelay]="750"
[tuiDropdown]="content"
[(tuiDropdownOpen)]="open"
[tuiDropdownMaxHeight]="9999"

View File

@@ -23,7 +23,7 @@ import { getMenu } from 'src/app/utils/system-utilities'
class="link"
routerLinkActive="link_active"
tuiHintDirection="bottom"
[tuiHintShowDelay]="1000"
[tuiHintShowDelay]="750"
[routerLink]="item.routerLink"
[class.link_system]="item.routerLink === '/portal/system'"
[tuiHint]="rla.isActive ? '' : (item.name | i18n)"

View File

@@ -15,6 +15,7 @@ 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({
standalone: true,
@@ -22,24 +23,30 @@ import { InterfaceComponent } from './interface.component'
template: `
<div class="desktop">
<ng-content />
@if (interface.serviceInterface().type === 'ui') {
<a
@if (interface.value().type === 'ui') {
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.external-link"
target="_blank"
rel="noreferrer"
[href]="actions()"
[disabled]="disabled()"
(click)="openUI()"
>
{{ 'Launch UI' | i18n }}
</a>
{{ 'Open' | i18n }}
</button>
}
<button tuiIconButton iconStart="@tui.qr-code" (click)="showQR()">
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.qr-code"
(click)="showQR()"
>
{{ 'Show QR' | i18n }}
</button>
<button
tuiIconButton
appearance="flat-grayscale"
iconStart="@tui.copy"
(click)="copyService.copy(actions())"
(click)="copyService.copy(href())"
>
{{ 'Copy URL' | i18n }}
</button>
@@ -55,27 +62,26 @@ import { InterfaceComponent } from './interface.component'
<ng-template #dropdown let-close>
<tui-data-list>
<tui-opt-group>
@if (interface.serviceInterface().type === 'ui') {
<a
tuiOption
iconStart="@tui.external-link"
target="_blank"
rel="noreferrer"
[href]="actions()"
>
{{ 'Launch UI' | i18n }}
</a>
<button tuiOption iconStart="@tui.qr-code" (click)="showQR()">
{{ 'Show QR' | i18n }}
</button>
@if (interface.value().type === 'ui') {
<button
tuiOption
iconStart="@tui.copy"
(click)="copyService.copy(actions()); close()"
iconStart="@tui.external-link"
[disabled]="disabled()"
(click)="openUI()"
>
{{ 'Copy URL' | i18n }}
{{ 'Open' | i18n }}
</button>
}
<button tuiOption iconStart="@tui.qr-code" (click)="showQR()">
{{ 'Show QR' | i18n }}
</button>
<button
tuiOption
iconStart="@tui.copy"
(click)="copyService.copy(href()); close()"
>
{{ 'Copy URL' | i18n }}
</button>
</tui-opt-group>
<tui-opt-group><ng-content select="[tuiOption]" /></tui-opt-group>
</tui-data-list>
@@ -110,20 +116,27 @@ import { InterfaceComponent } from './interface.component'
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InterfaceActionsComponent {
private readonly document = inject(DOCUMENT)
readonly isMobile = inject(TUI_IS_MOBILE)
readonly dialog = inject(DialogService)
readonly copyService = inject(CopyService)
readonly interface = inject(InterfaceComponent)
readonly actions = input.required<string>()
readonly href = input.required<string>()
readonly disabled = input.required<boolean>()
showQR() {
this.dialog
.openComponent(new PolymorpheusComponent(QRModal), {
size: 'auto',
closeable: this.isMobile,
data: this.actions(),
data: this.href(),
})
.subscribe()
}
openUI() {
this.document.defaultView?.open(this.href(), '_blank', 'noreferrer')
}
}

View File

@@ -1,13 +1,13 @@
import {
ChangeDetectionStrategy,
Component,
computed,
inject,
input,
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
DialogService,
DocsLinkDirective,
ErrorService,
i18nPipe,
LoadingService,
@@ -59,22 +59,12 @@ type ClearnetForm = {
}}
<a
tuiLink
docsLink
href="/user-manual/connecting-remotely/clearnet.html"
target="_blank"
rel="noreferrer"
>
{{ 'Learn more' | i18n }}
</a>
</ng-template>
<button
tuiButton
[appearance]="isPublic() ? 'primary-destructive' : 'accent'"
[iconStart]="isPublic() ? '@tui.globe-lock' : '@tui.globe'"
[style.margin-inline-start]="'auto'"
(click)="toggle()"
>
{{ isPublic() ? ('Make private' | i18n) : ('Make public' | i18n) }}
</button>
@if (clearnet().length) {
<button tuiButton iconStart="@tui.plus" (click)="add()">
{{ 'Add' | i18n }}
@@ -86,18 +76,15 @@ type ClearnetForm = {
@for (address of clearnet(); track $index) {
<tr>
<td [style.width.rem]="12">
{{
interface.serviceInterface().addSsl
? (address.acme | acme)
: '-'
}}
{{ interface.value().addSsl ? (address.acme | acme) : '-' }}
</td>
<td>{{ address.url | mask }}</td>
<td [actions]="address.url">
<td actions [href]="address.url" [disabled]="!isRunning()">
@if (address.isDomain) {
<button
tuiButton
appearance="primary-destructive"
tuiIconButton
iconStart="@tui.trash"
appearance="flat-grayscale"
[style.margin-inline-end.rem]="0.5"
(click)="remove(address)"
>
@@ -141,6 +128,7 @@ type ClearnetForm = {
AcmePipe,
InterfaceActionsComponent,
i18nPipe,
DocsLinkDirective,
],
changeDetection: ChangeDetectionStrategy.OnPush,
})
@@ -152,9 +140,10 @@ export class InterfaceClearnetComponent {
private readonly api = inject(ApiService)
readonly interface = inject(InterfaceComponent)
readonly isPublic = computed(() => this.interface.serviceInterface().public)
readonly clearnet = input.required<readonly ClearnetAddress[]>()
readonly isRunning = input.required<boolean>()
readonly acme = toSignal(
inject<PatchDB<DataModel>>(PatchDB)
.watch$('serverInfo', 'network', 'acme')
@@ -165,7 +154,15 @@ export class InterfaceClearnetComponent {
async remove({ url }: ClearnetAddress) {
const confirm = await firstValueFrom(
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.openConfirm({
label: 'Confirm',
size: 's',
data: {
yes: 'Delete',
no: 'Cancel',
content: 'Are you sure you want to delete this address?',
},
})
.pipe(defaultIfEmpty(false)),
)
@@ -181,7 +178,7 @@ export class InterfaceClearnetComponent {
await this.api.pkgRemoveDomain({
...params,
package: this.interface.packageId(),
host: this.interface.serviceInterface().addressInfo.hostId,
host: this.interface.value().addressInfo.hostId,
})
} else {
await this.api.serverRemoveDomain(params)
@@ -195,33 +192,6 @@ export class InterfaceClearnetComponent {
}
}
async toggle() {
const loader = this.loader
.open(`Making ${this.isPublic() ? 'private' : 'public'}`)
.subscribe()
const params = {
internalPort: this.interface.serviceInterface().addressInfo.internalPort,
public: !this.isPublic(),
}
try {
if (this.interface.packageId()) {
await this.api.pkgBindingSetPubic({
...params,
host: this.interface.serviceInterface().addressInfo.hostId,
package: this.interface.packageId(),
})
} else {
await this.api.serverBindingSetPubic(params)
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
async add() {
const domain = ISB.Value.text({
name: 'Domain',
@@ -250,9 +220,7 @@ export class InterfaceClearnetComponent {
data: {
spec: await configBuilderToSpec(
ISB.InputSpec.of(
this.interface.serviceInterface().addSsl
? { domain, acme }
: { domain },
this.interface.value().addSsl ? { domain, acme } : { domain },
),
),
buttons: [
@@ -281,7 +249,7 @@ export class InterfaceClearnetComponent {
await this.api.pkgAddDomain({
...params,
package: this.interface.packageId(),
host: this.interface.serviceInterface().addressInfo.hostId,
host: this.interface.value().addressInfo.hostId,
})
} else {
await this.api.serverAddDomain(params)

View File

@@ -1,17 +1,39 @@
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { tuiButtonOptionsProvider } from '@taiga-ui/core'
import {
ChangeDetectionStrategy,
Component,
inject,
input,
} from '@angular/core'
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
import { TuiButton, tuiButtonOptionsProvider } from '@taiga-ui/core'
import { InterfaceClearnetComponent } from 'src/app/routes/portal/components/interfaces/clearnet.component'
import { InterfaceLocalComponent } from 'src/app/routes/portal/components/interfaces/local.component'
import { InterfaceTorComponent } from 'src/app/routes/portal/components/interfaces/tor.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { MappedServiceInterface } from './interface.utils'
@Component({
standalone: true,
selector: 'app-interface',
template: `
<section [clearnet]="serviceInterface().addresses.clearnet"></section>
<section [tor]="serviceInterface().addresses.tor"></section>
<section [local]="serviceInterface().addresses.local"></section>
<button
tuiButton
size="s"
[appearance]="value().public ? 'primary-destructive' : 'primary-success'"
[iconStart]="value().public ? '@tui.globe-lock' : '@tui.globe'"
(click)="toggle()"
>
{{ value().public ? ('Make private' | i18n) : ('Make public' | i18n) }}
</button>
<section
[clearnet]="value().addresses.clearnet"
[isRunning]="isRunning()"
></section>
<section [tor]="value().addresses.tor" [isRunning]="isRunning()"></section>
<section
[local]="value().addresses.local"
[isRunning]="isRunning()"
></section>
`,
styles: `
:host {
@@ -19,6 +41,12 @@ import { MappedServiceInterface } from './interface.utils'
display: flex;
flex-direction: column;
gap: 1rem;
color: var(--tui-text-secondary);
font: var(--tui-font-text-l);
}
button {
margin: -0.5rem auto 0 0;
}
`,
providers: [tuiButtonOptionsProvider({ size: 'xs' })],
@@ -27,9 +55,43 @@ import { MappedServiceInterface } from './interface.utils'
InterfaceClearnetComponent,
InterfaceTorComponent,
InterfaceLocalComponent,
TuiButton,
i18nPipe,
],
})
export class InterfaceComponent {
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
private readonly api = inject(ApiService)
readonly packageId = input('')
readonly serviceInterface = input.required<MappedServiceInterface>()
readonly value = input.required<MappedServiceInterface>()
readonly isRunning = input.required<boolean>()
async toggle() {
const loader = this.loader
.open(`Making ${this.value().public ? 'private' : 'public'}`)
.subscribe()
const params = {
internalPort: this.value().addressInfo.internalPort,
public: !this.value().public,
}
try {
if (this.packageId()) {
await this.api.pkgBindingSetPubic({
...params,
host: this.value().addressInfo.hostId,
package: this.packageId(),
})
} else {
await this.api.serverBindingSetPubic(params)
}
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
}

View File

@@ -29,7 +29,7 @@ import { DocsLinkDirective, i18nPipe } from '@start9labs/shared'
<tr>
<td [style.width.rem]="12">{{ address.nid }}</td>
<td>{{ address.url | mask }}</td>
<td [actions]="address.url"></td>
<td actions [href]="address.url" [disabled]="!isRunning()"></td>
</tr>
}
</table>
@@ -49,4 +49,5 @@ import { DocsLinkDirective, i18nPipe } from '@start9labs/shared'
})
export class InterfaceLocalComponent {
readonly local = input.required<readonly LocalAddress[]>()
readonly isRunning = input.required<boolean>()
}

View File

@@ -9,7 +9,7 @@ export class MaskPipe implements PipeTransform {
private readonly interface = inject(InterfaceComponent)
transform(value: string): string {
return this.interface.serviceInterface().masked
return this.interface.value().masked
? '●'.repeat(Math.min(64, value.length))
: value
}

View File

@@ -9,13 +9,16 @@ import { TuiBadge } from '@taiga-ui/kit'
<tui-badge
size="l"
[iconStart]="public() ? '@tui.globe' : '@tui.lock'"
[style.vertical-align.rem]="-0.125"
[style.margin]="'0 0.25rem -0.25rem'"
[appearance]="public() ? 'positive' : 'negative'"
>
{{ public() ? ('Public' | i18n) : ('Private' | i18n) }}
</tui-badge>
`,
styles: `
:host {
display: inline-flex;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiBadge, i18nPipe],
})

View File

@@ -76,11 +76,11 @@ type OnionForm = {
{{ address.url | mask }}
</div>
</td>
<td [actions]="address.url">
<td actions [href]="address.url" [disabled]="!isRunning()">
<button
tuiButton
appearance="primary-destructive"
[style.margin-inline-end.rem]="0.5"
tuiIconButton
iconStart="@tui.trash"
appearance="flat-grayscale"
(click)="remove(address)"
>
{{ 'Delete' | i18n }}
@@ -141,11 +141,20 @@ export class InterfaceTorComponent {
private readonly i18n = inject(i18nPipe)
readonly tor = input.required<readonly TorAddress[]>()
readonly isRunning = input.required<boolean>()
async remove({ url }: TorAddress) {
const confirm = await firstValueFrom(
this.dialog
.openConfirm({ label: 'Are you sure?', size: 's' })
.openConfirm({
label: 'Confirm',
size: 's',
data: {
yes: 'Delete',
no: 'Cancel',
content: 'Are you sure you want to delete this address?',
},
})
.pipe(defaultIfEmpty(false)),
)
@@ -161,7 +170,7 @@ export class InterfaceTorComponent {
await this.api.pkgRemoveOnion({
...params,
package: this.interface.packageId(),
host: this.interface.serviceInterface().addressInfo.hostId,
host: this.interface.value().addressInfo.hostId,
})
} else {
await this.api.serverRemoveOnion(params)
@@ -215,7 +224,7 @@ export class InterfaceTorComponent {
await this.api.pkgAddOnion({
onion,
package: this.interface.packageId(),
host: this.interface.serviceInterface().addressInfo.hostId,
host: this.interface.value().addressInfo.hostId,
})
} else {
await this.api.serverAddOnion({ onion })

View File

@@ -62,6 +62,13 @@ import { HeaderComponent } from './components/header/header.component'
flex-direction: column;
// @TODO Theme
background: url(/assets/img/background_dark.jpeg) fixed center/cover;
&::before {
content: '';
position: fixed;
inset: 0;
backdrop-filter: blur(0.5rem);
}
}
main {

View File

@@ -30,7 +30,7 @@ const ROUTES: Routes = [
{
title: titleResolver,
path: 'logs',
loadComponent: () => import('./routes/logs/logs.component'),
loadChildren: () => import('./routes/logs/logs.routes'),
data: toNavigationItem('/portal/logs'),
},
{

View File

@@ -0,0 +1,64 @@
import {
ChangeDetectionStrategy,
Component,
input,
ViewEncapsulation,
} from '@angular/core'
import { RouterLink } from '@angular/router'
import { i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiTitle } from '@taiga-ui/core'
import { TuiHeader } from '@taiga-ui/layout'
import { TitleDirective } from 'src/app/services/title.service'
@Component({
standalone: true,
selector: 'logs-header',
template: `
<ng-container *title>
<a tuiIconButton size="m" iconStart="@tui.arrow-left" routerLink="..">
{{ 'Back' | i18n }}
</a>
{{ title() }}
</ng-container>
<hgroup tuiTitle>
<h3>{{ title() }}</h3>
<p tuiSubtitle><ng-content /></p>
</hgroup>
<aside tuiAccessories>
<a
tuiIconButton
appearance="secondary-grayscale"
iconStart="@tui.x"
size="s"
routerLink=".."
[style.border-radius.%]="100"
>
{{ 'Close' | i18n }}
</a>
</aside>
`,
styles: `
logs-header[tuiHeader] {
margin-block-end: 1rem;
+ logs {
height: calc(100% - 5rem);
}
tui-root._mobile & {
display: none;
+ logs {
height: 100%;
}
}
}
`,
encapsulation: ViewEncapsulation.None,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, TuiTitle, i18nPipe, RouterLink, TitleDirective],
hostDirectives: [TuiHeader],
})
export class LogsHeaderComponent {
readonly title = input<string | undefined>()
}

View File

@@ -1,202 +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>
}
}
`,
standalone: true,
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: 700;
}
logs {
height: calc(100% - 4rem);
width: 100%;
}
.close {
position: absolute;
right: 0;
border-radius: 100%;
}
button::before {
margin: 0 -0.25rem 0 -0.375rem;
--tui-icon-size: 1.5rem;
}
[tuiCardMedium] {
height: 14rem;
width: 14rem;
cursor: pointer;
box-shadow:
inset 0 0 0 1px var(--tui-background-neutral-1),
var(--tui-shadow-small);
[tuiSubtitle] {
color: var(--tui-text-secondary);
}
tui-icon:last-child {
align-self: flex-end;
}
}
:host-context(tui-root._mobile) {
flex-direction: column;
justify-content: flex-start;
header {
padding: 0;
}
.title {
display: none;
}
logs {
height: calc(100% - 2rem);
}
[tuiCardMedium] {
width: 100%;
height: auto;
gap: 1rem;
}
}
`,
],
imports: [
LogsComponent,
TitleDirective,
KeyValuePipe,
TuiTitle,
TuiCardMedium,
TuiIcon,
TuiAppearance,
TuiButton,
i18nPipe,
],
})
export default class SystemLogsComponent {
private readonly api = inject(ApiService)
readonly current = signal<string | null>(null)
readonly logs: Record<string, Log> = {
os: {
title: 'OS Logs',
subtitle: 'Raw, unfiltered operating system logs',
icon: '@tui.square-dashed-bottom-code',
follow: params => this.api.followServerLogs(params),
fetch: params => this.api.getServerLogs(params),
},
kernel: {
title: 'Kernel Logs',
subtitle: 'Diagnostics for drivers and other kernel processes',
icon: '@tui.square-chevron-right',
follow: params => this.api.followKernelLogs(params),
fetch: params => this.api.getKernelLogs(params),
},
tor: {
title: 'Tor Logs',
subtitle: 'Diagnostic logs for the Tor daemon on StartOS',
icon: '@tui.globe',
follow: params => this.api.followTorLogs(params),
fetch: params => this.api.getTorLogs(params),
},
}
}

View File

@@ -0,0 +1,22 @@
import { Routes } from '@angular/router'
export const ROUTES: Routes = [
{
path: '',
loadComponent: () => import('./routes/outlet.component'),
},
{
path: 'kernel',
loadComponent: () => import('./routes/kernel.component'),
},
{
path: 'os',
loadComponent: () => import('./routes/os.component'),
},
{
path: 'tor',
loadComponent: () => import('./routes/tor.component'),
},
]
export default ROUTES

View File

@@ -0,0 +1,40 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
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 { LogsHeaderComponent } from '../components/header.component'
@Component({
standalone: true,
template: `
<logs-header [title]="'Kernel Logs' | i18n">
{{ 'Diagnostics for drivers and other kernel processes' | i18n }}
</logs-header>
<logs context="kernel" [followLogs]="follow" [fetchLogs]="fetch" />
`,
styles: `
:host {
padding: 1rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [LogsComponent, LogsHeaderComponent, i18nPipe],
host: { class: 'g-page' },
})
export default class SystemKernelComponent {
private readonly api = inject(ApiService)
protected readonly follow = (params: RR.FollowServerLogsReq) =>
this.api.followKernelLogs(params)
protected readonly fetch = (params: RR.GetServerLogsReq) =>
this.api.getKernelLogs(params)
log = {
title: 'Kernel Logs',
subtitle: 'Diagnostics for drivers and other kernel processes',
icon: '@tui.square-chevron-right',
}
}

View File

@@ -0,0 +1,39 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { LogsComponent } from 'src/app/routes/portal/components/logs/logs.component'
import { LogsHeaderComponent } from 'src/app/routes/portal/routes/logs/components/header.component'
import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Component({
standalone: true,
template: `
<logs-header [title]="'OS Logs' | i18n">
{{ 'Raw, unfiltered operating system logs' | i18n }}
</logs-header>
<logs context="os" [followLogs]="follow" [fetchLogs]="fetch" />
`,
styles: `
:host {
padding: 1rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [LogsComponent, LogsHeaderComponent, i18nPipe],
host: { class: 'g-page' },
})
export default class SystemOSComponent {
private readonly api = inject(ApiService)
protected readonly follow = (params: RR.FollowServerLogsReq) =>
this.api.followServerLogs(params)
protected readonly fetch = (params: RR.GetServerLogsReq) =>
this.api.getServerLogs(params)
log = {
title: 'Kernel Logs',
subtitle: 'Diagnostics for drivers and other kernel processes',
icon: '@tui.square-chevron-right',
}
}

View File

@@ -0,0 +1,96 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { RouterLink } from '@angular/router'
import { i18nPipe } from '@start9labs/shared'
import { TuiAppearance, TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiCardMedium } from '@taiga-ui/layout'
import { TitleDirective } from 'src/app/services/title.service'
@Component({
template: `
<ng-container *title>{{ 'Logs' | i18n }}</ng-container>
@for (log of logs; track $index) {
<a tuiCardMedium tuiAppearance="neutral" [routerLink]="log.link">
<tui-icon [icon]="log.icon" />
<span tuiTitle>
{{ log.title | i18n }}
<span tuiSubtitle>{{ log.subtitle | i18n }}</span>
</span>
<tui-icon icon="@tui.chevron-right" />
</a>
}
`,
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
host: { class: 'g-page' },
styles: [
`
:host {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 1rem;
padding: 1rem;
}
[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;
[tuiCardMedium] {
width: 100%;
height: auto;
gap: 1rem;
}
}
`,
],
imports: [
RouterLink,
TitleDirective,
TuiTitle,
TuiCardMedium,
TuiIcon,
TuiAppearance,
i18nPipe,
],
})
export default class SystemLogsComponent {
readonly logs = [
{
link: 'os',
title: 'OS Logs',
subtitle: 'Raw, unfiltered operating system logs',
icon: '@tui.square-dashed-bottom-code',
},
{
link: 'kernel',
title: 'Kernel Logs',
subtitle: 'Diagnostics for drivers and other kernel processes',
icon: '@tui.square-chevron-right',
},
{
link: 'tor',
title: 'Tor Logs',
subtitle: 'Diagnostic logs for the Tor daemon on StartOS',
icon: '@tui.globe',
},
] as const
}

View File

@@ -0,0 +1,39 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { LogsComponent } from 'src/app/routes/portal/components/logs/logs.component'
import { LogsHeaderComponent } from 'src/app/routes/portal/routes/logs/components/header.component'
import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Component({
standalone: true,
template: `
<logs-header [title]="'Tor Logs' | i18n">
{{ 'Diagnostic logs for the Tor daemon on StartOS' | i18n }}
</logs-header>
<logs context="tor" [followLogs]="follow" [fetchLogs]="fetch" />
`,
styles: `
:host {
padding: 1rem;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [LogsComponent, LogsHeaderComponent, i18nPipe],
host: { class: 'g-page' },
})
export default class SystemOSComponent {
private readonly api = inject(ApiService)
protected readonly follow = (params: RR.FollowServerLogsReq) =>
this.api.followServerLogs(params)
protected readonly fetch = (params: RR.GetServerLogsReq) =>
this.api.getServerLogs(params)
log = {
title: 'Kernel Logs',
subtitle: 'Diagnostics for drivers and other kernel processes',
icon: '@tui.square-chevron-right',
}
}

View File

@@ -64,8 +64,15 @@ import { StorageService } from 'src/app/services/storage.service'
overflow: hidden;
padding: 0;
background: rgb(55 58 63 / 90%)
url('/assets/img/background_marketplace.png') no-repeat top right;
url('/assets/img/background_marketplace.jpg') no-repeat top right;
background-size: cover;
&::before {
content: '';
position: absolute;
inset: 0;
backdrop-filter: blur(2rem);
}
}
.marketplace-content {

View File

@@ -20,7 +20,6 @@ import {
import {
DialogService,
Exver,
i18nKey,
i18nPipe,
MARKDOWN,
SharedPipesModule,
@@ -34,6 +33,7 @@ import {
map,
startWith,
switchMap,
tap,
} from 'rxjs'
import { MarketplaceService } from 'src/app/services/marketplace.service'
@@ -59,10 +59,9 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
<marketplace-additional-item
(click)="selectVersion(pkg, version)"
[data]="('Click to view all versions' | i18n) || ''"
[icon]="versions.length > 1 ? '@tui.chevron-right' : ''"
icon="@tui.chevron-right"
label="All versions"
class="versions"
[class.versions_empty]="versions.length < 2"
/>
<ng-template
#version
@@ -81,7 +80,7 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
<button
tuiButton
appearance="secondary"
(click)="completeWith(data.value)"
(click)="completeWith(data.version)"
>
{{ 'Ok' | i18n }}
</button>
@@ -91,7 +90,7 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
</marketplace-additional>
</div>
} @else {
<tui-loader class="loading" textContent="Loading" />
<tui-loader textContent="Loading" [style.height.%]="100" />
}
</div>
`,
@@ -114,7 +113,7 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
}
.listing {
font-size: 0.9rem;
font-size: 0.8rem;
// @TODO theme
color: #8059e5;
font-weight: 600;
@@ -139,16 +138,6 @@ import { MarketplaceService } from 'src/app/services/marketplace.service'
::ng-deep label {
cursor: pointer;
}
&_empty {
pointer-events: none;
}
}
.loading {
min-width: 30rem;
height: 100%;
place-self: center;
}
marketplace-additional {
@@ -254,6 +243,6 @@ export class MarketplacePreviewComponent {
data: { version },
})
.pipe(filter(Boolean))
.subscribe(version => this.version$.next(version))
.subscribe(selected => this.version$.next(selected))
}
}

View File

@@ -49,7 +49,7 @@ import { ChangeDetectionStrategy, Component, input } from '@angular/core'
</linearGradient>
</defs>
</svg>
<b>{{ value() || '-' }}</b>
<b>{{ value() ? value() + ' C°' : 'N/A' }}</b>
`,
styles: `
@import '@taiga-ui/core/styles/taiga-ui-local';

View File

@@ -55,7 +55,11 @@ import { i18nPipe } from '@start9labs/shared'
}
@if (notificationItem.code === 1 || notificationItem.code === 2) {
<button tuiLink (click)="service.viewModal(notificationItem)">
{{ 'View report' | i18n }}
{{
notificationItem.code === 1
? ('View report' | i18n)
: ('View details' | i18n)
}}
</button>
}
</td>
@@ -66,7 +70,7 @@ import { i18nPipe } from '@start9labs/shared'
'[class._new]': '!notificationItem.read',
},
styles: `
@import '@taiga-ui/core/styles/taiga-ui-local';
@use '@taiga-ui/core/styles/taiga-ui-local' as taiga;
:host {
grid-template-columns: 1fr;
@@ -90,8 +94,16 @@ import { i18nPipe } from '@start9labs/shared'
}
:host-context(tui-root._mobile) {
gap: 0.5rem;
padding: 0.75rem 1rem !important;
.checkbox {
@include fullsize();
@include taiga.fullsize();
@include taiga.transition(box-shadow);
&:has(:checked) {
box-shadow: inset 0.25rem 0 var(--tui-background-accent-1);
}
}
.date {
@@ -103,8 +115,7 @@ import { i18nPipe } from '@start9labs/shared'
font-weight: bold;
font-size: 1.2em;
display: flex;
align-items: center;
gap: 0.75rem;
gap: 0.5rem;
}
.service:not(:has(a)) {

View File

@@ -2,6 +2,7 @@ import {
ChangeDetectionStrategy,
Component,
inject,
OnInit,
signal,
} from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
@@ -66,7 +67,7 @@ import { NotificationsTableComponent } from './table.component'
i18nPipe,
],
})
export default class NotificationsComponent {
export default class NotificationsComponent implements OnInit {
private readonly router = inject(Router)
private readonly route = inject(ActivatedRoute)
@@ -74,16 +75,19 @@ export default class NotificationsComponent {
readonly api = inject(ApiService)
readonly errorService = inject(ErrorService)
readonly notifications = signal<ServerNotifications | undefined>(undefined)
readonly toast = this.route.queryParams.subscribe(params => {
this.router.navigate([], { relativeTo: this.route, queryParams: {} })
if (isEmptyObject(params)) {
this.getMore({})
}
})
open = false
ngOnInit() {
this.route.queryParams.subscribe(params => {
this.router.navigate([], { relativeTo: this.route, queryParams: {} })
if (isEmptyObject(params)) {
this.getMore({})
}
})
}
async getMore(params: RR.GetNotificationsReq) {
try {
this.notifications.set(undefined)

View File

@@ -31,7 +31,7 @@ import { i18nPipe } from '@start9labs/shared'
/>
</th>
<th [style.min-width.rem]="12">{{ 'Date' | i18n }}</th>
<th [style.min-width.rem]="12">{{ 'Title' | i18n }}</th>
<th [style.min-width.rem]="14">{{ 'Title' | i18n }}</th>
<th [style.min-width.rem]="8">{{ 'Service' | i18n }}</th>
<th>{{ 'Message' | i18n }}</th>
</tr>
@@ -71,9 +71,13 @@ import { i18nPipe } from '@start9labs/shared'
styles: `
@import '@taiga-ui/core/styles/taiga-ui-local';
:host-context(tui-root._mobile) input {
@include fullsize();
opacity: 0;
:host-context(tui-root._mobile) {
margin: 0 -1rem;
input {
@include fullsize();
opacity: 0;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,

View File

@@ -14,7 +14,7 @@ interface ActionItem {
template: `
<div tuiTitle>
<strong>{{ action.name }}</strong>
<div tuiSubtitle>{{ action.description }}</div>
<div tuiSubtitle [innerHTML]="action.description"></div>
@if (disabled) {
<div tuiSubtitle class="g-warning">{{ disabled }}</div>
}

View File

@@ -27,7 +27,7 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
/>
</tui-avatar>
<span tuiTitle>
{{ d.value.title }}
{{ d.value.title || d.key }}
@if (getError(d.key); as error) {
<span tuiSubtitle class="g-warning">{{ error | i18n }}</span>
} @else {

View File

@@ -6,7 +6,7 @@ import {
} from '@angular/core'
import { DialogService, i18nKey, i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiIcon } from '@taiga-ui/core'
import { TuiLineClamp, TuiTooltip } from '@taiga-ui/kit'
import { TuiTooltip } from '@taiga-ui/kit'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { StandardActionsService } from 'src/app/services/standard-actions.service'
import { getManifest } from 'src/app/utils/get-package-data'
@@ -15,12 +15,9 @@ import { getManifest } from 'src/app/utils/get-package-data'
standalone: true,
selector: 'service-error',
template: `
<header>{{ 'Error' | i18n }}</header>
<tui-line-clamp
[linesLimit]="2"
[content]="error?.message"
(overflownChange)="overflow = $event"
/>
<header>{{ 'Service Launch Error' | i18n }}</header>
<p class="error-message">{{ error?.message }}</p>
<p>{{ error?.debug }}</p>
<h4>
{{ 'Actions' | i18n }}
<tui-icon [tuiTooltip]="hint" />
@@ -34,7 +31,13 @@ import { getManifest } from 'src/app/utils/get-package-data'
</p>
<p>
{{
'"Uninstall service" is a dangerous action that will remove the service from StartOS and wipe all its data.'
'"Soft uninstall" will remove the service from StartOS but preserve its data.'
| i18n
}}
</p>
<p>
{{
'"Hard uninstall" is a dangerous action that will remove the service from StartOS and wipe all its data.'
| i18n
}}
</p>
@@ -43,8 +46,11 @@ import { getManifest } from 'src/app/utils/get-package-data'
<button tuiButton (click)="rebuild()">
{{ 'Rebuild container' | i18n }}
</button>
<button tuiButton appearance="negative" (click)="uninstall()">
{{ 'Uninstall service' | i18n }}
<button tuiButton appearance="warning" (click)="uninstall()">
{{ 'Soft uninstall' | i18n }}
</button>
<button tuiButton appearance="negative" (click)="uninstall(false)">
{{ 'Hard uninstall' | i18n }}
</button>
@if (overflow) {
<button tuiButton appearance="secondary-grayscale" (click)="show()">
@@ -55,23 +61,21 @@ import { getManifest } from 'src/app/utils/get-package-data'
`,
styles: `
:host {
grid-column: span 4;
grid-column: span 5;
}
header {
--tui-background-neutral-1: var(--tui-status-negative-pale);
}
tui-line-clamp {
pointer-events: none;
margin: 1rem 0;
.error-message {
font-size: 1.5rem;
color: var(--tui-status-negative);
margin-bottom: 0;
}
h4 {
display: flex;
align-items: center;
gap: 0.5rem;
font: var(--tui-font-text-m);
font-weight: bold;
color: var(--tui-text-secondary);
@@ -80,7 +84,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
`,
host: { class: 'g-card' },
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, TuiIcon, TuiTooltip, TuiLineClamp, i18nPipe],
imports: [TuiButton, TuiIcon, TuiTooltip, i18nPipe],
})
export class ServiceErrorComponent {
private readonly dialog = inject(DialogService)
@@ -99,8 +103,8 @@ export class ServiceErrorComponent {
this.service.rebuild(getManifest(this.pkg).id)
}
uninstall() {
this.service.uninstall(getManifest(this.pkg))
uninstall(soft = true) {
this.service.uninstall(getManifest(this.pkg), { force: true, soft })
}
show() {

View File

@@ -97,7 +97,7 @@ export class ServiceHealthCheckComponent {
case 'starting':
return this.i18n.transform('Starting')!
case 'success':
return `${this.i18n.transform('Success')}: ${this.healthCheck.message}`
return `${this.i18n.transform('Success')}: ${this.healthCheck.message || 'health check passing'}`
case 'loading':
case 'failure':
return this.healthCheck.message

View File

@@ -1,3 +1,4 @@
import { DOCUMENT } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
@@ -5,9 +6,8 @@ import {
Input,
} from '@angular/core'
import { RouterLink } from '@angular/router'
import { i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiButton, TuiIcon, TuiLink } from '@taiga-ui/core'
import { TuiButton, TuiIcon } from '@taiga-ui/core'
import { TuiBadge } from '@taiga-ui/kit'
import { ConfigService } from 'src/app/services/config.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
@@ -16,16 +16,11 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
selector: 'tr[serviceInterface]',
template: `
<td>
<a tuiLink [routerLink]="info.routerLink">
<strong>{{ info.name }}</strong>
</a>
<strong>{{ info.name }}</strong>
</td>
<td>
<tui-badge size="m" [appearance]="appearance">{{ info.type }}</tui-badge>
</td>
<td class="g-secondary" [style.grid-area]="'2 / span 4'">
{{ info.description }}
</td>
<td [style.text-align]="'center'">
@if (info.public) {
<tui-icon class="g-positive" icon="@tui.globe" />
@@ -33,73 +28,70 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
<tui-icon class="g-negative" icon="@tui.lock" />
}
</td>
<td [style.grid-area]="'span 2'">
<td class="g-secondary" [style.grid-area]="'2 / span 4'">
{{ info.description }}
</td>
<td>
@if (info.type === 'ui') {
<a
<button
tuiIconButton
appearance="action"
iconStart="@tui.external-link"
target="_blank"
rel="noreferrer"
size="s"
[style.border-radius.%]="100"
[attr.href]="href"
(click.stop)="(0)"
>
{{ 'Open' | i18n }}
</a>
appearance="flat-grayscale"
[disabled]="disabled"
(click)="openUI()"
></button>
}
<a
tuiIconButton
iconStart="@tui.settings"
appearance="flat-grayscale"
[routerLink]="info.routerLink"
></a>
</td>
`,
styles: `
@import '@taiga-ui/core/styles/taiga-ui-local';
:host {
cursor: pointer;
clip-path: inset(0 round var(--tui-radius-m));
@include transition(background);
}
[tuiLink] {
background: transparent;
}
@media ($tui-mouse) {
:host:hover {
background: var(--tui-background-neutral-1);
}
}
strong {
white-space: nowrap;
}
tui-badge {
text-transform: uppercase;
font-weight: bold;
}
tui-icon {
font-size: 1rem;
}
td:last-child {
grid-area: 3 / span 4;
white-space: nowrap;
text-align: right;
flex-direction: row-reverse;
justify-content: flex-end;
gap: 0.5rem;
}
:host-context(tui-root._mobile) {
display: grid;
grid-template-columns: repeat(3, min-content) 1fr 2rem;
grid-template-columns: repeat(3, min-content) 1fr;
align-items: center;
padding: 1rem 0.5rem;
gap: 0.5rem;
td {
display: flex;
padding: 0;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [TuiButton, TuiBadge, TuiLink, TuiIcon, RouterLink, i18nPipe],
imports: [TuiButton, TuiBadge, TuiIcon, RouterLink],
})
export class ServiceInterfaceComponent {
export class ServiceInterfaceItemComponent {
private readonly config = inject(ConfigService)
private readonly document = inject(DOCUMENT)
@Input({ required: true })
info!: T.ServiceInterface & {
@@ -116,17 +108,19 @@ export class ServiceInterfaceComponent {
get appearance(): string {
switch (this.info.type) {
case 'ui':
return 'primary'
return 'positive'
case 'api':
return 'accent'
return 'info'
case 'p2p':
return 'primary-grayscale'
return 'negative'
}
}
get href(): string | null {
return this.disabled
? null
: this.config.launchableAddress(this.info, this.pkg.hosts)
get href() {
return this.config.launchableAddress(this.info, this.pkg.hosts)
}
openUI() {
this.document.defaultView?.open(this.href, '_blank', 'noreferrer')
}
}

View File

@@ -5,13 +5,12 @@ import {
inject,
input,
} from '@angular/core'
import { RouterLink } from '@angular/router'
import { TuiTable } from '@taiga-ui/addon-table'
import { tuiDefaultSort } from '@taiga-ui/cdk'
import { ConfigService } from 'src/app/services/config.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getAddresses } from '../../../components/interfaces/interface.utils'
import { ServiceInterfaceComponent } from './interface.component'
import { ServiceInterfaceItemComponent } from './interface-item.component'
import { i18nPipe } from '@start9labs/shared'
@Component({
@@ -24,8 +23,8 @@ import { i18nPipe } from '@start9labs/shared'
<tr>
<th tuiTh>{{ 'Name' | i18n }}</th>
<th tuiTh>{{ 'Type' | i18n }}</th>
<th tuiTh [style.text-align]="'center'">{{ 'Hosting' | i18n }}</th>
<th tuiTh>{{ 'Description' | i18n }}</th>
<th tuiTh>{{ 'Hosting' | i18n }}</th>
<th tuiTh></th>
</tr>
</thead>
@@ -33,7 +32,6 @@ import { i18nPipe } from '@start9labs/shared'
@for (info of interfaces(); track $index) {
<tr
serviceInterface
[routerLink]="info.routerLink"
[info]="info"
[pkg]="pkg()"
[disabled]="disabled()"
@@ -44,12 +42,12 @@ import { i18nPipe } from '@start9labs/shared'
`,
styles: `
:host {
grid-column: span 4;
grid-column: span 6;
}
`,
host: { class: 'g-card' },
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [ServiceInterfaceComponent, TuiTable, RouterLink, i18nPipe],
imports: [ServiceInterfaceItemComponent, TuiTable, i18nPipe],
})
export class ServiceInterfacesComponent {
private readonly config = inject(ConfigService)

View File

@@ -45,6 +45,7 @@ import {
`
:host {
grid-column: span 2;
min-height: 12rem;
}
h3 {
@@ -77,6 +78,10 @@ import {
}
:host-context(tui-root._mobile) {
:host {
min-height: 0;
}
div {
display: grid;
grid-template-columns: 1fr max-content;

View File

@@ -5,56 +5,84 @@ import {
inject,
input,
} from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import {
DialogService,
ErrorService,
i18nPipe,
LoadingService,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiButton } from '@taiga-ui/core'
import { TuiAvatar } from '@taiga-ui/kit'
import { TuiAvatar, TuiFade } from '@taiga-ui/kit'
import { filter } from 'rxjs'
import { ActionService } from 'src/app/services/action.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { getManifest } from 'src/app/utils/get-package-data'
@Component({
standalone: true,
selector: 'tr[actionRequest]',
selector: 'tr[task]',
template: `
<td>
<td tuiFade>
<tui-avatar size="xs"><img [src]="pkg()?.icon" alt="" /></tui-avatar>
<span>{{ title() }}</span>
<span>{{ pkgTitle() }}</span>
</td>
<td>
@if (actionRequest().severity === 'critical') {
{{ pkg()?.actions?.[task().actionId]?.name }}
</td>
<td>
@if (task().severity === 'critical') {
<strong [style.color]="'var(--tui-status-warning)'">
{{ 'Required' | i18n }}
</strong>
} @else {
} @else if (task().severity === 'important') {
<strong [style.color]="'var(--tui-status-info)'">
{{ 'Recommended' | i18n }}
</strong>
} @else {
<strong>
{{ 'Optional' | i18n }}
</strong>
}
</td>
<td
[style.color]="'var(--tui-text-secondary)'"
[style.grid-area]="'2 / span 2'"
[style.grid-area]="'2 / span 4'"
>
{{ actionRequest().reason || ('No reason provided' | i18n) }}
{{ task().reason || ('No reason provided' | i18n) }}
</td>
<td>
<button tuiButton (click)="handle()">
{{ pkg()?.actions?.[actionRequest().actionId]?.name }}
</button>
@if (task().severity !== 'critical') {
<button
tuiIconButton
iconStart="@tui.trash"
appearance="flat-grayscale"
(click)="dismiss()"
></button>
}
<button
tuiIconButton
iconStart="@tui.play"
appearance="flat-grayscale"
(click)="handle()"
></button>
</td>
`,
styles: `
td:first-child {
white-space: nowrap;
max-width: 10rem;
max-width: 15rem;
overflow: hidden;
text-overflow: ellipsis;
}
td:last-child {
grid-area: 3 / span 4;
white-space: nowrap;
text-align: right;
grid-area: span 2;
flex-direction: row-reverse;
justify-content: flex-end;
gap: 0.5rem;
}
span {
@@ -64,32 +92,66 @@ import { getManifest } from 'src/app/utils/get-package-data'
:host-context(tui-root._mobile) {
display: grid;
grid-template-columns: min-content 1fr min-content;
align-items: center;
padding: 1rem 0.5rem;
padding: 1rem 0rem 1rem 0.5rem;
gap: 0.5rem;
td {
display: flex;
align-items: center;
padding: 0;
}
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, TuiAvatar, i18nPipe],
imports: [TuiButton, TuiAvatar, i18nPipe, TuiFade],
})
export class ServiceTaskComponent {
private readonly actionService = inject(ActionService)
private readonly dialog = inject(DialogService)
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
private readonly loader = inject(LoadingService)
readonly actionRequest = input.required<T.Task>()
readonly task = input.required<T.Task & { replayId: string }>()
readonly services = input.required<Record<string, PackageDataEntry>>()
readonly pkg = computed(() => this.services()[this.actionRequest().packageId])
readonly title = computed((pkg = this.pkg()) => pkg && getManifest(pkg).title)
readonly pkg = computed(() => this.services()[this.task().packageId])
readonly pkgTitle = computed(
(pkg = this.pkg()) => pkg && getManifest(pkg).title,
)
async dismiss() {
this.dialog
.openConfirm<boolean>({
label: 'Confirm',
size: 's',
data: {
content: 'Are you sure you want to dismiss this task?',
yes: 'Dismiss',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
.subscribe(async () => {
const loader = this.loader.open().subscribe()
try {
await this.api.clearTask({
packageId: this.task().packageId,
replayId: this.task().replayId,
})
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
})
}
async handle() {
const title = this.title()
const title = this.pkgTitle()
const pkg = this.pkg()
const metadata = pkg?.actions[this.actionRequest().actionId]
const metadata = pkg?.actions[this.task().actionId]
if (!title || !pkg || !metadata) {
return
@@ -97,16 +159,16 @@ export class ServiceTaskComponent {
this.actionService.present({
pkgInfo: {
id: this.actionRequest().packageId,
id: this.task().packageId,
title,
mainStatus: pkg.status.main,
icon: pkg.icon,
},
actionInfo: {
id: this.actionRequest().actionId,
id: this.task().actionId,
metadata,
},
requestInfo: this.actionRequest(),
requestInfo: this.task(),
})
}
}

View File

@@ -19,18 +19,19 @@ import { i18nPipe } from '@start9labs/shared'
<thead>
<tr>
<th tuiTh>{{ 'Service' | i18n }}</th>
<th tuiTh>{{ 'Type' | i18n }}</th>
<th tuiTh>{{ 'Action' }}</th>
<th tuiTh>{{ 'Severity' }}</th>
<th tuiTh>{{ 'Description' | i18n }}</th>
<th tuiTh></th>
</tr>
</thead>
<tbody>
@for (item of requests(); track $index) {
<tr [actionRequest]="item.task" [services]="services()"></tr>
@for (item of tasks(); track $index) {
<tr [task]="item.task" [services]="services()"></tr>
}
</tbody>
</table>
@if (!requests().length) {
@if (!tasks().length) {
<app-placeholder icon="@tui.list-checks">
{{ 'All tasks complete' | i18n }}
</app-placeholder>
@@ -50,8 +51,12 @@ export class ServiceTasksComponent {
readonly pkg = input.required<PackageDataEntry>()
readonly services = input.required<Record<string, PackageDataEntry>>()
readonly requests = computed(() =>
Object.values(this.pkg().tasks)
readonly tasks = computed(() =>
Object.entries(this.pkg().tasks)
.map(([replayId, entry]) => ({
...entry,
task: { ...entry.task, replayId },
}))
.filter(
t =>
this.services()[t.task.packageId]?.actions[t.task.actionId] &&

View File

@@ -0,0 +1,90 @@
import { AsyncPipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
import { i18nPipe } from '@start9labs/shared'
import { map, timer } from 'rxjs'
import { distinctUntilChanged } from 'rxjs/operators'
@Component({
selector: 'service-uptime',
template: `
<header>{{ 'Uptime' | i18n }}</header>
<section>
@if (uptime$ | async; as time) {
<div>
<label>{{ time.days }}</label>
{{ 'Days' | i18n }}
</div>
<div>
<label>{{ time.hours }}</label>
{{ 'Hours' | i18n }}
</div>
<div>
<label>{{ time.minutes }}</label>
{{ 'Minutes' | i18n }}
</div>
<div>
<label>{{ time.seconds }}</label>
{{ 'Seconds' | i18n }}
</div>
}
</section>
`,
styles: [
`
:host {
grid-column: span 4;
}
h3 {
font: var(--tui-font-heading-4);
font-weight: normal;
margin: 0;
text-align: center;
}
section {
height: 100%;
max-width: 100%;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1rem;
place-content: center;
margin: auto;
padding: 1rem 0;
text-align: center;
text-transform: uppercase;
color: var(--tui-text-secondary);
font: var(--tui-font-text-ui-xs);
}
label {
display: block;
font-size: min(6vw, 2.5rem);
margin: 1rem 0;
color: var(--tui-text-primary);
}
`,
],
host: { class: 'g-card' },
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [i18nPipe, AsyncPipe],
})
export class ServiceUptimeComponent {
protected readonly uptime$ = timer(0, 1000).pipe(
map(() =>
this.started()
? Math.max(Date.now() - new Date(this.started()).getTime(), 0)
: 0,
),
distinctUntilChanged(),
map(delta => ({
seconds: Math.floor(delta / 1000) % 60,
minutes: Math.floor(delta / (1000 * 60)) % 60,
hours: Math.floor(delta / (1000 * 60 * 60)) % 24,
days: Math.floor(delta / (1000 * 60 * 60 * 24)),
})),
)
readonly started = input('')
}

View File

@@ -14,7 +14,7 @@ import { DepErrorService } from 'src/app/services/dep-error.service'
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.component'
import { UILaunchComponent } from './ui-launch.component'
import { i18nPipe } from '@start9labs/shared'
const RUNNING = ['running', 'starting', 'restarting']
@@ -23,6 +23,7 @@ const RUNNING = ['running', 'starting', 'restarting']
standalone: true,
selector: 'fieldset[appControls]',
template: `
<app-ui-launch [pkg]="pkg()" />
@if (running()) {
<button
tuiIconButton
@@ -42,8 +43,6 @@ const RUNNING = ['running', 'starting', 'restarting']
{{ 'Start' | i18n }}
</button>
}
<app-ui-launch [pkg]="pkg()" />
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, UILaunchComponent, TuiLet, AsyncPipe, i18nPipe],
@@ -53,6 +52,7 @@ const RUNNING = ['running', 'starting', 'restarting']
padding: 0;
border: none;
cursor: default;
text-align: right;
}
:host-context(tui-root._mobile) {

View File

@@ -35,9 +35,7 @@ import { i18nPipe } from '@start9labs/shared'
<th tuiTh [requiredSort]="true" [sorter]="status">
{{ 'Status' | i18n }}
</th>
<th [style.width.rem]="8" [style.text-indent.rem]="1.5">
{{ 'Controls' | i18n }}
</th>
<th [style.width.rem]="8" [style.text-indent.rem]="1.5"></th>
</tr>
</thead>
<tbody>

View File

@@ -57,7 +57,8 @@ export class StatusComponent {
private readonly i18n = inject(i18nPipe)
get healthy(): boolean {
return !this.hasDepErrors && this.getStatus(this.pkg).health !== 'failure'
const { primary, health } = this.getStatus(this.pkg)
return !this.hasDepErrors && primary !== 'error' && health !== 'failure'
}
get loading(): boolean {
@@ -66,7 +67,7 @@ export class StatusComponent {
@tuiPure
getStatus(pkg: PackageDataEntry) {
return renderPkgStatus(pkg, {})
return renderPkgStatus(pkg)
}
get status(): i18nKey {
@@ -95,6 +96,8 @@ export class StatusComponent {
return 'Removing'
case 'restoring':
return 'Restoring'
case 'error':
return 'Error'
default:
return 'Unknown'
}
@@ -120,6 +123,8 @@ export class StatusComponent {
return 'var(--tui-status-positive)'
case 'actionRequired':
return 'var(--tui-status-warning)'
case 'error':
return 'var(--tui-status-negative)'
case 'installing':
case 'updating':
case 'stopping':

View File

@@ -1,3 +1,4 @@
import { DOCUMENT } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
@@ -23,7 +24,7 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
[disabled]="!isRunning"
[tuiDropdown]="content"
>
{{ 'Launch UI' | i18n }}
{{ 'Open' | i18n }}
</button>
<ng-template #content>
<tui-data-list>
@@ -39,16 +40,15 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
}
</tui-data-list>
</ng-template>
} @else {
<a
} @else if (interfaces[0]) {
<button
tuiIconButton
iconStart="@tui.external-link"
target="_blank"
rel="noreferrer"
[attr.href]="getHref(first)"
[disabled]="!isRunning"
(click)="openUI(interfaces[0])"
>
{{ first?.name }}
</a>
{{ interfaces[0].name }}
</button>
}
`,
styles: `
@@ -61,6 +61,7 @@ import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
})
export class UILaunchComponent {
private readonly config = inject(ConfigService)
private readonly document = inject(DOCUMENT)
@Input()
pkg!: PackageDataEntry
@@ -73,10 +74,6 @@ export class UILaunchComponent {
return this.pkg.status.main === 'running'
}
get first(): T.ServiceInterface | undefined {
return this.interfaces[0]
}
@tuiPure
getInterfaces(pkg?: PackageDataEntry): T.ServiceInterface[] {
return pkg
@@ -89,9 +86,11 @@ export class UILaunchComponent {
: []
}
getHref(ui?: T.ServiceInterface): string | null {
return ui && this.isRunning
? this.config.launchableAddress(ui, this.pkg.hosts)
: null
getHref(ui: T.ServiceInterface): string {
return this.config.launchableAddress(ui, this.pkg.hosts)
}
openUI(ui: T.ServiceInterface) {
this.document.defaultView?.open(this.getHref(ui), '_blank', 'noreferrer')
}
}

View File

@@ -21,7 +21,9 @@ import { i18nPipe } from '@start9labs/shared'
standalone: true,
selector: 'app-action-success-single',
template: `
<p class="qr"><ng-container *ngTemplateOutlet="qr" /></p>
@if (single.qr) {
<p class="qr"><ng-container *ngTemplateOutlet="qr" /></p>
}
<tui-input
[readOnly]="true"
[ngModel]="single.value"

View File

@@ -8,9 +8,11 @@ import {
import { toSignal } from '@angular/core/rxjs-interop'
import { RouterLink } from '@angular/router'
import { getPkgId, i18nPipe } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiItem } from '@taiga-ui/cdk'
import { TuiButton, TuiLink } from '@taiga-ui/core'
import { TuiBreadcrumbs } from '@taiga-ui/kit'
import { TuiBadge, TuiBreadcrumbs } from '@taiga-ui/kit'
import { TuiHeader } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
import { getAddresses } from 'src/app/routes/portal/components/interfaces/interface.utils'
@@ -26,21 +28,34 @@ import { TitleDirective } from 'src/app/services/title.service'
{{ 'Back' | i18n }}
</a>
{{ interface()?.name }}
<interface-status [public]="!!interface()?.public" />
<interface-status
[style.margin-left.rem]="0.5"
[public]="!!interface()?.public"
/>
</ng-container>
<tui-breadcrumbs size="l" [style.margin-block-end.rem]="1">
<tui-breadcrumbs size="l">
<a *tuiItem tuiLink appearance="action-grayscale" routerLink="../..">
{{ 'Dashboard' | i18n }}
</a>
<span *tuiItem class="g-primary">
{{ interface()?.name }}
<interface-status [public]="!!interface()?.public" />
</span>
<span *tuiItem class="g-primary">{{ interface()?.name }}</span>
</tui-breadcrumbs>
@if (interface(); as serviceInterface) {
@if (interface(); as value) {
<header tuiHeader [style.margin-bottom.rem]="1">
<hgroup>
<h3>
{{ value.name }}
<tui-badge size="l" [appearance]="getAppearance(value.type)">
{{ value.type }}
</tui-badge>
<interface-status [public]="value.public" />
</h3>
<p tuiSubtitle>{{ value.description }}</p>
</hgroup>
</header>
<app-interface
[packageId]="pkgId"
[serviceInterface]="serviceInterface"
[value]="value"
[isRunning]="isRunning()"
/>
}
`,
@@ -48,6 +63,19 @@ import { TitleDirective } from 'src/app/services/title.service'
:host-context(tui-root._mobile) tui-breadcrumbs {
display: none;
}
h3 {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 1rem 0 0.5rem 0;
font-size: 2.4rem;
tui-badge {
text-transform: uppercase;
font-weight: bold;
}
}
`,
host: { class: 'g-subpage' },
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -62,6 +90,8 @@ import { TitleDirective } from 'src/app/services/title.service'
TuiLink,
InterfaceStatusComponent,
i18nPipe,
TuiBadge,
TuiHeader,
],
})
export default class ServiceInterfaceRoute {
@@ -74,6 +104,10 @@ export default class ServiceInterfaceRoute {
inject<PatchDB<DataModel>>(PatchDB).watch$('packageData', this.pkgId),
)
readonly isRunning = computed(() => {
return this.pkg()?.status.main === 'running'
})
readonly interface = computed(() => {
const pkg = this.pkg()
const id = this.interfaceId()
@@ -99,4 +133,15 @@ export default class ServiceInterfaceRoute {
addresses: getAddresses(item, host, this.config),
}
})
getAppearance(type: T.ServiceInterfaceType = 'ui'): string {
switch (type) {
case 'ui':
return 'positive'
case 'api':
return 'info'
case 'p2p':
return 'negative'
}
}
}

View File

@@ -13,9 +13,21 @@ import { TuiCell } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { distinctUntilChanged, filter, map, switchMap, tap } from 'rxjs'
import { DataModel } from 'src/app/services/patch-db/data-model'
import {
PrimaryStatus,
renderPkgStatus,
} from 'src/app/services/pkg-status-rendering.service'
import { TitleDirective } from 'src/app/services/title.service'
import { getManifest } from 'src/app/utils/get-package-data'
const INACTIVE: PrimaryStatus[] = [
'installing',
'updating',
'removing',
'restoring',
'backingUp',
]
@Component({
template: `
@if (service()) {
@@ -34,7 +46,7 @@ import { getManifest } from 'src/app/utils/get-package-data'
<span tuiSubtitle>{{ manifest()?.version }}</span>
</span>
</header>
<nav>
<nav [attr.inert]="isInactive() ? '' : null">
@for (item of nav; track $index) {
<a
tuiCell
@@ -76,6 +88,10 @@ import { getManifest } from 'src/app/utils/get-package-data'
margin: 0 -0.5rem;
}
nav[inert] a:not(:first-child) {
opacity: var(--tui-disabled-opacity);
}
a a {
display: none;
}
@@ -178,4 +194,9 @@ export class ServiceOutletComponent {
protected readonly manifest = computed(
(pkg = this.service()) => pkg && getManifest(pkg),
)
protected readonly isInactive = computed(
(pkg = this.service()) =>
!pkg || INACTIVE.includes(renderPkgStatus(pkg).primary),
)
}

View File

@@ -7,11 +7,13 @@ import {
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { ActivatedRoute } from '@angular/router'
import { isEmptyObject } from '@start9labs/shared'
import { WaIntersectionObserver } from '@ng-web-apis/intersection-observer'
import { i18nPipe, isEmptyObject } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { TuiElement } from '@taiga-ui/cdk'
import { TuiButton } from '@taiga-ui/core'
import { PatchDB } from 'patch-db-client'
import { map, of } from 'rxjs'
import { UptimeComponent } from 'src/app/routes/portal/components/uptime.component'
import { ConnectionService } from 'src/app/services/connection.service'
import { DepErrorService } from 'src/app/services/dep-error.service'
import {
@@ -27,14 +29,14 @@ import { ServiceHealthChecksComponent } from '../components/health-checks.compon
import { ServiceInterfacesComponent } from '../components/interfaces.component'
import { ServiceInstallProgressComponent } from '../components/progress.component'
import { ServiceStatusComponent } from '../components/status.component'
import { ServiceUptimeComponent } from '../components/uptime.component'
@Component({
template: `
@if (pkg(); as pkg) {
@if (pkg.status.main === 'error') {
<service-error [pkg]="pkg" />
}
@if (installing()) {
} @else if (installing()) {
<service-install-progress [pkg]="pkg" />
} @else if (installed()) {
<service-status
@@ -42,16 +44,13 @@ import { ServiceStatusComponent } from '../components/status.component'
[installingInfo]="pkg.stateInfo.installingInfo"
[status]="status()"
>
@if ($any(pkg.status)?.started; as started) {
<p class="g-secondary" [appUptime]="started"></p>
}
@if (connected()) {
<service-controls [pkg]="pkg" [status]="status()" />
}
</service-status>
@if (status() !== 'backingUp') {
<service-uptime [started]="$any(pkg.status)?.started" />
<service-interfaces [pkg]="pkg" [disabled]="status() !== 'running'" />
@if (errors() | async; as errors) {
@@ -63,7 +62,30 @@ import { ServiceStatusComponent } from '../components/status.component'
}
<service-health-checks [checks]="health()" />
<service-tasks [pkg]="pkg" [services]="services() || {}" />
<service-tasks
#tasks="elementRef"
tuiElement
waIntersectionObserver
waIntersectionThreshold="0.5"
(waIntersectionObservee)="scrolled = $event.at(-1)?.isIntersecting"
[pkg]="pkg"
[services]="services() || {}"
/>
<button
tuiIconButton
iconStart="@tui.arrow-down"
tabindex="-1"
class="arrow"
[class.arrow_hidden]="scrolled"
(click)="
tasks.nativeElement.scrollIntoView({
block: 'end',
behavior: 'smooth',
})
"
>
{{ 'Tasks' | i18n }}
</button>
}
} @else if (removing()) {
<service-status
@@ -74,6 +96,14 @@ import { ServiceStatusComponent } from '../components/status.component'
}
`,
styles: `
@use '@taiga-ui/core/styles/taiga-ui-local' as taiga;
@keyframes bounce {
to {
transform: translateY(-1rem);
}
}
:host {
display: grid;
grid-template-columns: repeat(6, 1fr);
@@ -86,6 +116,23 @@ import { ServiceStatusComponent } from '../components/status.component'
text-transform: uppercase;
}
.arrow {
@include taiga.transition(opacity);
position: sticky;
bottom: 1rem;
border-radius: 100%;
place-self: center;
grid-area: auto / span 6;
box-shadow: inset 0 0 0 2rem var(--tui-status-warning);
animation: bounce 1s infinite alternate;
&_hidden,
:host:has(::ng-deep service-tasks app-placeholder) & {
opacity: 0;
pointer-events: none;
}
}
:host-context(tui-root._mobile) {
grid-template-columns: 1fr;
@@ -99,6 +146,10 @@ import { ServiceStatusComponent } from '../components/status.component'
standalone: true,
imports: [
CommonModule,
TuiElement,
TuiButton,
WaIntersectionObserver,
i18nPipe,
ServiceInstallProgressComponent,
ServiceStatusComponent,
ServiceControlsComponent,
@@ -107,13 +158,15 @@ import { ServiceStatusComponent } from '../components/status.component'
ServiceDependenciesComponent,
ServiceErrorComponent,
ServiceTasksComponent,
UptimeComponent,
ServiceUptimeComponent,
],
})
export class ServiceRoute {
private readonly errorService = inject(DepErrorService)
protected readonly connected = toSignal(inject(ConnectionService))
protected scrolled?: boolean
protected readonly id = toSignal(
inject(ActivatedRoute).paramMap.pipe(map(params => params.get('pkgId'))),
)

View File

@@ -13,6 +13,7 @@ import { TuiCell, TuiHeader } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs'
import { FormComponent } from 'src/app/routes/portal/components/form.component'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { FormDialogService } from 'src/app/services/form-dialog.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
@@ -89,6 +90,10 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
{{ 'Edit' | i18n }}
</button>
</div>
} @empty {
<app-placeholder icon="@tui.shield-question">
{{ 'No saved providers' | i18n }}
</app-placeholder>
}
} @else {
<tui-loader [style.height.rem]="5" />
@@ -113,6 +118,7 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
TitleDirective,
i18nPipe,
DocsLinkDirective,
PlaceholderComponent,
],
})
export default class SystemAcmeComponent {

View File

@@ -75,7 +75,7 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
<button
tuiButton
size="l"
[disabled]="form.invalid"
[disabled]="form.invalid || form.pristine"
(click)="save(form.value)"
>
{{ 'Save' | i18n }}
@@ -98,7 +98,6 @@ import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
<footer>
<button
tuiButton
appearance="secondary"
size="l"
[disabled]="!testAddress || form.invalid"
(click)="sendTestEmail(form.value)"
@@ -188,11 +187,14 @@ export default class SystemEmailComponent {
async sendTestEmail(value: typeof inputSpec.constants.customSmtp._TYPE) {
const loader = this.loader.open('Sending email').subscribe()
const success =
`${this.i18n.transform('A test email has been sent to')} ${this.testAddress}.<br /><br /><b>${this.i18n.transform('Check your spam folder and mark as not spam.')}</b>` as i18nKey
`${this.i18n.transform('A test email has been sent to')} ${this.testAddress}. <i>${this.i18n.transform('Check your spam folder and mark as not spam.')}</i>` as i18nKey
try {
await this.api.testSmtp({ to: this.testAddress, ...value })
this.dialog.openAlert(success, { label: 'Success' }).subscribe()
this.dialog
.openAlert(success, { label: 'Success', size: 's' })
.subscribe()
this.testAddress = ''
} catch (e: any) {
this.errorService.handleError(e)
} finally {

View File

@@ -7,6 +7,7 @@ import {
} from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { FormsModule } from '@angular/forms'
import { Title } from '@angular/platform-browser'
import { RouterLink } from '@angular/router'
import {
DialogService,
@@ -30,6 +31,7 @@ import {
TuiTitle,
} from '@taiga-ui/core'
import {
TuiBadge,
TuiButtonLoading,
TuiButtonSelect,
TuiDataListWrapper,
@@ -92,7 +94,9 @@ import { SystemWipeComponent } from './wipe.component'
<tui-icon icon="@tui.app-window" />
<span tuiTitle>
<strong>{{ 'Browser Tab Title' | i18n }}</strong>
<span tuiSubtitle>{{ name() }}</span>
<span tuiSubtitle>
{{ 'Customize the name appearing in your browser tab' | i18n }}
</span>
</span>
<button tuiButton (click)="onTitle()">{{ 'Change' | i18n }}</button>
</div>
@@ -134,6 +138,43 @@ import { SystemWipeComponent } from './wipe.component'
{{ 'Download' | i18n }}
</button>
</div>
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.monitor" />
<span tuiTitle>
<strong>
{{ 'Kiosk Mode' | i18n }}
<tui-badge
size="m"
[appearance]="
server.kiosk ? 'primary-success' : 'primary-destructive'
"
>
{{ server.kiosk ? ('Enabled' | i18n) : ('Disabled' | i18n) }}
</tui-badge>
</strong>
<span tuiSubtitle>
{{
server.kiosk === true
? ('Disable Kiosk Mode unless you need to attach a monitor'
| i18n)
: server.kiosk === false
? ('Enable Kiosk Mode if you need to attach a monitor' | i18n)
: ('Kiosk Mode is unavailable on this device' | i18n)
}}
</span>
</span>
@if (server.kiosk !== null) {
<button
tuiButton
[appearance]="
server.kiosk ? 'primary-destructive' : 'primary-success'
"
(click)="tryToggleKiosk()"
>
{{ server.kiosk ? ('Disable' | i18n) : ('Enable' | i18n) }}
</button>
}
</div>
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.circle-power" (click)="count = count + 1" />
<span tuiTitle>
@@ -190,11 +231,6 @@ import { SystemWipeComponent } from './wipe.component'
[tuiCell] {
background: var(--tui-background-neutral-1);
}
[tuiSubtitle],
tui-data-list-wrapper ::ng-deep [tuiOption] {
text-transform: capitalize;
}
`,
providers: [tuiCellOptionsProvider({ height: 'spacious' })],
changeDetection: ChangeDetectionStrategy.OnPush,
@@ -217,9 +253,11 @@ import { SystemWipeComponent } from './wipe.component'
TuiTextfield,
FormsModule,
SnekDirective,
TuiBadge,
],
})
export default class SystemGeneralComponent {
private readonly title = inject(Title)
private readonly dialogs = inject(TuiResponsiveDialogService)
private readonly loader = inject(LoadingService)
private readonly errorService = inject(ErrorService)
@@ -276,9 +314,11 @@ export default class SystemGeneralComponent {
})
.subscribe(async name => {
const loader = this.loader.open('Saving').subscribe()
const title = `${name || 'StartOS'}${this.i18n.transform('System')}`
try {
await this.api.setDbValue(['name'], name || null)
this.title.setTitle(title)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
@@ -310,6 +350,28 @@ export default class SystemGeneralComponent {
this.document.getElementById('download-ca')?.click()
}
async tryToggleKiosk() {
if (
this.server()?.kiosk &&
['localhost', '127.0.0.1'].includes(this.document.location.hostname)
) {
return this.dialog
.openConfirm({
label: 'Warning',
data: {
content:
'You are currently using a kiosk. Disabling Kiosk Mode will result in the kiosk disconnecting.',
yes: 'Disable',
no: 'Cancel',
},
})
.pipe(filter(Boolean))
.subscribe(async () => this.toggleKiosk())
}
this.toggleKiosk()
}
async onRepair() {
this.dialog
.openConfirm({
@@ -332,6 +394,22 @@ export default class SystemGeneralComponent {
})
}
private async toggleKiosk() {
const kiosk = this.server()?.kiosk
const loader = this.loader
.open(kiosk ? 'Disabling' : 'Enabling')
.subscribe()
try {
await this.api.toggleKiosk(!kiosk)
} catch (e: any) {
this.errorService.handleError(e)
} finally {
loader.unsubscribe()
}
}
private async resetTor(wipeState: boolean) {
const loader = this.loader.open('Resetting Tor').subscribe()

View File

@@ -24,7 +24,7 @@ import { firstValueFrom } from 'rxjs'
template: `
<h2 style="margin-top: 0">StartOS {{ versions[0]?.version }}</h2>
<h3 style="color: var(--tui-text-secondary); font-weight: normal">
{{ 'Release Notes' | i18n }}
{{ 'Release notes' | i18n }}
</h3>
<tui-scrollbar style="margin-bottom: 24px; max-height: 50vh;">
@for (v of versions; track $index) {

View File

@@ -1,13 +1,18 @@
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
viewChild,
} 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 { CommonModule } from '@angular/common'
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
import { TuiHeader } from '@taiga-ui/layout'
import { from, map, merge, Observable, Subject } from 'rxjs'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { Session } 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'
import { SessionsTableComponent } from './table.component'
@@ -36,18 +41,16 @@ import { SessionsTableComponent } from './table.component'
<section *tuiLet="other$ | async as others" class="g-card">
<header>
{{ 'Other sessions' | i18n }}
@if (table.selected$ | async; as selected) {
<button
tuiButton
size="xs"
appearance="primary-destructive"
[style.margin-inline-start]="'auto'"
[disabled]="!selected.length"
(click)="terminate(selected, others || [])"
>
{{ 'Terminate selected' | i18n }}
</button>
}
<button
tuiButton
size="xs"
appearance="primary-destructive"
[style.margin-inline-start]="'auto'"
[disabled]="!(sessions()?.selected$ | async)?.length"
(click)="terminate(others || [])"
>
{{ 'Terminate selected' | i18n }}
</button>
</header>
<div #table [sessions]="others"></div>
</section>
@@ -73,6 +76,8 @@ export default class SystemSessionsComponent {
private readonly sessions$ = from(this.api.getSessions({}))
private readonly local$ = new Subject<readonly SessionWithId[]>()
protected sessions = viewChild<SessionsTableComponent<SessionWithId>>('table')
readonly current$ = this.sessions$.pipe(
map(s => {
const current = s.sessions[s.current]
@@ -99,16 +104,14 @@ export default class SystemSessionsComponent {
),
)
async terminate(
sessions: readonly SessionWithId[],
all: readonly SessionWithId[],
) {
const ids = sessions.map(s => s.id)
async terminate(all: readonly SessionWithId[]) {
const ids = this.sessions()?.selected$.value.map(s => s.id) || []
const loader = this.loader.open('Terminating sessions').subscribe()
try {
await this.api.killSessions({ ids })
this.local$.next(all.filter(s => !ids.includes(s.id)))
this.sessions()?.selected$.next([])
} catch (e: any) {
this.errorService.handleError(e)
} finally {

View File

@@ -31,7 +31,7 @@ import { i18nPipe } from '@start9labs/shared'
(ngModelChange)="onToggle(session)"
/>
}
<span tuiFade class="agent">{{ session.userAgent }}</span>
<span tuiFade class="agent">{{ session.userAgent || '-' }}</span>
</label>
</td>
@if (session.userAgent | platformInfo; as platform) {

View File

@@ -25,11 +25,11 @@ import { TitleDirective } from 'src/app/services/title.service'
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">
{{ 'Back' | i18n }}
</a>
StartOS UI
<interface-status [public]="public()" />
{{ iface.name }}
<interface-status [style.margin-left.rem]="0.5" [public]="public()" />
</ng-container>
<header tuiHeader>
<hgroup tuiTitle>
<hgroup>
<h3>
{{ iface.name }}
<interface-status [public]="public()" />
@@ -38,9 +38,24 @@ import { TitleDirective } from 'src/app/services/title.service'
</hgroup>
</header>
@if (ui(); as ui) {
<app-interface [serviceInterface]="ui" />
<app-interface [value]="ui" [isRunning]="true" />
}
`,
styles: `
h3 {
display: flex;
align-items: center;
gap: 0.5rem;
margin: 1rem 0 0.5rem 0;
font-size: 2.4rem;
tui-badge {
text-transform: uppercase;
font-weight: bold;
}
}
`,
host: { class: 'g-subpage' },
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
imports: [
@@ -49,7 +64,6 @@ import { TitleDirective } from 'src/app/services/title.service'
TuiButton,
TitleDirective,
TuiHeader,
TuiTitle,
InterfaceStatusComponent,
i18nPipe,
],

View File

@@ -149,7 +149,7 @@ export default class SystemWifiComponent {
async onToggle(enable: boolean) {
const loader = this.loader
.open(enable ? 'Enabling WiFi' : 'Disabling WiFi')
.open(enable ? 'Enabling' : 'Disabling')
.subscribe()
try {

View File

@@ -11,6 +11,7 @@ import { BadgeService } from 'src/app/services/badge.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service'
import { SYSTEM_MENU } from './system.const'
import { map } from 'rxjs'
@Component({
template: `
@@ -128,10 +129,7 @@ import { SYSTEM_MENU } from './system.const'
export class SystemComponent {
readonly menu = SYSTEM_MENU
readonly badge = toSignal(inject(BadgeService).getCount('/portal/system'))
readonly wifiEnabled$ = inject<PatchDB<DataModel>>(PatchDB).watch$(
'serverInfo',
'network',
'wifi',
'enabled',
)
readonly wifiEnabled$ = inject<PatchDB<DataModel>>(PatchDB)
.watch$('serverInfo', 'network', 'wifi')
.pipe(map(wifi => !!wifi.interface && wifi.enabled))
}

View File

@@ -7,11 +7,6 @@ export const SYSTEM_MENU = [
item: 'General',
link: 'general',
},
{
icon: '@tui.mail',
item: 'Email',
link: 'email',
},
],
[
{
@@ -36,6 +31,11 @@ export const SYSTEM_MENU = [
item: 'ACME',
link: 'acme',
},
{
icon: '@tui.mail',
item: 'Email',
link: 'email',
},
{
icon: '@tui.wifi',
item: 'WiFi',

View File

@@ -6,6 +6,7 @@ import {
Routes,
} from '@angular/router'
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
import { titleResolver } from 'src/app/utils/title-resolver'
import { SystemComponent } from './system.component'
export default [
@@ -21,40 +22,49 @@ export default [
children: [
{
path: 'general',
title: titleResolver,
loadComponent: () => import('./routes/general/general.component'),
},
{
path: 'email',
title: titleResolver,
loadComponent: () => import('./routes/email/email.component'),
},
{
path: 'backup',
title: titleResolver,
loadComponent: () => import('./routes/backups/backups.component'),
data: { type: 'create' },
},
{
path: 'restore',
title: titleResolver,
loadComponent: () => import('./routes/backups/backups.component'),
data: { type: 'restore' },
},
{
path: 'interfaces',
loadComponent: () => import('./routes/interfaces/interfaces.component'),
title: titleResolver,
loadComponent: () => import('./routes/startos-ui/startos-ui.component'),
},
{
path: 'acme',
title: titleResolver,
loadComponent: () => import('./routes/acme/acme.component'),
},
{
path: 'wifi',
title: titleResolver,
loadComponent: () => import('./routes/wifi/wifi.component'),
},
{
path: 'sessions',
title: titleResolver,
loadComponent: () => import('./routes/sessions/sessions.component'),
},
{
path: 'password',
title: titleResolver,
loadComponent: () => import('./routes/password/password.component'),
},
// {

View File

@@ -212,6 +212,11 @@ import UpdatesComponent from './updates.component'
.mobile {
display: flex;
gap: 0.25rem;
[tuiSubtitle] {
color: var(--tui-text-secondary);
}
}
}
`,

View File

@@ -21,6 +21,7 @@ import {
import { TuiCell } from '@taiga-ui/layout'
import { PatchDB } from 'patch-db-client'
import { combineLatest, map, tap } from 'rxjs'
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
import { TableComponent } from 'src/app/routes/portal/components/table.component'
import { MarketplaceService } from 'src/app/services/marketplace.service'
import {
@@ -112,7 +113,9 @@ interface UpdatesData {
} @empty {
<tr>
<td colspan="5">
{{ 'All services are up to date!' | i18n }}
<app-placeholder icon="@tui.circle-check">
{{ 'All services are up to date!' | i18n }}
</app-placeholder>
</td>
</tr>
}
@@ -161,6 +164,11 @@ interface UpdatesData {
clip-path: inset(0.5rem round var(--tui-radius-s));
}
.g-subpage,
.g-card {
overflow: auto;
}
:host-context(tui-root._mobile) {
aside {
width: 100%;
@@ -214,6 +222,7 @@ interface UpdatesData {
TitleDirective,
TableComponent,
i18nPipe,
PlaceholderComponent,
],
})
export default class UpdatesComponent {
@@ -224,7 +233,7 @@ export default class UpdatesComponent {
readonly data = toSignal<UpdatesData>(
combineLatest({
hosts: this.marketplaceService.filteredRegistries$.pipe(
hosts: this.marketplaceService.registries$.pipe(
tap(
([registry]) =>
!this.isMobile && registry && this.current.set(registry),

View File

@@ -27,7 +27,7 @@ const routes: Routes = [
loadChildren: () => import('./routes/portal/portal.routes'),
},
{
path: '',
path: '**',
redirectTo: 'portal',
pathMatch: 'full',
},

View File

@@ -18,7 +18,7 @@ const mockMerkleArchiveCommitment: T.MerkleArchiveCommitment = {
const mockDescription = {
short: 'Lorem ipsum dolor sit amet',
long: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
long: 'Lorem ipsum dolor sit amet, <p>consectetur adipiscing elit</p>, sed do eiusmod <i>tempor</i> incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.',
}
export namespace Mock {
@@ -632,55 +632,6 @@ export namespace Mock {
},
},
},
'btc-rpc-proxy': {
'=0.3.2.6:0': {
best: {
'0.3.2.6:0': {
title: 'Bitcoin Proxy',
description: mockDescription,
hardwareRequirements: { arch: null, device: [], ram: null },
license: 'mit',
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',
marketingSite: '',
releaseNotes: 'Upstream release and minor fixes.',
osVersion: '0.3.6',
gitHash: 'fakehash',
icon: PROXY_ICON,
sourceVersion: null,
dependencyMetadata: {
bitcoind: {
title: 'Bitcoin Core',
icon: BTC_ICON,
description: 'Used for RPC requests',
optional: false,
},
},
donationUrl: null,
alerts: {
install: 'test',
uninstall: 'test',
start: 'test',
stop: 'test',
restore: 'test',
},
s9pk: {
url: 'https://github.com/Start9Labs/btc-rpc-proxy-startos/releases/download/v0.3.2.7.1/btc-rpc-proxy.s9pk',
commitment: mockMerkleArchiveCommitment,
signatures: {},
publishedAt: Date.now().toString(),
},
},
},
categories: ['bitcoin'],
otherVersions: {
'0.3.2.7:0': {
releaseNotes: 'Upstream release and minor fixes.',
},
},
},
},
}
export const RegistryPackages: GetPackagesRes = {
@@ -857,11 +808,7 @@ export namespace Mock {
},
},
categories: ['bitcoin'],
otherVersions: {
'0.3.2.6:0': {
releaseNotes: 'Upstream release and minor fixes.',
},
},
otherVersions: {},
},
}
@@ -891,7 +838,7 @@ export namespace Mock {
id: 2,
packageId: null,
createdAt: '2019-12-26T14:20:30.872Z',
code: 2,
code: 0,
level: 'warning',
title: 'SSH Key Added',
message: 'A new SSH key was added. If you did not do this, shit is bad.',
@@ -902,7 +849,7 @@ export namespace Mock {
id: 3,
packageId: null,
createdAt: '2019-12-26T14:20:30.872Z',
code: 3,
code: 0,
level: 'info',
title: 'SSH Key Removed',
message: 'A SSH key was removed.',
@@ -913,7 +860,7 @@ export namespace Mock {
id: 4,
packageId: 'bitcoind',
createdAt: '2019-12-26T14:20:30.872Z',
code: 4,
code: 0,
level: 'error',
title: 'Service Crashed',
message: new Array(3)
@@ -1339,7 +1286,7 @@ export namespace Mock {
result: {
type: 'single',
copyable: true,
qr: true,
qr: false,
masked: true,
value: 'iwejdoiewdhbew',
},

View File

@@ -335,6 +335,12 @@ export namespace RR {
} // package.action.run
export type ActionRes = (T.ActionResult & { version: '1' }) | null
export type ClearTaskReq = {
packageId: string
replayId: string
} // package.action.clear-task
export type ClearTaskRes = null
export type RestorePackagesReq = {
// package.backup.restore
ids: string[]
@@ -356,7 +362,11 @@ export namespace RR {
export type RebuildPackageReq = { id: string } // package.rebuild
export type RebuildPackageRes = null
export type UninstallPackageReq = { id: string } // package.uninstall
export type UninstallPackageReq = {
id: string
force: boolean
soft: boolean
} // package.uninstall
export type UninstallPackageRes = null
export type SideloadPackageReq = {

View File

@@ -120,6 +120,8 @@ export abstract class ApiService {
abstract repairDisk(params: RR.DiskRepairReq): Promise<RR.DiskRepairRes>
abstract toggleKiosk(enable: boolean): Promise<null>
abstract resetTor(params: RR.ResetTorReq): Promise<RR.ResetTorRes>
// @TODO 041
@@ -335,6 +337,8 @@ export abstract class ApiService {
abstract runAction(params: RR.ActionReq): Promise<RR.ActionRes>
abstract clearTask(params: RR.ClearTaskReq): Promise<RR.ClearTaskRes>
abstract restorePackages(
params: RR.RestorePackagesReq,
): Promise<RR.RestorePackagesRes>

View File

@@ -261,6 +261,13 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'disk.repair', params })
}
async toggleKiosk(enable: boolean): Promise<null> {
return this.rpcRequest({
method: enable ? 'kiosk.enable' : 'kiosk.disable',
params: {},
})
}
async resetTor(params: RR.ResetTorReq): Promise<RR.ResetTorRes> {
return this.rpcRequest({ method: 'net.tor.reset', params })
}
@@ -577,6 +584,10 @@ export class LiveApiService extends ApiService {
return this.rpcRequest({ method: 'package.action.run', params })
}
async clearTask(params: RR.ClearTaskReq): Promise<RR.ClearTaskRes> {
return this.rpcRequest({ method: 'package.action.clear-task', params })
}
async restorePackages(
params: RR.RestorePackagesReq,
): Promise<RR.RestorePackagesRes> {

View File

@@ -22,11 +22,7 @@ import { from, interval, map, shareReplay, startWith, Subject, tap } from 'rxjs'
import { mockPatchData } from './mock-patch'
import { AuthService } from '../auth.service'
import { T } from '@start9labs/start-sdk'
import {
GetPackageRes,
GetPackagesRes,
MarketplacePkg,
} from '@start9labs/marketplace'
import { MarketplacePkg } from '@start9labs/marketplace'
import markdown from 'raw-loader!../../../../../shared/assets/markdown/md-sample.md'
import { WebSocketSubject } from 'rxjs/webSocket'
import { toAcmeUrl } from 'src/app/utils/acme'
@@ -166,7 +162,6 @@ export class MockApiService extends ApiService {
pathArr: Array<string | number>,
value: T,
): Promise<RR.SetDBValueRes> {
console.warn(pathArr, value)
const pointer = pathFromArray(pathArr)
const params: RR.SetDBValueReq<T> = { pointer, value }
await pauseFor(2000)
@@ -449,6 +444,21 @@ export class MockApiService extends ApiService {
return null
}
async toggleKiosk(enable: boolean): Promise<null> {
await pauseFor(2000)
const patch = [
{
op: PatchOp.REPLACE,
path: '/serverInfo/kiosk',
value: enable,
},
]
this.mockRevision(patch)
return null
}
async resetTor(params: RR.ResetTorReq): Promise<RR.ResetTorRes> {
await pauseFor(2000)
return null
@@ -1103,23 +1113,32 @@ export class MockApiService extends ApiService {
async runAction(params: RR.ActionReq): Promise<RR.ActionRes> {
await pauseFor(2000)
if (params.actionId === 'properties') {
// return Mock.ActionResGroup
return Mock.ActionResMessage
// return Mock.ActionResSingle
} else if (params.actionId === 'config') {
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/packageData/${params.packageId}/requestedActions/${params.packageId}-config`,
},
]
this.mockRevision(patch)
return null
} else {
return Mock.ActionResMessage
// return Mock.ActionResSingle
}
const patch: ReplaceOperation<{ [key: string]: T.TaskEntry }>[] = [
{
op: PatchOp.REPLACE,
path: `/packageData/${params.packageId}/tasks`,
value: {},
},
]
this.mockRevision(patch)
// return Mock.ActionResGroup
return Mock.ActionResMessage
// return Mock.ActionResSingle
}
async clearTask(params: RR.ClearTaskReq): Promise<RR.ClearTaskRes> {
await pauseFor(2000)
const patch: RemoveOperation[] = [
{
op: PatchOp.REMOVE,
path: `/packageData/${params.packageId}/tasks/${params.replayId}`,
},
]
this.mockRevision(patch)
return null
}
async restorePackages(

View File

@@ -183,13 +183,7 @@ export const mockPatchData: DataModel = {
pubkey: 'npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m',
caFingerprint: '63:2B:11:99:44:40:17:DF:37:FC:C3:DF:0F:3D:15',
ntpSynced: false,
smtp: {
server: '',
port: 587,
from: '',
login: '',
password: '',
},
smtp: null,
platform: 'x86_64-nonfree',
zram: true,
governor: 'performance',
@@ -221,7 +215,7 @@ export const mockPatchData: DataModel = {
actions: {
config: {
name: 'Set Config',
description: 'edit bitcoin.conf',
description: 'edit bitcoin.conf, <b>soo cool!</b>',
warning: null,
visibility: 'enabled',
allowedStatuses: 'any',

View File

@@ -1,22 +1,18 @@
import { Injectable } from '@angular/core'
import { inject, Injectable } from '@angular/core'
import { ReplaySubject } from 'rxjs'
import { StorageService } from './storage.service'
const SHOW_DEV_TOOLS = 'SHOW_DEV_TOOLS'
const SHOW_DISK_REPAIR = 'SHOW_DISK_REPAIR'
@Injectable({
providedIn: 'root',
})
export class ClientStorageService {
private readonly storage = inject(StorageService)
readonly showDevTools$ = new ReplaySubject<boolean>(1)
readonly showDiskRepair$ = new ReplaySubject<boolean>(1)
constructor(private readonly storage: StorageService) {}
init() {
this.showDevTools$.next(!!this.storage.get(SHOW_DEV_TOOLS))
this.showDiskRepair$.next(!!this.storage.get(SHOW_DISK_REPAIR))
}
toggleShowDevTools(): boolean {
@@ -25,11 +21,4 @@ export class ClientStorageService {
this.showDevTools$.next(newVal)
return newVal
}
toggleShowDiskRepair(): boolean {
const newVal = !this.storage.get(SHOW_DISK_REPAIR)
this.storage.set(SHOW_DISK_REPAIR, newVal)
this.showDiskRepair$.next(newVal)
return newVal
}
}

View File

@@ -31,7 +31,6 @@ import {
import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { ClientStorageService } from './client-storage.service'
const { start9, community } = defaultRegistries
@@ -55,20 +54,6 @@ export class MarketplaceService {
]),
)
// option to filter out hosts containing 'alpha' or 'beta' substrings in registryURL
readonly filteredRegistries$: Observable<StoreIdentity[]> = combineLatest([
inject(ClientStorageService).showDevTools$,
this.registries$,
]).pipe(
map(([devMode, registries]) =>
devMode
? registries
: registries.filter(
({ url }) => !url.includes('alpha') && !url.includes('beta'),
),
),
)
readonly currentRegistryUrl$ = new ReplaySubject<string>(1)
readonly requestErrors$ = new BehaviorSubject<string[]>([])
@@ -252,7 +237,6 @@ export class MarketplaceService {
oldName: string | null,
newName: string,
): Promise<void> {
console.warn(oldName, newName)
if (oldName !== newName) {
this.api.setDbValue<string>(['registries', url], newName)
}

View File

@@ -93,7 +93,7 @@ export class NotificationService {
{ data, createdAt, code, title, message }: ServerNotification<number>,
full = false,
) {
const label = full || code === 2 ? title : 'Backup Report'
const label = code === 1 ? 'Backup Report' : title
const component = code === 1 ? REPORT : MARKDOWN
const content = code === 1 ? data : of(data)
@@ -104,6 +104,7 @@ export class NotificationService {
content,
timestamp: createdAt,
},
size: code === 1 ? 'm' : 'l',
})
.subscribe()
}

Some files were not shown because too many files have changed in this diff Show More