Merge pull request #2706 from Start9Labs/setup-wizard

fix: fix merge issues for setup-wizard project
This commit is contained in:
Matt Hill
2024-08-10 15:04:01 -06:00
committed by GitHub
32 changed files with 364 additions and 571 deletions

View File

@@ -17,7 +17,7 @@
"outputPath": "dist/raw/ui",
"index": "projects/ui/src/index.html",
"main": "projects/ui/src/main.ts",
"polyfills": ["zone.js"],
"polyfills": ["zone.js", "projects/ui/src/polyfills.ts"],
"tsConfig": "projects/ui/tsconfig.json",
"inlineStyleLanguage": "scss",
"assets": [

View File

@@ -14,7 +14,7 @@ export class AppComponent {
async ngOnInit() {
try {
const inProgress = await this.api.getSetupStatus()
const inProgress = await this.api.getStatus()
let route = 'home'

View File

@@ -4,7 +4,6 @@ import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
import { PreloadAllModules, RouterModule } from '@angular/router'
import {
provideSetupLogsService,
provideSetupService,
RELATIVE_URL,
WorkspaceConfig,
} from '@start9labs/shared'
@@ -34,7 +33,6 @@ const {
],
providers: [
NG_EVENT_PLUGINS,
provideSetupService(ApiService),
provideSetupLogsService(ApiService),
tuiButtonOptionsProvider({ size: 'm' }),
{

View File

@@ -1,6 +1,5 @@
import { TuiInputModule, TuiInputPasswordModule } from '@taiga-ui/legacy'
import { CommonModule } from '@angular/common'
import { Component, inject, Inject } from '@angular/core'
import { Component, inject } from '@angular/core'
import {
FormControl,
FormGroup,
@@ -9,24 +8,26 @@ import {
Validators,
} from '@angular/forms'
import { LoadingService, StartOSDiskInfo } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import {
TuiButton,
TuiDialogContext,
TuiDialogService,
TuiError,
TuiButton,
} from '@taiga-ui/core'
import { TUI_VALIDATION_ERRORS, TuiFieldErrorPipe } from '@taiga-ui/kit'
import { POLYMORPHEUS_CONTEXT } from '@taiga-ui/polymorpheus'
import { PASSWORD } from 'src/app/components/password.component'
import { TuiInputModule, TuiInputPasswordModule } from '@taiga-ui/legacy'
import {
ApiService,
CifsBackupTarget,
CifsRecoverySource,
} from 'src/app/services/api.service'
POLYMORPHEUS_CONTEXT,
PolymorpheusComponent,
} from '@taiga-ui/polymorpheus'
import { SERVERS, ServersResponse } from 'src/app/components/servers.component'
import { ApiService } from 'src/app/services/api.service'
interface Context {
cifs: CifsRecoverySource
recoveryPassword: string
export interface CifsResponse {
cifs: T.Cifs
serverId: string
password: string
}
@Component({
@@ -34,10 +35,10 @@ interface Context {
template: `
<form [formGroup]="form" (ngSubmit)="submit()">
<tui-input formControlName="hostname">
Hostname
Hostname *
<input
tuiTextfieldLegacy
placeholder="'My Computer' OR 'my-computer.local'"
placeholder="e.g. 'My Computer' OR 'my-computer.local'"
/>
</tui-input>
<tui-error
@@ -46,7 +47,7 @@ interface Context {
></tui-error>
<tui-input formControlName="path" class="input">
Path
Path *
<input tuiTextfieldLegacy placeholder="/Desktop/my-folder'" />
</tui-input>
<tui-error
@@ -55,7 +56,7 @@ interface Context {
></tui-error>
<tui-input formControlName="username" class="input">
Username
Username *
<input tuiTextfieldLegacy placeholder="Enter username" />
</tui-input>
<tui-error
@@ -108,7 +109,7 @@ export class CifsComponent {
private readonly api = inject(ApiService)
private readonly loader = inject(LoadingService)
private readonly context =
inject<TuiDialogContext<Context>>(POLYMORPHEUS_CONTEXT)
inject<TuiDialogContext<CifsResponse>>(POLYMORPHEUS_CONTEXT)
readonly form = new FormGroup({
hostname: new FormControl('', {
@@ -141,7 +142,6 @@ export class CifsComponent {
try {
const diskInfo = await this.api.verifyCifs({
...this.form.getRawValue(),
type: 'cifs',
password: this.form.value.password
? await this.api.encrypt(String(this.form.value.password))
: null,
@@ -149,35 +149,31 @@ export class CifsComponent {
loader.unsubscribe()
this.presentModalPassword(diskInfo)
this.selectServer(diskInfo)
} catch (e) {
loader.unsubscribe()
this.presentAlertFailed()
this.onFail()
}
}
private presentModalPassword(diskInfo: StartOSDiskInfo) {
const target: CifsBackupTarget = {
...this.form.getRawValue(),
mountable: true,
startOs: diskInfo,
}
private selectServer(servers: Record<string, StartOSDiskInfo>) {
this.dialogs
.open<string>(PASSWORD, {
label: 'Unlock Drive',
size: 's',
data: { target },
.open<ServersResponse>(SERVERS, {
label: 'Select Server to Restore',
data: {
servers: Object.keys(servers).map(id => ({ id, ...servers[id] })),
},
})
.subscribe(recoveryPassword => {
.subscribe(({ password, serverId }) => {
this.context.completeWith({
cifs: { ...this.form.getRawValue(), type: 'cifs' },
recoveryPassword,
cifs: { ...this.form.getRawValue() },
serverId,
password,
})
})
}
private presentAlertFailed() {
private onFail() {
this.dialogs
.open(
'Unable to connect to shared folder. Ensure (1) target computer is connected to LAN, (2) target folder is being shared, and (3) hostname, path, and credentials are accurate.',
@@ -189,3 +185,5 @@ export class CifsComponent {
.subscribe()
}
}
export const CIFS = new PolymorpheusComponent(CifsComponent)

View File

@@ -1,12 +1,5 @@
import {
Component,
Directive,
ElementRef,
inject,
NgZone,
OnInit,
} from '@angular/core'
import { WINDOW } from '@ng-web-apis/common'
import { Component, ElementRef, inject, NgZone, OnInit } from '@angular/core'
import { WA_WINDOW } from '@ng-web-apis/common'
// a higher fade factor will make the characters fade quicker
const FADE_FACTOR = 0.07
@@ -19,7 +12,7 @@ const FADE_FACTOR = 0.07
})
export class MatrixComponent implements OnInit {
private readonly ngZone = inject(NgZone)
private readonly window = inject(WINDOW)
private readonly window = inject(WA_WINDOW)
private readonly el: HTMLCanvasElement = inject(ElementRef).nativeElement
private readonly ctx = this.el.getContext('2d')!

View File

@@ -8,13 +8,9 @@ import {
POLYMORPHEUS_CONTEXT,
PolymorpheusComponent,
} from '@taiga-ui/polymorpheus'
import {
CifsBackupTarget,
DiskBackupTarget,
} from 'src/app/services/api.service'
interface DialogData {
target?: CifsBackupTarget | DiskBackupTarget
passwordHash?: string
storageDrive?: boolean
}
@@ -73,24 +69,14 @@ export class PasswordComponent {
private readonly context =
inject<TuiDialogContext<string, DialogData>>(POLYMORPHEUS_CONTEXT)
readonly target = this.context.data.target
readonly storageDrive = this.context.data.storageDrive
readonly password = new FormControl('', { nonNullable: true })
readonly confirm = new FormControl('', { nonNullable: true })
get passwordError(): string | null {
if (!this.password.touched || this.target) return null
if (!this.storageDrive && !this.target?.['embassy-os'])
return 'No recovery target' // unreachable
if (this.password.value.length < 12)
return 'Must be 12 characters or greater'
if (this.password.value.length > 64)
return 'Must be less than 65 characters'
return null
return this.password.touched && this.password.value.length < 12
? 'Must be 12 characters or greater'
: null
}
get confirmError(): string | null {
@@ -107,9 +93,7 @@ export class PasswordComponent {
}
try {
const passwordHash = this.target!.startOs?.passwordHash || ''
argon2.verify(passwordHash, this.password.value)
argon2.verify(this.context.data.passwordHash || '', this.password.value)
this.context.completeWith(this.password.value)
} catch (e) {
this.errorService.handleError('Incorrect password provided')

View File

@@ -0,0 +1,46 @@
import { DatePipe } from '@angular/common'
import { Component, ElementRef, inject, input, Output } from '@angular/core'
import { StartOSDiskInfo } from '@start9labs/shared'
import { TuiDialogService, TuiIcon, TuiTitle } from '@taiga-ui/core'
import { TuiCell } from '@taiga-ui/layout'
import { filter, fromEvent, switchMap } from 'rxjs'
import { PASSWORD } from 'src/app/components/password.component'
@Component({
standalone: true,
selector: 'button[server]',
template: `
<tui-icon icon="@tui.save" />
<span tuiTitle>
<strong>{{ server().hostname }}.local</strong>
<span tuiSubtitle>
<b>StartOS Version</b>
: {{ server().version }}
</span>
<span tuiSubtitle>
<b>Created</b>
: {{ server().timestamp | date: 'medium' }}
</span>
</span>
`,
styles: ':host { width: stretch; border-radius: var(--tui-radius-l); }',
hostDirectives: [TuiCell],
imports: [DatePipe, TuiIcon, TuiTitle],
})
export class ServerComponent {
private readonly dialogs = inject(TuiDialogService)
readonly server = input.required<StartOSDiskInfo>()
@Output()
readonly password = fromEvent(inject(ElementRef).nativeElement, 'click').pipe(
switchMap(() =>
this.dialogs.open<string>(PASSWORD, {
label: 'Unlock Drive',
size: 's',
data: { passwordHash: this.server().passwordHash },
}),
),
filter(Boolean),
)
}

View File

@@ -0,0 +1,37 @@
import { Component, inject } from '@angular/core'
import { TuiDialogContext } from '@taiga-ui/core'
import {
POLYMORPHEUS_CONTEXT,
PolymorpheusComponent,
} from '@taiga-ui/polymorpheus'
import { ServerComponent } from 'src/app/components/server.component'
import { StartOSDiskInfoWithId } from 'src/app/services/api.service'
interface Data {
servers: StartOSDiskInfoWithId[]
}
export interface ServersResponse {
password: string
serverId: string
}
@Component({
standalone: true,
template: `
@for (server of context.data.servers; track $index) {
<button [server]="server" (password)="select($event, server.id)"></button>
}
`,
imports: [ServerComponent],
})
export class ServersComponent {
readonly context =
inject<TuiDialogContext<ServersResponse, Data>>(POLYMORPHEUS_CONTEXT)
select(password: string, serverId: string) {
this.context.completeWith({ serverId, password })
}
}
export const SERVERS = new PolymorpheusComponent(ServersComponent)

View File

@@ -1,13 +0,0 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { ServerBackupSelectModal } from './server-backup-select.page'
import { PasswordPageModule } from '../password/password.module'
@NgModule({
declarations: [ServerBackupSelectModal],
imports: [CommonModule, FormsModule, IonicModule, PasswordPageModule],
exports: [ServerBackupSelectModal],
})
export class ServerBackupSelectModule {}

View File

@@ -1,24 +0,0 @@
<ion-header>
<ion-toolbar>
<ion-title>Select Server to Restore</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-item *ngFor="let server of servers" button (click)="select(server)">
<ion-label>
<h2>
<b>Local Hostname</b>
: {{ server.hostname }}.local
</h2>
<h2>
<b>StartOS Version</b>
: {{ server.version }}
</h2>
<h2>
<b>Created</b>
: {{ server.timestamp | date : 'medium' }}
</h2>
</ion-label>
</ion-item>
</ion-content>

View File

@@ -1,44 +0,0 @@
import { Component, Input } from '@angular/core'
import { ModalController } from '@ionic/angular'
import { StartOSDiskInfoWithId } from 'src/app/services/api/api.service'
import { PasswordPage } from '../password/password.page'
@Component({
selector: 'server-backup-select',
templateUrl: 'server-backup-select.page.html',
styleUrls: ['server-backup-select.page.scss'],
})
export class ServerBackupSelectModal {
@Input() servers: StartOSDiskInfoWithId[] = []
constructor(private readonly modalController: ModalController) {}
cancel() {
this.modalController.dismiss()
}
async select(server: StartOSDiskInfoWithId): Promise<void> {
this.presentModalPassword(server)
}
private async presentModalPassword(
server: StartOSDiskInfoWithId,
): Promise<void> {
const modal = await this.modalController.create({
component: PasswordPage,
componentProps: { passwordHash: server.passwordHash },
})
modal.onDidDismiss().then(res => {
if (res.role === 'success') {
this.modalController.dismiss(
{
serverId: server.id,
recoveryPassword: res.data.password,
},
'success',
)
}
})
await modal.present()
}
}

View File

@@ -26,7 +26,7 @@ import { StateService } from 'src/app/services/state.service'
Back
</button>
}
{{ recover ? 'StartOS Setup' : 'Recover Options' }}
{{ recover ? 'Recover Options' : 'StartOS Setup' }}
</header>
<div class="pages">
<div class="options" [class.options_recover]="recover">

View File

@@ -1,20 +1,105 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { Router } from '@angular/router'
import { InitializingComponent } from '@start9labs/shared'
import { ErrorService, InitializingComponent } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import {
catchError,
EMPTY,
filter,
from,
interval,
map,
startWith,
switchMap,
take,
tap,
} from 'rxjs'
import { ApiService } from 'src/app/services/api.service'
import { StateService } from 'src/app/services/state.service'
@Component({
standalone: true,
template: `
<app-initializing
[setupType]="stateService.setupType"
(finished)="router.navigate(['success'])"
/>
`,
template: '<app-initializing [setupType]="type" [progress]="progress()" />',
styles: ':host { max-width: unset; align-items: stretch; }',
imports: [InitializingComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export default class LoadingPage {
readonly stateService = inject(StateService)
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
readonly type = inject(StateService).setupType
readonly router = inject(Router)
readonly progress = toSignal(
from(this.getStatus()).pipe(
filter(Boolean),
take(1),
switchMap(({ guid, progress }) =>
this.api.openProgressWebsocket$(guid).pipe(
startWith(progress),
catchError((_, watch$) =>
interval(2000).pipe(
switchMap(() =>
from(this.api.getStatus()).pipe(catchError(() => EMPTY)),
),
take(1),
switchMap(() => watch$),
),
),
tap(({ overall }) => {
if (overall === true) {
this.getStatus()
}
}),
),
),
map(({ phases, overall }) => ({
total: getDecimal(overall),
message: phases
.filter(p => p.progress !== true && p.progress !== null)
.map(p => `${p.name}${getPhaseBytes(p.progress)}`)
.join(','),
})),
catchError(e => {
this.errorService.handleError(e)
return EMPTY
}),
),
{ initialValue: { total: 0, message: '' } },
)
private async getStatus(): Promise<{
status: 'running'
guid: string
progress: T.FullProgress
} | null> {
const res = await this.api.getStatus()
if (!res) {
this.router.navigate(['home'])
return null
} else if (res.status === 'complete') {
this.router.navigate(['success'])
return null
} else {
return res
}
}
}
function getDecimal(progress: T.Progress): number {
if (progress === true) {
return 1
} else if (!progress || !progress.total) {
return 0
} else {
return progress.total && progress.done / progress.total
}
}
function getPhaseBytes(progress: T.Progress): string {
return progress === true || !progress
? ''
: `: (${progress.done}/${progress.total})`
}

View File

@@ -1,6 +1,7 @@
import { DatePipe } from '@angular/common'
import { Component, inject } from '@angular/core'
import { Router } from '@angular/router'
import { DriveComponent, ErrorService } from '@start9labs/shared'
import { ErrorService } from '@start9labs/shared'
import {
TuiButton,
TuiDialogService,
@@ -9,15 +10,9 @@ import {
TuiTitle,
} from '@taiga-ui/core'
import { TuiCardLarge, TuiCell } from '@taiga-ui/layout'
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
import { filter } from 'rxjs'
import { CifsComponent } from 'src/app/components/cifs.component'
import { PASSWORD } from 'src/app/components/password.component'
import {
ApiService,
CifsRecoverySource,
DiskBackupTarget,
} from 'src/app/services/api.service'
import { CIFS, CifsResponse } from 'src/app/components/cifs.component'
import { ServerComponent } from 'src/app/components/server.component'
import { ApiService, StartOSDiskInfoFull } from 'src/app/services/api.service'
import { StateService } from 'src/app/services/state.service'
@Component({
@@ -38,8 +33,10 @@ import { StateService } from 'src/app/services/state.service'
</button>
<h2>Physical Drive</h2>
Restore StartOS data from a physical drive that is plugged directly into
your server.
<div>
Restore StartOS data from a physical drive that is plugged directly
into your server.
</div>
<strong>
Warning. Do not use this option if you are using a Raspberry Pi with
an external SSD as your main data drive. The Raspberry Pi cannot not
@@ -47,18 +44,11 @@ import { StateService } from 'src/app/services/state.service'
cause data corruption.
</strong>
@for (d of drives; track d) {
<button tuiCell [drive]="d" [disabled]="empty(d)" (click)="select(d)">
<span tuiSubtitle>
@if (empty(d)) {
<tui-icon icon="@tui.cloud-off" class="g-error" />
<strong>No StartOS backup</strong>
} @else {
<tui-icon icon="@tui.cloud" class="g-success" />
<strong>StartOS backup detected</strong>
}
</span>
</button>
@for (server of servers; track $index) {
<button
[server]="server"
(password)="select($event, server)"
></button>
}
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
@@ -74,7 +64,8 @@ import { StateService } from 'src/app/services/state.service'
TuiCell,
TuiIcon,
TuiTitle,
DriveComponent,
DatePipe,
ServerComponent,
],
})
export default class RecoverPage {
@@ -85,7 +76,7 @@ export default class RecoverPage {
private readonly stateService = inject(StateService)
loading = true
drives: DiskBackupTarget[] = []
servers: StartOSDiskInfoFull[] = []
async ngOnInit() {
this.stateService.setupType = 'restore'
@@ -97,21 +88,21 @@ export default class RecoverPage {
await this.getDrives()
}
empty(drive: DiskBackupTarget) {
return !drive.startOs?.full
}
async getDrives() {
this.drives = []
this.servers = []
try {
await this.api.getDrives().then(disks =>
disks
.filter(d => d.partitions.length)
.forEach(d => {
d.partitions.forEach(p => {
this.drives.push({ ...d, ...p })
})
}),
const drives = await this.api.getDrives()
this.servers = drives.flatMap(drive =>
drive.partitions.flatMap(partition =>
Object.entries(partition.startOs).map(([id, val]) => ({
id,
...val,
partition,
drive,
})),
),
)
} catch (e: any) {
this.errorService.handleError(e)
@@ -120,44 +111,35 @@ export default class RecoverPage {
}
}
select(target: DiskBackupTarget) {
const { logicalname } = target
if (!logicalname) return
this.dialogs
.open<string>(PASSWORD, {
label: 'Unlock Drive',
size: 's',
data: { target },
})
.pipe(filter(Boolean))
.subscribe(password => {
this.onSource(logicalname, password)
})
select(password: string, server: StartOSDiskInfoFull) {
this.stateService.recoverySource = {
type: 'backup',
target: {
type: 'disk',
logicalname: server.partition.logicalname,
},
serverId: server.id,
password,
}
this.router.navigate(['storage'])
}
onCifs() {
this.dialogs
.open<{
cifs: CifsRecoverySource
recoveryPassword: string
}>(new PolymorpheusComponent(CifsComponent), {
.open<CifsResponse>(CIFS, {
label: 'Connect Network Folder',
})
.subscribe(({ cifs, recoveryPassword }) => {
this.stateService.recoverySource = { type: 'backup', target: cifs }
this.stateService.recoveryPassword = recoveryPassword
.subscribe(({ cifs, serverId, password }) => {
this.stateService.recoverySource = {
type: 'backup',
target: {
type: 'cifs',
...cifs,
},
serverId,
password,
}
this.router.navigate(['storage'])
})
}
private onSource(logicalname: string, password?: string) {
this.stateService.recoverySource = {
type: 'backup',
target: { type: 'disk', logicalname },
}
this.stateService.recoveryPassword = password
this.router.navigate(['storage'])
}
}

View File

@@ -12,12 +12,7 @@ import { TUI_CONFIRM } from '@taiga-ui/kit'
import { TuiCardLarge, TuiCell } from '@taiga-ui/layout'
import { filter, of, switchMap } from 'rxjs'
import { PASSWORD } from 'src/app/components/password.component'
import {
ApiService,
BackupRecoverySource,
DiskMigrateSource,
DiskRecoverySource,
} from 'src/app/services/api.service'
import { ApiService } from 'src/app/services/api.service'
import { StateService } from 'src/app/services/state.service'
@Component({
@@ -44,7 +39,7 @@ import { StateService } from 'src/app/services/state.service'
</button>
}
<button tuiButton iconStart="@tui.rotate-cw" (click)="getDrives()">
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
Refresh
</button>
</section>
@@ -81,21 +76,24 @@ export default class StoragePage {
const disks = await this.api.getDrives()
if (this.stateService.setupType === 'fresh') {
this.drives = disks
} else if (this.stateService.setupType === 'restore') {
this.drives = disks.filter(
d =>
!d.partitions
.map(p => p.logicalname)
.includes(
(
(this.stateService.recoverySource as BackupRecoverySource)
?.target as DiskRecoverySource
)?.logicalname,
),
)
} else if (this.stateService.setupType === 'transfer') {
const guid = (this.stateService.recoverySource as DiskMigrateSource)
.guid
} else if (
this.stateService.setupType === 'restore' &&
this.stateService.recoverySource?.type === 'backup'
) {
if (this.stateService.recoverySource.target.type === 'disk') {
const logicalname =
this.stateService.recoverySource.target.logicalname
this.drives = disks.filter(
d => !d.partitions.map(p => p.logicalname).includes(logicalname),
)
} else {
this.drives = disks
}
} else if (
this.stateService.setupType === 'transfer' &&
this.stateService.recoverySource?.type === 'migrate'
) {
const guid = this.stateService.recoverySource.guid
this.drives = disks.filter(d => {
return (
d.guid !== guid && !d.partitions.map(p => p.guid).includes(guid)
@@ -130,19 +128,19 @@ export default class StoragePage {
.pipe(filter(Boolean))
.subscribe(() => {
// for backup recoveries
if (this.stateService.recoveryPassword) {
if (this.stateService.recoverySource?.type === 'backup') {
this.setupEmbassy(
drive.logicalname,
this.stateService.recoveryPassword,
this.stateService.recoverySource.password,
)
} else {
// for migrations and fresh setups
this.presentModalPassword(drive.logicalname)
this.promptPassword(drive.logicalname)
}
})
}
private presentModalPassword(logicalname: string) {
private promptPassword(logicalname: string) {
this.dialogs
.open<string>(PASSWORD, {
label: 'Set Password',
@@ -162,7 +160,7 @@ export default class StoragePage {
try {
await this.stateService.setupEmbassy(logicalname, password)
await this.router.navigate([`loading`])
await this.router.navigate(['loading'])
} catch (e: any) {
this.errorService.handleError(e)
} finally {

View File

@@ -2,6 +2,9 @@ import * as jose from 'node-jose'
import {
DiskInfo,
DiskListResponse,
FollowLogsReq,
FollowLogsRes,
Log,
PartitionInfo,
StartOSDiskInfo,
} from '@start9labs/shared'
@@ -22,6 +25,10 @@ export abstract class ApiService {
abstract execute(setupInfo: T.SetupExecuteParams): Promise<T.SetupProgress> // setup.execute
abstract complete(): Promise<T.SetupResult> // setup.complete
abstract exit(): Promise<void> // setup.exit
abstract followServerLogs(params: FollowLogsReq): Promise<FollowLogsRes> // setup.logs.follow
abstract openLogsWebsocket$(
config: WebSocketSubjectConfig<Log>,
): Observable<Log>
abstract openProgressWebsocket$(guid: string): Observable<T.FullProgress>
async encrypt(toEncrypt: string): Promise<T.EncryptedWire> {

View File

@@ -1,23 +1,22 @@
import { DOCUMENT } from '@angular/common'
import { Inject, Injectable } from '@angular/core'
import {
DiskListResponse,
StartOSDiskInfo,
encodeBase64,
FollowLogsReq,
FollowLogsRes,
HttpService,
isRpcError,
Log,
RpcError,
RPCOptions,
SetupStatus,
FollowLogsRes,
FollowLogsReq,
StartOSDiskInfo,
} from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { ApiService } from './api.service'
import * as jose from 'node-jose'
import { Observable } from 'rxjs'
import { DOCUMENT } from '@angular/common'
import { webSocket } from 'rxjs/webSocket'
import { webSocket, WebSocketSubjectConfig } from 'rxjs/webSocket'
import { ApiService } from './api.service'
@Injectable({
providedIn: 'root',
@@ -100,6 +99,14 @@ export class LiveApiService extends ApiService {
})
}
async followServerLogs(params: FollowLogsReq): Promise<FollowLogsRes> {
return this.rpcRequest({ method: 'setup.logs.follow', params })
}
openLogsWebsocket$({ url }: WebSocketSubjectConfig<Log>): Observable<Log> {
return webSocket(`http://start.local/ws/${url}`)
}
async complete(): Promise<T.SetupResult> {
const res = await this.rpcRequest<T.SetupResult>({
method: 'setup.complete',

View File

@@ -1,27 +1,18 @@
import { Injectable } from '@angular/core'
import {
DiskListResponse,
StartOSDiskInfo,
encodeBase64,
FollowLogsReq,
FollowLogsRes,
Log,
pauseFor,
StartOSDiskInfo,
} from '@start9labs/shared'
import { ApiService } from './api.service'
import * as jose from 'node-jose'
import { T } from '@start9labs/start-sdk'
import {
Observable,
concatMap,
delay,
from,
interval,
map,
mergeScan,
of,
startWith,
switchMap,
switchScan,
takeWhile,
} from 'rxjs'
import * as jose from 'node-jose'
import { interval, map, Observable, of } from 'rxjs'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
import { ApiService } from './api.service'
@Injectable({
providedIn: 'root',
@@ -279,6 +270,24 @@ export class MockApiService extends ApiService {
}
}
async followServerLogs(params: FollowLogsReq): Promise<FollowLogsRes> {
await pauseFor(1000)
return {
startCursor: 'fakestartcursor',
guid: 'fake-guid',
}
}
openLogsWebsocket$(config: WebSocketSubjectConfig<Log>): Observable<Log> {
return interval(500).pipe(
map(() => ({
timestamp: new Date().toISOString(),
message: 'fake log entry',
bootId: 'boot-id',
})),
)
}
async complete(): Promise<T.SetupResult> {
await pauseFor(1000)
return {

View File

@@ -1,37 +1,27 @@
import { TuiLet } from '@taiga-ui/cdk'
import { CommonModule } from '@angular/common'
import {
ChangeDetectionStrategy,
Component,
inject,
Input,
Output,
} from '@angular/core'
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
import { TuiLet } from '@taiga-ui/cdk'
import { TuiProgress } from '@taiga-ui/kit'
import { delay, filter } from 'rxjs'
import { LogsWindowComponent } from './logs-window.component'
import { SetupService } from '../../services/setup.service'
@Component({
standalone: true,
selector: 'app-initializing',
template: `
<section *tuiLet="progress$ | async as progress">
<h1 [style.font-size.rem]="2.5" [style.margin.rem]="1">
Initializing StartOS
<section>
<h1 [style.font-size.rem]="2" [style.margin-bottom.rem]="2">
Setting up your server
</h1>
<div *ngIf="progress" class="center-wrapper">
Progress: {{ (progress * 100).toFixed(0) }}%
<div *ngIf="progress.total">
Progress: {{ (progress.total * 100).toFixed(0) }}%
</div>
<progress
tuiProgressBar
class="progress"
[style.max-width.rem]="40"
[style.margin]="'1rem auto'"
[attr.value]="progress && progress < 1 ? progress : null"
[attr.value]="progress.total"
></progress>
<p>{{ getMessage(progress) }}</p>
<p>{{ progress.message }}</p>
</section>
<logs-window />
`,
@@ -64,28 +54,9 @@ import { SetupService } from '../../services/setup.service'
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class InitializingComponent {
readonly progress$ = inject(SetupService)
@Input()
progress: { total: number; message: string } = { total: 0, message: '' }
@Input()
setupType?: 'fresh' | 'restore' | 'attach' | 'transfer'
@Output()
readonly finished = this.progress$.pipe(
filter(progress => progress === 1),
delay(500),
)
getMessage(progress: number | null): string {
if (['fresh', 'attach'].includes(this.setupType || '')) {
return 'Setting up your server'
}
if (!progress) {
return 'Preparing data. This can take a while'
} else if (progress < 1) {
return 'Copying data'
} else {
return 'Finalizing'
}
}
}

View File

@@ -1,13 +0,0 @@
import { NgModule } from '@angular/core'
import { TuiLoaderModule } from '@taiga-ui/core'
import { tuiAsDialog } from '@taiga-ui/cdk'
import { LoadingComponent } from './loading.component'
import { LoadingService } from './loading.service'
@NgModule({
imports: [TuiLoaderModule],
declarations: [LoadingComponent],
exports: [LoadingComponent],
providers: [tuiAsDialog(LoadingService)],
})
export class LoadingModule {}

View File

@@ -1,25 +0,0 @@
import { SetupStatus } from '../types/api'
import { pauseFor } from '../util/misc.util'
let tries: number | undefined
export async function getSetupStatusMock(): Promise<SetupStatus | null> {
const restoreOrMigrate = true
const total = 4
await pauseFor(1000)
if (tries === undefined) {
tries = 0
return null
}
tries++
const progress = tries - 1
return {
bytesTransferred: restoreOrMigrate ? progress : 0,
totalBytes: restoreOrMigrate ? total : null,
complete: progress === total,
}
}

View File

@@ -9,7 +9,6 @@ export * from './components/initializing/logs-window.component'
export * from './components/initializing/initializing.component'
export * from './components/loading/loading.component'
export * from './components/loading/loading.component'
export * from './components/loading/loading.module'
export * from './components/loading/loading.service'
export * from './components/markdown/markdown.component'
export * from './components/markdown/markdown.component.module'
@@ -20,8 +19,6 @@ export * from './components/drive.component'
export * from './directives/drag-scroller.directive'
export * from './directives/safe-links.directive'
export * from './mocks/get-setup-status'
export * from './pipes/exver/exver.module'
export * from './pipes/exver/exver.pipe'
export * from './pipes/markdown/markdown.module'
@@ -38,7 +35,6 @@ export * from './services/download-html.service'
export * from './services/exver.service'
export * from './services/error.service'
export * from './services/http.service'
export * from './services/setup.service'
export * from './services/setup-logs.service'
export * from './types/api'

View File

@@ -1,59 +0,0 @@
import { inject, StaticClassProvider } from '@angular/core'
import {
catchError,
EMPTY,
exhaustMap,
filter,
from,
interval,
map,
Observable,
shareReplay,
takeWhile,
} from 'rxjs'
import { SetupStatus } from '../types/api'
import { Constructor } from '../types/constructor'
import { ErrorService } from './error.service'
export function provideSetupService(
api: Constructor<ConstructorParameters<typeof SetupService>[0]>,
): StaticClassProvider {
return {
provide: SetupService,
deps: [api],
useClass: SetupService,
}
}
export class SetupService extends Observable<number> {
private readonly errorService = inject(ErrorService)
private readonly progress$ = interval(500).pipe(
exhaustMap(() =>
from(this.api.getSetupStatus()).pipe(
catchError(e => {
this.errorService.handleError(e)
return EMPTY
}),
),
),
filter(Boolean),
map(progress => {
if (progress.complete) {
return 1
}
return progress.totalBytes
? progress.bytesTransferred / progress.totalBytes
: 0
}),
takeWhile(value => value !== 1, true),
shareReplay(1),
)
constructor(
private readonly api: { getSetupStatus: () => Promise<SetupStatus | null> },
) {
super(subscriber => this.progress$.subscribe(subscriber))
}
}

View File

@@ -49,9 +49,3 @@ export type StartOSDiskInfo = {
passwordHash: string | null
wrappedKey: string | null
}
export interface SetupStatus {
bytesTransferred: number
totalBytes: number | null
complete: boolean
}

View File

@@ -1,11 +1,5 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { WorkspaceConfig } from '@start9labs/shared'
import { DiagnosticService } from './services/diagnostic.service'
import { MockDiagnosticService } from './services/mock-diagnostic.service'
import { LiveDiagnosticService } from './services/live-diagnostic.service'
const { useMocks } = require('../../../../../../config.json') as WorkspaceConfig
const ROUTES: Routes = [
{
@@ -22,11 +16,5 @@ const ROUTES: Routes = [
@NgModule({
imports: [RouterModule.forChild(ROUTES)],
providers: [
{
provide: DiagnosticService,
useClass: useMocks ? MockDiagnosticService : LiveDiagnosticService,
},
],
})
export class DiagnosticModule {}

View File

@@ -1,17 +1,10 @@
<<<<<<<< HEAD:web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
import { TUI_CONFIRM } from '@taiga-ui/kit'
import { Component, Inject } from '@angular/core'
import { WINDOW } from '@ng-web-apis/common'
import { LoadingService } from '@start9labs/shared'
import { TuiDialogService } from '@taiga-ui/core'
import { filter } from 'rxjs'
import { DiagnosticService } from '../services/diagnostic.service'
========
import { Component } from '@angular/core'
import { AlertController } from '@ionic/angular'
import { LoadingService } from '@start9labs/shared'
import { ApiService } from 'src/app/services/api/embassy-api.service'
>>>>>>>> 94a5075b6daa1375433420abf5d121171dae72cb:web/projects/ui/src/app/pages/diagnostic-routes/home/home.page.ts
@Component({
selector: 'diagnostic-home',
@@ -29,14 +22,9 @@ export class HomePage {
constructor(
private readonly loader: LoadingService,
<<<<<<<< HEAD:web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
private readonly api: DiagnosticService,
private readonly api: ApiService,
private readonly dialogs: TuiDialogService,
@Inject(WINDOW) private readonly window: Window,
========
private readonly api: ApiService,
private readonly alertCtrl: AlertController,
>>>>>>>> 94a5075b6daa1375433420abf5d121171dae72cb:web/projects/ui/src/app/pages/diagnostic-routes/home/home.page.ts
) {}
async ngOnInit() {
@@ -102,11 +90,7 @@ export class HomePage {
}
async restart(): Promise<void> {
<<<<<<<< HEAD:web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
const loader = this.loader.open('').subscribe()
========
const loader = this.loader.open('Loading...').subscribe()
>>>>>>>> 94a5075b6daa1375433420abf5d121171dae72cb:web/projects/ui/src/app/pages/diagnostic-routes/home/home.page.ts
try {
await this.api.diagnosticRestart()
@@ -119,11 +103,7 @@ export class HomePage {
}
async forgetDrive(): Promise<void> {
<<<<<<<< HEAD:web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
const loader = this.loader.open('').subscribe()
========
const loader = this.loader.open('Loading...').subscribe()
>>>>>>>> 94a5075b6daa1375433420abf5d121171dae72cb:web/projects/ui/src/app/pages/diagnostic-routes/home/home.page.ts
try {
await this.api.diagnosticForgetDrive()
@@ -136,31 +116,6 @@ export class HomePage {
}
}
<<<<<<<< HEAD:web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
async presentAlertSystemRebuild() {
this.dialogs
.open(TUI_CONFIRM, {
label: 'Warning',
size: 's',
data: {
no: 'Cancel',
yes: 'Rebuild',
content:
'<p>This action will tear down all service containers and rebuild them from scratch. No data will be deleted.</p><p>A system rebuild can be useful if your system gets into a bad state, and it should only be performed if you are experiencing general performance or reliability issues.</p><p>It may take up to an hour to complete. During this time, you will lose all connectivity to your Start9 server.</p>',
},
})
.pipe(filter(Boolean))
.subscribe(() => {
try {
this.systemRebuild()
} catch (e) {
console.error(e)
}
})
}
========
>>>>>>>> 94a5075b6daa1375433420abf5d121171dae72cb:web/projects/ui/src/app/pages/diagnostic-routes/home/home.page.ts
async presentAlertRepairDisk() {
this.dialogs
.open(TUI_CONFIRM, {
@@ -187,27 +142,8 @@ export class HomePage {
this.window.location.reload()
}
<<<<<<<< HEAD:web/projects/ui/src/app/routes/diagnostic/home/home.page.ts
private async systemRebuild(): Promise<void> {
const loader = this.loader.open('').subscribe()
try {
await this.api.systemRebuild()
await this.api.restart()
this.restarted = true
} catch (e) {
console.error(e)
} finally {
loader.unsubscribe()
}
}
private async repairDisk(): Promise<void> {
const loader = this.loader.open('').subscribe()
========
private async repairDisk(): Promise<void> {
const loader = this.loader.open('Loading...').subscribe()
>>>>>>>> 94a5075b6daa1375433420abf5d121171dae72cb:web/projects/ui/src/app/pages/diagnostic-routes/home/home.page.ts
try {
await this.api.diagnosticRepairDisk()

View File

@@ -2,7 +2,7 @@ import { Component, ElementRef, inject, OnInit, ViewChild } from '@angular/core'
import { INTERSECTION_ROOT } from '@ng-web-apis/intersection-observer'
import { convertAnsi, ErrorService } from '@start9labs/shared'
import { TuiScrollbar } from '@taiga-ui/core'
import { DiagnosticService } from 'src/app/routes/diagnostic/services/diagnostic.service'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Component({
selector: 'logs',
@@ -28,7 +28,7 @@ import { DiagnosticService } from 'src/app/routes/diagnostic/services/diagnostic
export class LogsPage implements OnInit {
@ViewChild(TuiScrollbar, { read: ElementRef })
private readonly scrollbar?: ElementRef<HTMLElement>
private readonly api = inject(DiagnosticService)
private readonly api = inject(ApiService)
private readonly errorService = inject(ErrorService)
startCursor?: string
@@ -59,7 +59,7 @@ export class LogsPage implements OnInit {
this.loading = true
try {
const response = await this.api.getLogs({
const response = await this.api.diagnosticGetLogs({
cursor: this.startCursor,
before: !!this.startCursor,
limit: 200,

View File

@@ -5,7 +5,6 @@ import {
provideSetupLogsService,
provideSetupService,
} from '@start9labs/shared'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Component({

View File

@@ -1,13 +1,12 @@
import { Observable } from 'rxjs'
import { RR, BackupTargetType, Metrics } from './api.types'
import { SetupStatus } from '@start9labs/shared'
import { RPCOptions } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import {
GetPackageRes,
GetPackagesRes,
MarketplacePkg,
} from '@start9labs/marketplace'
import { RPCOptions } from '@start9labs/shared'
import { T } from '@start9labs/start-sdk'
import { Observable } from 'rxjs'
import { BackupTargetType, RR } from './api.types'
export abstract class ApiService {
// http

View File

@@ -2,7 +2,6 @@ import { Injectable } from '@angular/core'
import {
pauseFor,
Log,
getSetupStatusMock,
RPCErrorDetails,
RPCOptions,
} from '@start9labs/shared'

View File

@@ -1,60 +1,5 @@
/**
* This file includes polyfills needed by Angular and is loaded before the app.
* You can add your own extra polyfills to this file.
*
* This file is divided into 2 sections:
* 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers.
* 2. Application imports. Files imported after ZoneJS that should be loaded before your main
* file.
*
* The current setup is for so-called "evergreen" browsers; the last versions of browsers that
* automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera),
* Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile.
*
* Learn more in https://angular.io/guide/browser-support
*/
import { Buffer } from 'buffer'
/***************************************************************************************************
* BROWSER POLYFILLS
*/
/** IE10 and IE11 requires the following for NgClass support on SVG elements */
// import 'classlist.js'; // Run `npm install --save classlist.js`.
/**
* Web Animations `@angular/platform-browser/animations`
* Only required if AnimationBuilder is used within the application and using IE/Edge or Safari.
* Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0).
*/
// import 'web-animations-js'; // Run `npm install --save web-animations-js`.
/**
* By default, zone.js will patch all possible macroTask and DomEvents
* user can disable parts of macroTask/DomEvents patch by setting following flags
* because those flags need to be set before `zone.js` being loaded, and webpack
* will put import in the top of bundle, so user need to create a separate file
* in this directory (for example: zone-flags.ts), and put the following flags
* into that file, and then add the following code before importing zone.js.
* import './zone-flags.ts';
*
* The flags allowed in zone-flags.ts are listed here.
*
* The following flags will work for all browsers.
*
* (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame
* (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick
* (window as any).__zone_symbol__BLACK_LISTED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames
*
* in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js
* with the following flag, it will bypass `zone.js` patch for IE/Edge
*
* (window as any).__Zone_enable_cross_context_check = true;
*
*/
// @TODO Alex include in angular.json
;(window as any).global = window
;(window as any).process = { env: { DEBUG: undefined }, browser: true }
import { Buffer } from 'buffer'
;(window as any).Buffer = Buffer