mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 02:11:53 +00:00
frontend start-tunnel updates
This commit is contained in:
@@ -3,6 +3,7 @@ import { Component, inject } from '@angular/core'
|
||||
import { RouterOutlet } from '@angular/router'
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
|
||||
import { PatchService } from './services/patch.service'
|
||||
import { UpdateService } from './services/update.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
@@ -24,4 +25,6 @@ export class App {
|
||||
readonly subscription = inject(PatchService)
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe()
|
||||
|
||||
readonly updates = inject(UpdateService)
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { Router, RouterLink, RouterLinkActive } from '@angular/router'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { TuiButton } from '@taiga-ui/core'
|
||||
import { TuiBadgeNotification } from '@taiga-ui/kit'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { AuthService } from 'src/app/services/auth.service'
|
||||
import { SidebarService } from 'src/app/services/sidebar.service'
|
||||
import { UpdateService } from 'src/app/services/update.service'
|
||||
|
||||
@Component({
|
||||
selector: 'nav',
|
||||
@@ -22,6 +24,19 @@ import { SidebarService } from 'src/app/services/sidebar.service'
|
||||
{{ route.name }}
|
||||
</a>
|
||||
}
|
||||
<a
|
||||
tuiButton
|
||||
size="s"
|
||||
appearance="flat-grayscale"
|
||||
routerLinkActive="active"
|
||||
iconStart="@tui.settings"
|
||||
routerLink="settings"
|
||||
>
|
||||
Settings
|
||||
@if (update.hasUpdate()) {
|
||||
<tui-badge-notification size="s" appearance="positive" />
|
||||
}
|
||||
</a>
|
||||
</div>
|
||||
<button
|
||||
tuiButton
|
||||
@@ -57,6 +72,11 @@ import { SidebarService } from 'src/app/services/sidebar.service'
|
||||
&.active {
|
||||
background: var(--tui-background-neutral-1);
|
||||
}
|
||||
|
||||
tui-badge-notification {
|
||||
margin-inline-start: auto;
|
||||
background: var(--tui-status-positive);
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
@@ -78,7 +98,7 @@ import { SidebarService } from 'src/app/services/sidebar.service'
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiButton, RouterLink, RouterLinkActive],
|
||||
imports: [TuiButton, TuiBadgeNotification, RouterLink, RouterLinkActive],
|
||||
host: {
|
||||
'[class._expanded]': 'sidebars.start()',
|
||||
'(document:click)': 'sidebars.start.set(false)',
|
||||
@@ -92,6 +112,7 @@ export class Nav {
|
||||
protected readonly api = inject(ApiService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
protected readonly update = inject(UpdateService)
|
||||
|
||||
protected readonly routes = [
|
||||
{
|
||||
@@ -109,11 +130,6 @@ export class Nav {
|
||||
icon: '@tui.globe',
|
||||
link: 'port-forwards',
|
||||
},
|
||||
{
|
||||
name: 'Settings',
|
||||
icon: '@tui.settings',
|
||||
link: 'settings',
|
||||
},
|
||||
] as const
|
||||
|
||||
protected async logout() {
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import {
|
||||
NonNullableFormBuilder,
|
||||
ReactiveFormsModule,
|
||||
ValidatorFn,
|
||||
Validators,
|
||||
} from '@angular/forms'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { TuiAutoFocus, TuiValidator } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiAlertService,
|
||||
TuiButton,
|
||||
TuiDialogContext,
|
||||
TuiError,
|
||||
TuiTextfield,
|
||||
} from '@taiga-ui/core'
|
||||
import {
|
||||
TuiButtonLoading,
|
||||
TuiFieldErrorPipe,
|
||||
tuiValidationErrorsProvider,
|
||||
} from '@taiga-ui/kit'
|
||||
import { TuiForm } from '@taiga-ui/layout'
|
||||
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import { map } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<form tuiForm [formGroup]="form">
|
||||
<tui-textfield>
|
||||
<label tuiLabel>New password</label>
|
||||
<input tuiTextfield tuiAutoFocus formControlName="password" />
|
||||
</tui-textfield>
|
||||
<tui-error
|
||||
formControlName="password"
|
||||
[error]="[] | tuiFieldError | async"
|
||||
/>
|
||||
<tui-textfield>
|
||||
<label tuiLabel>Confirm new password</label>
|
||||
<input
|
||||
tuiTextfield
|
||||
formControlName="confirm"
|
||||
[tuiValidator]="matchValidator()"
|
||||
/>
|
||||
</tui-textfield>
|
||||
<tui-error
|
||||
formControlName="confirm"
|
||||
[error]="[] | tuiFieldError | async"
|
||||
/>
|
||||
<footer>
|
||||
<button
|
||||
tuiButton
|
||||
(click)="onSave()"
|
||||
[loading]="loading()"
|
||||
[disabled]="formInvalid()"
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
`,
|
||||
providers: [
|
||||
tuiValidationErrorsProvider({
|
||||
required: 'This field is required',
|
||||
minlength: 'Password must be at least 8 characters',
|
||||
maxlength: 'Password cannot exceed 64 characters',
|
||||
match: 'Passwords do not match',
|
||||
}),
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
ReactiveFormsModule,
|
||||
TuiAutoFocus,
|
||||
TuiButton,
|
||||
TuiButtonLoading,
|
||||
TuiError,
|
||||
TuiFieldErrorPipe,
|
||||
TuiForm,
|
||||
TuiTextfield,
|
||||
TuiValidator,
|
||||
],
|
||||
})
|
||||
export class ChangePasswordDialog {
|
||||
private readonly context = injectContext<TuiDialogContext<void>>()
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly alerts = inject(TuiAlertService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
|
||||
protected readonly loading = signal(false)
|
||||
protected readonly form = inject(NonNullableFormBuilder).group({
|
||||
password: [
|
||||
'',
|
||||
[Validators.required, Validators.minLength(8), Validators.maxLength(64)],
|
||||
],
|
||||
confirm: [
|
||||
'',
|
||||
[Validators.required, Validators.minLength(8), Validators.maxLength(64)],
|
||||
],
|
||||
})
|
||||
|
||||
protected readonly matchValidator = toSignal(
|
||||
this.form.controls.password.valueChanges.pipe(
|
||||
map(
|
||||
(password): ValidatorFn =>
|
||||
({ value }) =>
|
||||
value === password ? null : { match: true },
|
||||
),
|
||||
),
|
||||
{ initialValue: Validators.nullValidator },
|
||||
)
|
||||
|
||||
protected readonly formInvalid = toSignal(
|
||||
this.form.statusChanges.pipe(map(() => this.form.invalid)),
|
||||
{ initialValue: this.form.invalid },
|
||||
)
|
||||
|
||||
protected async onSave() {
|
||||
this.loading.set(true)
|
||||
|
||||
try {
|
||||
await this.api.setPassword({ password: this.form.getRawValue().password })
|
||||
this.alerts
|
||||
.open('Password changed', { label: 'Success', appearance: 'positive' })
|
||||
.subscribe()
|
||||
this.context.$implicit.complete()
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const CHANGE_PASSWORD = new PolymorpheusComponent(ChangePasswordDialog)
|
||||
@@ -1,142 +1,101 @@
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
signal,
|
||||
} from '@angular/core'
|
||||
import { toSignal } from '@angular/core/rxjs-interop'
|
||||
import {
|
||||
NonNullableFormBuilder,
|
||||
ReactiveFormsModule,
|
||||
ValidatorFn,
|
||||
Validators,
|
||||
} from '@angular/forms'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { tuiMarkControlAsTouchedAndValidate, TuiValidator } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiAlertService,
|
||||
TuiAppearance,
|
||||
TuiButton,
|
||||
TuiError,
|
||||
TuiTextfield,
|
||||
TuiTitle,
|
||||
} from '@taiga-ui/core'
|
||||
import {
|
||||
TuiButtonLoading,
|
||||
TuiFieldErrorPipe,
|
||||
tuiValidationErrorsProvider,
|
||||
} from '@taiga-ui/kit'
|
||||
import { TuiCard, TuiForm, TuiHeader } from '@taiga-ui/layout'
|
||||
import { map } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { TuiAppearance, TuiButton, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiDialogService } from '@taiga-ui/experimental'
|
||||
import { TuiBadge, TuiButtonLoading } from '@taiga-ui/kit'
|
||||
import { TuiCard, TuiCell } from '@taiga-ui/layout'
|
||||
import { UpdateService } from 'src/app/services/update.service'
|
||||
|
||||
import { CHANGE_PASSWORD } from './change-password'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<form tuiCardLarge tuiAppearance="neutral" tuiForm [formGroup]="form">
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>
|
||||
Settings
|
||||
<span tuiSubtitle>Change password</span>
|
||||
</h2>
|
||||
</header>
|
||||
<tui-textfield>
|
||||
<label tuiLabel>New password</label>
|
||||
<input formControlName="password" tuiTextfield />
|
||||
</tui-textfield>
|
||||
<tui-error
|
||||
formControlName="password"
|
||||
[error]="[] | tuiFieldError | async"
|
||||
/>
|
||||
<tui-textfield>
|
||||
<label tuiLabel>Confirm new password</label>
|
||||
<input
|
||||
formControlName="confirm"
|
||||
tuiTextfield
|
||||
[tuiValidator]="matchValidator()"
|
||||
/>
|
||||
</tui-textfield>
|
||||
<tui-error
|
||||
formControlName="confirm"
|
||||
[error]="[] | tuiFieldError | async"
|
||||
/>
|
||||
<footer>
|
||||
<button tuiButton (click)="onSave()" [loading]="loading()">Save</button>
|
||||
</footer>
|
||||
</form>
|
||||
<div tuiCardLarge tuiAppearance="neutral">
|
||||
<div tuiCell>
|
||||
<span tuiTitle>
|
||||
<strong>
|
||||
Version
|
||||
@if (update.hasUpdate()) {
|
||||
<tui-badge appearance="positive" size="s">
|
||||
Update Available
|
||||
</tui-badge>
|
||||
}
|
||||
</strong>
|
||||
<span tuiSubtitle>Current: {{ update.installed() ?? '—' }}</span>
|
||||
</span>
|
||||
@if (update.hasUpdate()) {
|
||||
<button tuiButton size="s" [loading]="applying()" (click)="onApply()">
|
||||
Update to {{ update.candidate() }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
tuiButton
|
||||
size="s"
|
||||
appearance="secondary"
|
||||
[loading]="checking()"
|
||||
(click)="onCheckUpdate()"
|
||||
>
|
||||
Check for updates
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div tuiCell>
|
||||
<span tuiTitle>
|
||||
<strong>Change password</strong>
|
||||
</span>
|
||||
<button tuiButton size="s" (click)="onChangePassword()">Change</button>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
providers: [
|
||||
tuiValidationErrorsProvider({
|
||||
required: 'This field is required',
|
||||
minlength: 'Password must be at least 8 characters',
|
||||
maxlength: 'Password cannot exceed 64 characters',
|
||||
match: 'Passwords do not match',
|
||||
}),
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
ReactiveFormsModule,
|
||||
AsyncPipe,
|
||||
TuiCard,
|
||||
TuiForm,
|
||||
TuiHeader,
|
||||
TuiCell,
|
||||
TuiTitle,
|
||||
TuiTextfield,
|
||||
TuiError,
|
||||
TuiFieldErrorPipe,
|
||||
TuiButton,
|
||||
TuiButtonLoading,
|
||||
TuiValidator,
|
||||
TuiBadge,
|
||||
TuiAppearance,
|
||||
],
|
||||
})
|
||||
export default class Settings {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly alerts = inject(TuiAlertService)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
|
||||
protected readonly loading = signal(false)
|
||||
protected readonly form = inject(NonNullableFormBuilder).group({
|
||||
password: [
|
||||
'',
|
||||
[Validators.required, Validators.minLength(8), Validators.maxLength(64)],
|
||||
],
|
||||
confirm: [
|
||||
'',
|
||||
[Validators.required, Validators.minLength(8), Validators.maxLength(64)],
|
||||
],
|
||||
})
|
||||
protected readonly update = inject(UpdateService)
|
||||
protected readonly checking = signal(false)
|
||||
protected readonly applying = signal(false)
|
||||
|
||||
protected readonly matchValidator = toSignal(
|
||||
this.form.controls.password.valueChanges.pipe(
|
||||
map(
|
||||
(password): ValidatorFn =>
|
||||
({ value }) =>
|
||||
value === password ? null : { match: true },
|
||||
),
|
||||
),
|
||||
{ initialValue: Validators.nullValidator },
|
||||
)
|
||||
|
||||
protected async onSave() {
|
||||
if (this.form.invalid) {
|
||||
tuiMarkControlAsTouchedAndValidate(this.form)
|
||||
|
||||
return
|
||||
protected onChangePassword(): void {
|
||||
this.dialogs.open(CHANGE_PASSWORD, { label: 'Change Password' }).subscribe()
|
||||
}
|
||||
|
||||
this.loading.set(true)
|
||||
protected async onCheckUpdate() {
|
||||
this.checking.set(true)
|
||||
|
||||
try {
|
||||
await this.api.setPassword({ password: this.form.getRawValue().password })
|
||||
this.alerts
|
||||
.open('Password changed', { label: 'Success', appearance: 'positive' })
|
||||
.subscribe()
|
||||
this.form.reset()
|
||||
await this.update.checkUpdate()
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading.set(false)
|
||||
this.checking.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
protected async onApply() {
|
||||
this.applying.set(true)
|
||||
|
||||
try {
|
||||
await this.update.applyUpdate()
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.applying.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
120
web/projects/start-tunnel/src/app/services/update.service.ts
Normal file
120
web/projects/start-tunnel/src/app/services/update.service.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { Component, computed, inject, Injectable, signal } from '@angular/core'
|
||||
import { toObservable } from '@angular/core/rxjs-interop'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { TuiLoader } from '@taiga-ui/core'
|
||||
import { TuiDialogService } from '@taiga-ui/experimental'
|
||||
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import {
|
||||
catchError,
|
||||
EMPTY,
|
||||
filter,
|
||||
from,
|
||||
interval,
|
||||
Subscription,
|
||||
switchMap,
|
||||
takeWhile,
|
||||
} from 'rxjs'
|
||||
import { ApiService, TunnelUpdateResult } from './api/api.service'
|
||||
import { AuthService } from './auth.service'
|
||||
|
||||
@Component({
|
||||
template: '<tui-loader size="xl" [textContent]="text" />',
|
||||
imports: [TuiLoader],
|
||||
})
|
||||
class UpdatingDialog {
|
||||
protected readonly text = 'StartTunnel is updating...'
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class UpdateService {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly auth = inject(AuthService)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
|
||||
readonly result = signal<TunnelUpdateResult | null>(null)
|
||||
readonly hasUpdate = computed(
|
||||
() => this.result()?.status === 'update-available',
|
||||
)
|
||||
readonly installed = computed(() => this.result()?.installed ?? null)
|
||||
readonly candidate = computed(() => this.result()?.candidate ?? null)
|
||||
|
||||
private polling = false
|
||||
private updatingDialog: Subscription | null = null
|
||||
|
||||
constructor() {
|
||||
toObservable(this.auth.authenticated)
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => this.initCheck())
|
||||
}
|
||||
|
||||
async checkUpdate(): Promise<void> {
|
||||
const result = await this.api.checkUpdate()
|
||||
this.setResult(result)
|
||||
}
|
||||
|
||||
async applyUpdate(): Promise<void> {
|
||||
const result = await this.api.applyUpdate()
|
||||
this.setResult(result)
|
||||
}
|
||||
|
||||
private setResult(result: TunnelUpdateResult): void {
|
||||
this.result.set(result)
|
||||
|
||||
if (result.status === 'updating') {
|
||||
this.showUpdatingDialog()
|
||||
this.startPolling()
|
||||
} else {
|
||||
this.hideUpdatingDialog()
|
||||
}
|
||||
}
|
||||
|
||||
private async initCheck(): Promise<void> {
|
||||
try {
|
||||
await this.checkUpdate()
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
private startPolling(): void {
|
||||
if (this.polling) return
|
||||
this.polling = true
|
||||
|
||||
interval(5000)
|
||||
.pipe(
|
||||
switchMap(() =>
|
||||
from(this.api.checkUpdate()).pipe(catchError(() => EMPTY)),
|
||||
),
|
||||
takeWhile(result => result.status === 'updating', true),
|
||||
)
|
||||
.subscribe({
|
||||
next: result => this.result.set(result),
|
||||
complete: () => {
|
||||
this.polling = false
|
||||
this.hideUpdatingDialog()
|
||||
},
|
||||
error: () => {
|
||||
this.polling = false
|
||||
this.hideUpdatingDialog()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private showUpdatingDialog(): void {
|
||||
if (this.updatingDialog) return
|
||||
this.updatingDialog = this.dialogs
|
||||
.open(new PolymorpheusComponent(UpdatingDialog), {
|
||||
closable: false,
|
||||
dismissible: false,
|
||||
})
|
||||
.subscribe({ complete: () => (this.updatingDialog = null) })
|
||||
}
|
||||
|
||||
private hideUpdatingDialog(): void {
|
||||
this.updatingDialog?.unsubscribe()
|
||||
this.updatingDialog = null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user