frontend start-tunnel updates

This commit is contained in:
Matt Hill
2026-02-20 13:33:18 -07:00
parent 80cb2d9ba5
commit 6a01b5eab1
5 changed files with 355 additions and 115 deletions

View File

@@ -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)
}

View File

@@ -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() {

View File

@@ -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)

View File

@@ -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 onChangePassword(): void {
this.dialogs.open(CHANGE_PASSWORD, { label: 'Change Password' }).subscribe()
}
protected async onSave() {
if (this.form.invalid) {
tuiMarkControlAsTouchedAndValidate(this.form)
return
}
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)
}
}
}

View 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
}
}