mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 04:01:58 +00:00
Feature/consolidate setup (#3092)
* start consolidating * add start-cli flash-os * combine install and setup and refactor all * use http * undo mock * fix translation * translations * use dialogservice wrapper * better ST messaging on setup * only warn on update if breakages (#3097) * finish setup wizard and ui language-keyboard feature * fix typo * wip: localization * remove start-tunnel readme * switch to posix strings for language internal * revert mock * translate backend strings * fix missing about text * help text for args * feat: add "Add new gateway" option (#3098) * feat: add "Add new gateway" option * Update web/projects/ui/src/app/routes/portal/components/form/controls/select.component.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * add translation --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Matt Hill <mattnine@protonmail.com> * fix dns selection * keyboard keymap also * ability to shutdown after install * revert mock * working setup flow + manifest localization * (mostly) redundant localization on frontend * version bump * omit live medium from disk list and better space management * ignore missing package archive on 035 migration * fix device migration * add i18n helper to sdk * fix install over 0.3.5.1 * fix grub config --------- Co-authored-by: Matt Hill <mattnine@protonmail.com> Co-authored-by: Matt Hill <MattDHill@users.noreply.github.com> Co-authored-by: Alex Inkin <alexander@inkin.ru> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
import { Component, inject, DOCUMENT } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { ApiService } from 'src/app/services/api.service'
|
||||
import { ApiService } from './services/api.service'
|
||||
import { StateService } from './services/state.service'
|
||||
|
||||
@Component({
|
||||
@@ -18,19 +18,49 @@ export class AppComponent {
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
// Determine if we're in kiosk mode
|
||||
this.stateService.kiosk = ['localhost', '127.0.0.1'].includes(
|
||||
this.document.location.hostname,
|
||||
)
|
||||
|
||||
const inProgress = await this.api.getStatus()
|
||||
// Get pubkey for encryption
|
||||
await this.api.getPubKey()
|
||||
|
||||
let route = 'home'
|
||||
// Check setup status to determine initial route
|
||||
const status = await this.api.getStatus()
|
||||
|
||||
if (inProgress) {
|
||||
route = inProgress.status === 'complete' ? '/success' : '/loading'
|
||||
switch (status.status) {
|
||||
case 'needs-install':
|
||||
// Restore keyboard from status if it was previously set
|
||||
if (status.keyboard) {
|
||||
this.stateService.keyboard = status.keyboard.layout
|
||||
}
|
||||
// Start the install flow
|
||||
await this.router.navigate(['/language'])
|
||||
break
|
||||
|
||||
case 'incomplete':
|
||||
// Store the data drive info from status
|
||||
this.stateService.dataDriveGuid = status.guid
|
||||
this.stateService.attach = status.attach
|
||||
// Restore keyboard from status if it was previously set
|
||||
if (status.keyboard) {
|
||||
this.stateService.keyboard = status.keyboard.layout
|
||||
}
|
||||
|
||||
await this.router.navigate(['/language'])
|
||||
break
|
||||
|
||||
case 'running':
|
||||
// Setup is in progress, show loading page
|
||||
await this.router.navigate(['/loading'])
|
||||
break
|
||||
|
||||
case 'complete':
|
||||
// Setup execution finished, show success page
|
||||
await this.router.navigate(['/success'])
|
||||
break
|
||||
}
|
||||
|
||||
await this.router.navigate([route])
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { PreloadAllModules, RouterModule } from '@angular/router'
|
||||
import { WA_LOCATION } from '@ng-web-apis/common'
|
||||
import initArgon from '@start9labs/argon2'
|
||||
import {
|
||||
I18N_PROVIDERS,
|
||||
provideSetupLogsService,
|
||||
RELATIVE_URL,
|
||||
VERSION,
|
||||
@@ -16,9 +17,9 @@ import {
|
||||
} from '@start9labs/shared'
|
||||
import { tuiButtonOptionsProvider, TuiRoot } from '@taiga-ui/core'
|
||||
import { NG_EVENT_PLUGINS } from '@taiga-ui/event-plugins'
|
||||
import { ApiService } from 'src/app/services/api.service'
|
||||
import { LiveApiService } from 'src/app/services/live-api.service'
|
||||
import { MockApiService } from 'src/app/services/mock-api.service'
|
||||
import { ApiService } from './services/api.service'
|
||||
import { LiveApiService } from './services/live-api.service'
|
||||
import { MockApiService } from './services/mock-api.service'
|
||||
import { AppComponent } from './app.component'
|
||||
import { ROUTES } from './app.routes'
|
||||
|
||||
@@ -41,6 +42,7 @@ const version = require('../../../../package.json').version
|
||||
],
|
||||
providers: [
|
||||
NG_EVENT_PLUGINS,
|
||||
I18N_PROVIDERS,
|
||||
provideSetupLogsService(ApiService),
|
||||
tuiButtonOptionsProvider({ size: 'm' }),
|
||||
{
|
||||
|
||||
@@ -1,33 +1,48 @@
|
||||
import { Routes } from '@angular/router'
|
||||
|
||||
export const ROUTES: Routes = [
|
||||
{ path: '', redirectTo: '/home', pathMatch: 'full' },
|
||||
// Entry point - app.component handles initial routing based on setup.status
|
||||
{ path: '', redirectTo: '/language', pathMatch: 'full' },
|
||||
|
||||
// Install flow
|
||||
{
|
||||
path: 'language',
|
||||
loadComponent: () => import('./pages/language.page'),
|
||||
},
|
||||
{
|
||||
path: 'keyboard',
|
||||
loadComponent: () => import('./pages/keyboard.page'),
|
||||
},
|
||||
{
|
||||
path: 'drives',
|
||||
loadComponent: () => import('./pages/drives.page'),
|
||||
},
|
||||
|
||||
// Setup flow (after install or for pre-installed devices)
|
||||
{
|
||||
path: 'home',
|
||||
loadComponent: () => import('src/app/pages/home.page'),
|
||||
loadComponent: () => import('./pages/home.page'),
|
||||
},
|
||||
{
|
||||
path: 'attach',
|
||||
loadComponent: () => import('src/app/pages/attach.page'),
|
||||
},
|
||||
{
|
||||
path: 'recover',
|
||||
loadComponent: () => import('src/app/pages/recover.page'),
|
||||
path: 'restore',
|
||||
loadComponent: () => import('./pages/restore.page'),
|
||||
},
|
||||
{
|
||||
path: 'transfer',
|
||||
loadComponent: () => import('src/app/pages/transfer.page'),
|
||||
loadComponent: () => import('./pages/transfer.page'),
|
||||
},
|
||||
{
|
||||
path: 'storage',
|
||||
loadComponent: () => import('src/app/pages/storage.page'),
|
||||
path: 'password',
|
||||
loadComponent: () => import('./pages/password.page'),
|
||||
},
|
||||
|
||||
// Shared
|
||||
{
|
||||
path: 'loading',
|
||||
loadComponent: () => import('src/app/pages/loading.page'),
|
||||
loadComponent: () => import('./pages/loading.page'),
|
||||
},
|
||||
{
|
||||
path: 'success',
|
||||
loadComponent: () => import('src/app/pages/success.page'),
|
||||
loadComponent: () => import('./pages/success.page'),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -3,39 +3,38 @@ import { Component, inject } from '@angular/core'
|
||||
import {
|
||||
FormControl,
|
||||
FormGroup,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms'
|
||||
import { i18nKey, LoadingService, StartOSDiskInfo } from '@start9labs/shared'
|
||||
import { DialogService, i18nPipe } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDialogContext,
|
||||
TuiDialogService,
|
||||
TuiError,
|
||||
TuiIcon,
|
||||
TuiTextfield,
|
||||
} from '@taiga-ui/core'
|
||||
import {
|
||||
TUI_VALIDATION_ERRORS,
|
||||
TuiButtonLoading,
|
||||
TuiFieldErrorPipe,
|
||||
TuiPassword,
|
||||
} from '@taiga-ui/kit'
|
||||
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import { SERVERS, ServersResponse } from 'src/app/components/servers.component'
|
||||
import { ApiService } from 'src/app/services/api.service'
|
||||
import { ApiService } from '../services/api.service'
|
||||
import { StartOSDiskInfoWithId } from '../types'
|
||||
|
||||
export interface CifsResponse {
|
||||
export interface CifsResult {
|
||||
cifs: T.Cifs
|
||||
serverId: string
|
||||
password: string
|
||||
servers: StartOSDiskInfoWithId[]
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
<tui-textfield>
|
||||
<label tuiLabel>Hostname *</label>
|
||||
<label tuiLabel>{{ 'Hostname' | i18n }}*</label>
|
||||
<input
|
||||
tuiTextfield
|
||||
formControlName="hostname"
|
||||
@@ -48,17 +47,17 @@ export interface CifsResponse {
|
||||
/>
|
||||
|
||||
<tui-textfield class="input">
|
||||
<label tuiLabel>Path *</label>
|
||||
<label tuiLabel>{{ 'Path' | i18n }}*</label>
|
||||
<input
|
||||
tuiTextfield
|
||||
formControlName="path"
|
||||
placeholder="/Desktop/my-folder'"
|
||||
placeholder="/Desktop/my-folder"
|
||||
/>
|
||||
</tui-textfield>
|
||||
<tui-error formControlName="path" [error]="[] | tuiFieldError | async" />
|
||||
|
||||
<tui-textfield class="input">
|
||||
<label tuiLabel>Username *</label>
|
||||
<label tuiLabel>{{ 'Username' | i18n }}*</label>
|
||||
<input
|
||||
tuiTextfield
|
||||
formControlName="username"
|
||||
@@ -71,7 +70,7 @@ export interface CifsResponse {
|
||||
/>
|
||||
|
||||
<tui-textfield class="input">
|
||||
<label tuiLabel>Password</label>
|
||||
<label tuiLabel>{{ 'Password' | i18n }}</label>
|
||||
<input tuiTextfield type="password" formControlName="password" />
|
||||
<tui-icon tuiPassword />
|
||||
</tui-textfield>
|
||||
@@ -81,11 +80,14 @@ export interface CifsResponse {
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
type="button"
|
||||
[disabled]="connecting"
|
||||
(click)="cancel()"
|
||||
>
|
||||
Cancel
|
||||
{{ 'Cancel' | i18n }}
|
||||
</button>
|
||||
<button tuiButton [disabled]="form.invalid" [loading]="connecting">
|
||||
{{ 'Connect' | i18n }}
|
||||
</button>
|
||||
<button tuiButton [disabled]="form.invalid">Verify</button>
|
||||
</footer>
|
||||
</form>
|
||||
`,
|
||||
@@ -97,18 +99,20 @@ export interface CifsResponse {
|
||||
footer {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
TuiButton,
|
||||
TuiButtonLoading,
|
||||
TuiTextfield,
|
||||
TuiPassword,
|
||||
TuiError,
|
||||
TuiFieldErrorPipe,
|
||||
TuiIcon,
|
||||
i18nPipe,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
@@ -120,10 +124,11 @@ export interface CifsResponse {
|
||||
],
|
||||
})
|
||||
export class CifsComponent {
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly dialogs = inject(DialogService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly context = injectContext<TuiDialogContext<CifsResponse>>()
|
||||
private readonly context = injectContext<TuiDialogContext<CifsResult>>()
|
||||
|
||||
connecting = false
|
||||
|
||||
readonly form = new FormGroup({
|
||||
hostname: new FormControl('', {
|
||||
@@ -149,9 +154,7 @@ export class CifsComponent {
|
||||
}
|
||||
|
||||
async submit(): Promise<void> {
|
||||
const loader = this.loader
|
||||
.open('Connecting to shared folder' as i18nKey)
|
||||
.subscribe()
|
||||
this.connecting = true
|
||||
|
||||
try {
|
||||
const diskInfo = await this.api.verifyCifs({
|
||||
@@ -161,36 +164,25 @@ export class CifsComponent {
|
||||
: null,
|
||||
})
|
||||
|
||||
loader.unsubscribe()
|
||||
const servers = Object.keys(diskInfo).map(id => ({
|
||||
id,
|
||||
...diskInfo[id]!,
|
||||
}))
|
||||
|
||||
this.selectServer(diskInfo)
|
||||
this.context.completeWith({
|
||||
cifs: { ...this.form.getRawValue() },
|
||||
servers,
|
||||
})
|
||||
} catch (e) {
|
||||
loader.unsubscribe()
|
||||
this.connecting = false
|
||||
this.onFail()
|
||||
}
|
||||
}
|
||||
|
||||
private selectServer(servers: Record<string, StartOSDiskInfo>) {
|
||||
this.dialogs
|
||||
.open<ServersResponse>(SERVERS, {
|
||||
label: 'Select Server to Restore',
|
||||
data: {
|
||||
servers: Object.keys(servers).map(id => ({ id, ...servers[id] })),
|
||||
},
|
||||
})
|
||||
.subscribe(({ password, serverId }) => {
|
||||
this.context.completeWith({
|
||||
cifs: { ...this.form.getRawValue() },
|
||||
serverId,
|
||||
password,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
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.',
|
||||
.openAlert(
|
||||
'Unable to connect to network folder. Ensure (1) target computer is connected to LAN, (2) target folder is being shared, and (3) hostname, path, and credentials are accurate.',
|
||||
{
|
||||
label: 'Connection Failed',
|
||||
size: 's',
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import {
|
||||
AbstractControl,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms'
|
||||
import { verify } from '@start9labs/argon2'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
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 {
|
||||
passwordHash?: string
|
||||
storageDrive?: boolean
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
@if (storageDrive) {
|
||||
Choose a password for your server.
|
||||
<em>Make it good. Write it down.</em>
|
||||
} @else {
|
||||
Enter the password that was used to encrypt this drive.
|
||||
}
|
||||
|
||||
<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-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
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
type="button"
|
||||
(click)="cancel()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button tuiButton [disabled]="form.invalid">
|
||||
{{ storageDrive ? 'Finish' : 'Unlock' }}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
`,
|
||||
styles: `
|
||||
footer {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
ReactiveFormsModule,
|
||||
TuiButton,
|
||||
TuiError,
|
||||
TuiAutoFocus,
|
||||
TuiFieldErrorPipe,
|
||||
TuiTextfield,
|
||||
TuiPassword,
|
||||
TuiValidator,
|
||||
TuiIcon,
|
||||
TuiMapperPipe,
|
||||
],
|
||||
providers: [
|
||||
tuiValidationErrorsProvider({
|
||||
required: 'Required',
|
||||
minlength: 'Must be 12 characters or greater',
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class PasswordComponent {
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly context =
|
||||
injectContext<TuiDialogContext<string, DialogData>>()
|
||||
|
||||
readonly storageDrive = this.context.data.storageDrive
|
||||
readonly form = new FormGroup({
|
||||
password: new FormControl('', [
|
||||
Validators.required,
|
||||
Validators.minLength(12),
|
||||
]),
|
||||
confirm: new FormControl('', this.storageDrive ? Validators.required : []),
|
||||
})
|
||||
|
||||
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(password)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
verify(this.context.data.passwordHash || '', password)
|
||||
this.context.completeWith(password)
|
||||
} catch (e) {
|
||||
this.errorService.handleError('Incorrect password provided')
|
||||
}
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.context.$implicit.complete()
|
||||
}
|
||||
}
|
||||
|
||||
export const PASSWORD = new PolymorpheusComponent(PasswordComponent)
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Directive, ElementRef, inject, input, Output } from '@angular/core'
|
||||
import { StartOSDiskInfo } from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { filter, fromEvent, switchMap } from 'rxjs'
|
||||
import { PASSWORD } from 'src/app/components/password.component'
|
||||
|
||||
@Directive({
|
||||
selector: 'button[server][password]',
|
||||
})
|
||||
export class PasswordDirective {
|
||||
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),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import { TuiButton } from '@taiga-ui/core'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
import { injectContext } from '@taiga-ui/polymorpheus'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [TuiButton, i18nPipe],
|
||||
template: `
|
||||
<p>{{ 'This drive contains existing StartOS data.' | i18n }}</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong class="g-positive">{{ 'Preserve' | i18n }}</strong>
|
||||
{{ 'to keep your data.' | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
<strong class="g-negative">{{ 'Overwrite' | i18n }}</strong>
|
||||
{{ 'to discard' | i18n }}
|
||||
</li>
|
||||
</ul>
|
||||
<footer>
|
||||
<button
|
||||
tuiButton
|
||||
appearance="flat-destructive"
|
||||
(click)="context.completeWith(false)"
|
||||
>
|
||||
{{ 'Overwrite' | i18n }}
|
||||
</button>
|
||||
<button
|
||||
tuiButton
|
||||
class="preserve-btn"
|
||||
(click)="context.completeWith(true)"
|
||||
>
|
||||
{{ 'Preserve' | i18n }}
|
||||
</button>
|
||||
</footer>
|
||||
`,
|
||||
styles: `
|
||||
p {
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
margin-top: 2rem;
|
||||
gap: 0.5rem;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.preserve-btn {
|
||||
background: var(--tui-status-positive) !important;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class PreserveOverwriteDialog {
|
||||
protected readonly context = injectContext<TuiDialogContext<boolean>>()
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { TuiCell } from '@taiga-ui/layout'
|
||||
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { RouterModule } from '@angular/router'
|
||||
|
||||
@Component({
|
||||
selector: 'app-recover',
|
||||
template: `
|
||||
<a tuiCell [routerLink]="disabled ? null : '/attach'">
|
||||
<tui-icon icon="@tui.box" />
|
||||
<span tuiTitle>
|
||||
<span class="g-positive">Use Existing Drive</span>
|
||||
<span tuiSubtitle>
|
||||
Attach an existing StartOS data drive (not a backup)
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<a tuiCell [routerLink]="disabled ? null : '/transfer'">
|
||||
<tui-icon icon="@tui.share" />
|
||||
<span tuiTitle>
|
||||
<span class="g-info">Transfer</span>
|
||||
<span tuiSubtitle>
|
||||
Transfer data from an existing StartOS data drive (not a backup) to a
|
||||
new, preferred drive
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<a tuiCell [routerLink]="disabled ? null : '/recover'">
|
||||
<tui-icon icon="@tui.save" />
|
||||
<span tuiTitle>
|
||||
<span class="g-warning">Restore From Backup (Disaster Recovery)</span>
|
||||
<span tuiSubtitle>Restore StartOS data from an encrypted backup</span>
|
||||
</span>
|
||||
</a>
|
||||
`,
|
||||
imports: [RouterModule, TuiIcon, TuiCell, TuiTitle],
|
||||
})
|
||||
export class RecoverComponent {
|
||||
@Input() disabled = false
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import { TuiDialogContext, TuiTextfield } from '@taiga-ui/core'
|
||||
import { TuiDataListWrapper, TuiSelect } from '@taiga-ui/kit'
|
||||
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import { StartOSDiskInfoWithId } from '../types'
|
||||
|
||||
interface Data {
|
||||
servers: StartOSDiskInfoWithId[]
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [FormsModule, TuiTextfield, TuiSelect, TuiDataListWrapper, i18nPipe],
|
||||
template: `
|
||||
<p>{{ 'Multiple backups found. Select which one to restore.' | i18n }}</p>
|
||||
<tui-textfield [stringify]="stringify">
|
||||
<label tuiLabel>{{ 'Backups' | i18n }}</label>
|
||||
<input tuiSelect [(ngModel)]="selectedServer" />
|
||||
<tui-data-list-wrapper
|
||||
new
|
||||
*tuiTextfieldDropdown
|
||||
[items]="context.data.servers"
|
||||
[itemContent]="serverContent"
|
||||
/>
|
||||
</tui-textfield>
|
||||
|
||||
<ng-template #serverContent let-server>
|
||||
<div class="server-item">
|
||||
<span>{{ server.id }}</span>
|
||||
<!-- @TODO eos-version? -->
|
||||
<small>{{ server['eos-version'] }}</small>
|
||||
</div>
|
||||
</ng-template>
|
||||
`,
|
||||
styles: `
|
||||
.server-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
small {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class SelectNetworkBackupDialog {
|
||||
protected readonly context =
|
||||
injectContext<TuiDialogContext<StartOSDiskInfoWithId | null, Data>>()
|
||||
|
||||
private _selectedServer: StartOSDiskInfoWithId | null = null
|
||||
|
||||
get selectedServer(): StartOSDiskInfoWithId | null {
|
||||
return this._selectedServer
|
||||
}
|
||||
|
||||
set selectedServer(value: StartOSDiskInfoWithId | null) {
|
||||
this._selectedServer = value
|
||||
|
||||
if (value) {
|
||||
this.context.completeWith(value)
|
||||
}
|
||||
}
|
||||
|
||||
readonly stringify = (server: StartOSDiskInfoWithId | null) =>
|
||||
server ? server.id : ''
|
||||
}
|
||||
|
||||
export const SELECT_NETWORK_BACKUP = new PolymorpheusComponent(
|
||||
SelectNetworkBackupDialog,
|
||||
)
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { ServerComponent } from '@start9labs/shared'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import { PasswordDirective } from 'src/app/components/password.directive'
|
||||
import { StartOSDiskInfoWithId } from 'src/app/services/api.service'
|
||||
|
||||
interface Data {
|
||||
servers: StartOSDiskInfoWithId[]
|
||||
}
|
||||
|
||||
export interface ServersResponse {
|
||||
password: string
|
||||
serverId: string
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
@for (server of context.data.servers; track $index) {
|
||||
<button [server]="server" (password)="select($event, server.id)"></button>
|
||||
}
|
||||
`,
|
||||
imports: [ServerComponent, PasswordDirective],
|
||||
})
|
||||
export class ServersComponent {
|
||||
readonly context = injectContext<TuiDialogContext<ServersResponse, Data>>()
|
||||
|
||||
select(password: string, serverId: string) {
|
||||
this.context.completeWith({ serverId, password })
|
||||
}
|
||||
}
|
||||
|
||||
export const SERVERS = new PolymorpheusComponent(ServersComponent)
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDialogContext,
|
||||
TuiIcon,
|
||||
TuiTextfield,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiPassword } from '@taiga-ui/kit'
|
||||
import { injectContext } from '@taiga-ui/polymorpheus'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
TuiButton,
|
||||
TuiTextfield,
|
||||
TuiPassword,
|
||||
TuiIcon,
|
||||
i18nPipe,
|
||||
],
|
||||
template: `
|
||||
<p>
|
||||
{{ 'Enter the password that was used to encrypt this backup.' | i18n }}
|
||||
</p>
|
||||
<tui-textfield>
|
||||
<label tuiLabel>{{ 'Password' | i18n }}</label>
|
||||
<input
|
||||
tuiTextfield
|
||||
type="password"
|
||||
[(ngModel)]="password"
|
||||
(keyup.enter)="unlock()"
|
||||
/>
|
||||
<tui-icon tuiPassword />
|
||||
</tui-textfield>
|
||||
<footer>
|
||||
<button tuiButton appearance="flat" (click)="context.completeWith(null)">
|
||||
{{ 'Cancel' | i18n }}
|
||||
</button>
|
||||
<button tuiButton [disabled]="!password" (click)="unlock()">
|
||||
{{ 'Unlock' | i18n }}
|
||||
</button>
|
||||
</footer>
|
||||
`,
|
||||
styles: `
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class UnlockPasswordDialog {
|
||||
protected readonly context = injectContext<TuiDialogContext<string | null>>()
|
||||
|
||||
password = ''
|
||||
|
||||
unlock() {
|
||||
if (this.password) {
|
||||
this.context.completeWith(this.password)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import {
|
||||
DiskInfo,
|
||||
DriveComponent,
|
||||
ErrorService,
|
||||
i18nKey,
|
||||
LoadingService,
|
||||
toGuid,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiButton, TuiDialogService, TuiLoader } from '@taiga-ui/core'
|
||||
import { TuiCardLarge, TuiCell } from '@taiga-ui/layout'
|
||||
import { PASSWORD } from 'src/app/components/password.component'
|
||||
import { ApiService } from 'src/app/services/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<section tuiCardLarge="compact">
|
||||
<header>Use existing drive</header>
|
||||
<div>Select the physical drive containing your StartOS data</div>
|
||||
|
||||
@if (loading) {
|
||||
<tui-loader />
|
||||
} @else {
|
||||
@for (drive of drives; track drive) {
|
||||
<button tuiCell [drive]="drive" (click)="select(drive)"></button>
|
||||
} @empty {
|
||||
No valid StartOS data drives found. Please make sure the drive is a
|
||||
valid StartOS data drive (not a backup) and is firmly connected, then
|
||||
refresh the page.
|
||||
}
|
||||
<footer>
|
||||
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
|
||||
Refresh
|
||||
</button>
|
||||
</footer>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
imports: [TuiButton, TuiCardLarge, TuiCell, TuiLoader, DriveComponent],
|
||||
})
|
||||
export default class AttachPage {
|
||||
private readonly apiService = inject(ApiService)
|
||||
private readonly router = inject(Router)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly stateService = inject(StateService)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
|
||||
loading = true
|
||||
drives: DiskInfo[] = []
|
||||
|
||||
async ngOnInit() {
|
||||
this.stateService.setupType = 'attach'
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.loading = true
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
async getDrives() {
|
||||
try {
|
||||
this.drives = await this.apiService
|
||||
.getDrives()
|
||||
.then(drives => drives.filter(toGuid))
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
select(disk: DiskInfo) {
|
||||
this.dialogs
|
||||
.open<string>(PASSWORD, {
|
||||
label: 'Set Password',
|
||||
size: 's',
|
||||
data: { storageDrive: true },
|
||||
})
|
||||
.subscribe(password => {
|
||||
this.attachDrive(toGuid(disk) || '', password)
|
||||
})
|
||||
}
|
||||
|
||||
private async attachDrive(guid: string, password: string) {
|
||||
const loader = this.loader
|
||||
.open('Connecting to drive' as i18nKey)
|
||||
.subscribe()
|
||||
|
||||
try {
|
||||
await this.stateService.importDrive(guid, password)
|
||||
await this.router.navigate([`loading`])
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
395
web/projects/setup-wizard/src/app/pages/drives.page.ts
Normal file
395
web/projects/setup-wizard/src/app/pages/drives.page.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
import { ChangeDetectorRef, Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import {
|
||||
DialogService,
|
||||
DiskInfo,
|
||||
ErrorService,
|
||||
i18nKey,
|
||||
i18nPipe,
|
||||
LoadingService,
|
||||
toGuid,
|
||||
} from '@start9labs/shared'
|
||||
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiIcon,
|
||||
TuiLoader,
|
||||
TuiTextfield,
|
||||
TuiTitle,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiDataListWrapper, TuiSelect, TuiTooltip } from '@taiga-ui/kit'
|
||||
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
|
||||
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import { filter } from 'rxjs'
|
||||
import { ApiService } from '../services/api.service'
|
||||
import { StateService } from '../services/state.service'
|
||||
import { PreserveOverwriteDialog } from '../components/preserve-overwrite.dialog'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<section tuiCardLarge="compact">
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>{{ 'Select Drives' | i18n }}</h2>
|
||||
</header>
|
||||
|
||||
@if (loading) {
|
||||
<tui-loader />
|
||||
} @else if (drives.length === 0) {
|
||||
<p class="no-drives">
|
||||
{{
|
||||
'No drives found. Please connect a drive and click Refresh.' | i18n
|
||||
}}
|
||||
</p>
|
||||
} @else {
|
||||
<tui-textfield [stringify]="stringify">
|
||||
<label tuiLabel>{{ 'OS Drive' | i18n }}</label>
|
||||
@if (mobile) {
|
||||
<select
|
||||
tuiSelect
|
||||
[(ngModel)]="selectedOsDrive"
|
||||
[items]="drives"
|
||||
></select>
|
||||
} @else {
|
||||
<input tuiSelect [(ngModel)]="selectedOsDrive" />
|
||||
}
|
||||
@if (!mobile) {
|
||||
<tui-data-list-wrapper
|
||||
new
|
||||
*tuiTextfieldDropdown
|
||||
[items]="drives"
|
||||
[itemContent]="driveContent"
|
||||
/>
|
||||
}
|
||||
<tui-icon [tuiTooltip]="osDriveTooltip" />
|
||||
</tui-textfield>
|
||||
|
||||
<tui-textfield [stringify]="stringify">
|
||||
<label tuiLabel>{{ 'Data Drive' | i18n }}</label>
|
||||
@if (mobile) {
|
||||
<select
|
||||
tuiSelect
|
||||
[(ngModel)]="selectedDataDrive"
|
||||
(ngModelChange)="onDataDriveChange($event)"
|
||||
[items]="drives"
|
||||
></select>
|
||||
} @else {
|
||||
<input
|
||||
tuiSelect
|
||||
[(ngModel)]="selectedDataDrive"
|
||||
(ngModelChange)="onDataDriveChange($event)"
|
||||
/>
|
||||
}
|
||||
@if (!mobile) {
|
||||
<tui-data-list-wrapper
|
||||
new
|
||||
*tuiTextfieldDropdown
|
||||
[items]="drives"
|
||||
[itemContent]="driveContent"
|
||||
/>
|
||||
}
|
||||
@if (preserveData === true) {
|
||||
<tui-icon
|
||||
icon="@tui.database"
|
||||
style="color: var(--tui-status-positive); pointer-events: none"
|
||||
/>
|
||||
}
|
||||
@if (preserveData === false) {
|
||||
<tui-icon
|
||||
icon="@tui.database-zap"
|
||||
style="color: var(--tui-status-negative); pointer-events: none"
|
||||
/>
|
||||
}
|
||||
<tui-icon [tuiTooltip]="dataDriveTooltip" />
|
||||
</tui-textfield>
|
||||
|
||||
<ng-template #driveContent let-drive>
|
||||
<div class="drive-item">
|
||||
<span class="drive-name">
|
||||
{{ drive.vendor || ('Unknown' | i18n) }}
|
||||
{{ drive.model || ('Drive' | i18n) }}
|
||||
</span>
|
||||
<small>
|
||||
{{ formatCapacity(drive.capacity) }} · {{ drive.logicalname }}
|
||||
</small>
|
||||
</div>
|
||||
</ng-template>
|
||||
}
|
||||
|
||||
<footer>
|
||||
@if (drives.length === 0) {
|
||||
<button tuiButton appearance="secondary" (click)="refresh()">
|
||||
{{ 'Refresh' | i18n }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
tuiButton
|
||||
[disabled]="!selectedOsDrive || !selectedDataDrive"
|
||||
(click)="continue()"
|
||||
>
|
||||
{{ 'Continue' | i18n }}
|
||||
</button>
|
||||
}
|
||||
</footer>
|
||||
</section>
|
||||
`,
|
||||
styles: `
|
||||
.no-drives {
|
||||
text-align: center;
|
||||
color: var(--tui-text-secondary);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.drive-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
small {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
FormsModule,
|
||||
TuiCardLarge,
|
||||
TuiButton,
|
||||
TuiIcon,
|
||||
TuiLoader,
|
||||
TuiTextfield,
|
||||
TuiSelect,
|
||||
TuiDataListWrapper,
|
||||
TuiTooltip,
|
||||
TuiHeader,
|
||||
TuiTitle,
|
||||
i18nPipe,
|
||||
],
|
||||
})
|
||||
export default class DrivesPage {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly router = inject(Router)
|
||||
private readonly dialogs = inject(DialogService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly stateService = inject(StateService)
|
||||
private readonly cdr = inject(ChangeDetectorRef)
|
||||
private readonly i18n = inject(i18nPipe)
|
||||
|
||||
protected readonly mobile = inject(TUI_IS_MOBILE)
|
||||
|
||||
readonly osDriveTooltip = this.i18n.transform(
|
||||
'The drive where the StartOS operating system will be installed.',
|
||||
)
|
||||
readonly dataDriveTooltip = this.i18n.transform(
|
||||
'The drive where your StartOS data (services, settings, etc.) will be stored. This can be the same as the OS drive or a separate drive.',
|
||||
)
|
||||
|
||||
drives: DiskInfo[] = []
|
||||
loading = true
|
||||
selectedOsDrive: DiskInfo | null = null
|
||||
selectedDataDrive: DiskInfo | null = null
|
||||
preserveData: boolean | null = null
|
||||
|
||||
readonly stringify = (drive: DiskInfo | null) =>
|
||||
drive
|
||||
? `${drive.vendor || this.i18n.transform('Unknown')} ${drive.model || this.i18n.transform('Drive')}`
|
||||
: ''
|
||||
|
||||
formatCapacity(bytes: number): string {
|
||||
const gb = bytes / 1e9
|
||||
if (gb >= 1000) {
|
||||
return `${(gb / 1000).toFixed(1)} TB`
|
||||
}
|
||||
return `${gb.toFixed(0)} GB`
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.loadDrives()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.loading = true
|
||||
this.selectedOsDrive = null
|
||||
this.selectedDataDrive = null
|
||||
this.preserveData = null
|
||||
await this.loadDrives()
|
||||
}
|
||||
|
||||
onDataDriveChange(drive: DiskInfo | null) {
|
||||
this.preserveData = null
|
||||
|
||||
if (!drive) {
|
||||
return
|
||||
}
|
||||
|
||||
const hasStartOSData = !!toGuid(drive)
|
||||
if (hasStartOSData) {
|
||||
this.showPreserveOverwriteDialog()
|
||||
}
|
||||
}
|
||||
|
||||
continue() {
|
||||
if (!this.selectedOsDrive || !this.selectedDataDrive) return
|
||||
|
||||
const sameDevice =
|
||||
this.selectedOsDrive.logicalname === this.selectedDataDrive.logicalname
|
||||
const dataHasStartOS = !!toGuid(this.selectedDataDrive)
|
||||
|
||||
// Scenario 1: Same drive, has StartOS data, preserving → no warning
|
||||
if (sameDevice && dataHasStartOS && this.preserveData) {
|
||||
this.installOs(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Scenario 2: Different drives, preserving data → warn OS only
|
||||
if (!sameDevice && this.preserveData) {
|
||||
this.showOsDriveWarning()
|
||||
return
|
||||
}
|
||||
|
||||
// Scenario 3: All other cases → warn about overwriting
|
||||
this.showFullWarning(sameDevice)
|
||||
}
|
||||
|
||||
private showPreserveOverwriteDialog() {
|
||||
let selectionMade = false
|
||||
|
||||
this.dialogs
|
||||
.openComponent<boolean>(
|
||||
new PolymorpheusComponent(PreserveOverwriteDialog),
|
||||
{
|
||||
label: 'StartOS Data Detected',
|
||||
size: 's',
|
||||
dismissible: true,
|
||||
closeable: true,
|
||||
},
|
||||
)
|
||||
.subscribe({
|
||||
next: preserve => {
|
||||
selectionMade = true
|
||||
this.preserveData = preserve
|
||||
this.cdr.markForCheck()
|
||||
},
|
||||
complete: () => {
|
||||
if (!selectionMade) {
|
||||
// Dialog was dismissed without selection - clear the data drive
|
||||
this.selectedDataDrive = null
|
||||
this.preserveData = null
|
||||
this.cdr.markForCheck()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private showOsDriveWarning() {
|
||||
this.dialogs
|
||||
.openConfirm({
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content: `<ul>
|
||||
<li class="g-negative">${this.i18n.transform('Data on the OS drive may be overwritten.')}</li>
|
||||
<li class="g-positive">${this.i18n.transform('your StartOS data on the data drive will be preserved.')}</li>
|
||||
</ul>` as i18nKey,
|
||||
yes: 'Continue',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => {
|
||||
this.installOs(false)
|
||||
})
|
||||
}
|
||||
|
||||
private showFullWarning(sameDevice: boolean) {
|
||||
const message = sameDevice
|
||||
? `<p class="g-negative">${this.i18n.transform('Data on this drive will be overwritten.')}</p>`
|
||||
: `<p class="g-negative">${this.i18n.transform('Data on both drives will be overwritten.')}</p>`
|
||||
|
||||
this.dialogs
|
||||
.openConfirm({
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content: message as i18nKey,
|
||||
yes: 'Continue',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => {
|
||||
this.installOs(true)
|
||||
})
|
||||
}
|
||||
|
||||
private async installOs(wipe: boolean) {
|
||||
const loader = this.loader.open('Installing StartOS').subscribe()
|
||||
|
||||
try {
|
||||
const result = await this.api.installOs({
|
||||
osDrive: this.selectedOsDrive!.logicalname,
|
||||
dataDrive: {
|
||||
logicalname: this.selectedDataDrive!.logicalname,
|
||||
wipe,
|
||||
},
|
||||
})
|
||||
|
||||
this.stateService.dataDriveGuid = result.guid
|
||||
this.stateService.attach = result.attach
|
||||
|
||||
loader.unsubscribe()
|
||||
|
||||
// Show success dialog
|
||||
this.dialogs
|
||||
.openConfirm({
|
||||
label: 'Installation Complete!',
|
||||
size: 's',
|
||||
data: {
|
||||
content: 'StartOS has been installed successfully.',
|
||||
yes: 'Continue to Setup',
|
||||
no: 'Shutdown',
|
||||
},
|
||||
})
|
||||
.subscribe(continueSetup => {
|
||||
if (continueSetup) {
|
||||
this.navigateToNextStep(result.attach)
|
||||
} else {
|
||||
this.shutdownServer()
|
||||
}
|
||||
})
|
||||
} catch (e: any) {
|
||||
loader.unsubscribe()
|
||||
this.errorService.handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
private async navigateToNextStep(attach: boolean) {
|
||||
if (attach) {
|
||||
this.stateService.setupType = 'attach'
|
||||
await this.router.navigate(['/password'])
|
||||
} else {
|
||||
await this.router.navigate(['/home'])
|
||||
}
|
||||
}
|
||||
|
||||
private async shutdownServer() {
|
||||
const loader = this.loader.open('Beginning shutdown').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.shutdown()
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async loadDrives() {
|
||||
try {
|
||||
this.drives = await this.api.getDisks()
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,134 +1,74 @@
|
||||
import { Component, inject, OnInit } from '@angular/core'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { TuiButton, TuiIcon, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiCardLarge, TuiCell } from '@taiga-ui/layout'
|
||||
import { RecoverComponent } from 'src/app/components/recover.component'
|
||||
import { ApiService } from 'src/app/services/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import { TuiAppearance, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiAvatar } from '@taiga-ui/kit'
|
||||
import { TuiCardLarge, TuiCell, TuiHeader } from '@taiga-ui/layout'
|
||||
import { StateService } from '../services/state.service'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<img class="logo" src="assets/img/icon.png" alt="Start9" />
|
||||
@if (!loading) {
|
||||
<section tuiCardLarge="compact">
|
||||
<header [style.padding-top.rem]="1.25">
|
||||
@if (recover) {
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="flat-grayscale"
|
||||
class="back"
|
||||
iconStart="@tui.chevron-left"
|
||||
(click)="recover = false"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
}
|
||||
{{ recover ? 'Recover Options' : 'StartOS Setup' }}
|
||||
</header>
|
||||
<div class="pages">
|
||||
<div class="options" [class.options_recover]="recover">
|
||||
<button tuiCell [routerLink]="error || recover ? null : '/storage'">
|
||||
<tui-icon icon="@tui.plus" />
|
||||
<span tuiTitle>
|
||||
<span class="g-positive">Start Fresh</span>
|
||||
<span tuiSubtitle>
|
||||
Get started with a brand new Start9 server
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
tuiCell
|
||||
[disabled]="error || recover"
|
||||
(click)="recover = true"
|
||||
>
|
||||
<tui-icon icon="@tui.rotate-cw" />
|
||||
<span tuiTitle>
|
||||
<span class="g-warning">Recover</span>
|
||||
<span tuiSubtitle>
|
||||
Recover, restore, or transfer StartOS data
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<app-recover class="options" [disabled]="!recover" />
|
||||
<div tuiCardLarge="compact">
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>{{ 'Select Setup Flow' | i18n }}</h2>
|
||||
</header>
|
||||
|
||||
<button tuiCell="l" (click)="startFresh()">
|
||||
<tui-avatar appearance="positive" src="@tui.plus" />
|
||||
<div tuiTitle>
|
||||
{{ 'Start Fresh' | i18n }}
|
||||
<div tuiSubtitle>{{ 'Set up a brand new server' | i18n }}</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
@use '@taiga-ui/core/styles/taiga-ui-local' as taiga;
|
||||
</button>
|
||||
|
||||
.logo {
|
||||
width: 6rem;
|
||||
margin: auto auto -2rem;
|
||||
z-index: 1;
|
||||
<button tuiCell="l" (click)="restore()">
|
||||
<tui-avatar appearance="warning" src="@tui.archive-restore" />
|
||||
<div tuiTitle>
|
||||
{{ 'Restore from Backup' | i18n }}
|
||||
<div tuiSubtitle>
|
||||
{{ 'Restore StartOS data from an encrypted backup' | i18n }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
&:only-child {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
+ * {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.back {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
border-radius: 10rem;
|
||||
}
|
||||
|
||||
.pages {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.options {
|
||||
@include taiga.transition(margin);
|
||||
|
||||
min-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
|
||||
&_recover {
|
||||
margin-left: -100%;
|
||||
}
|
||||
}
|
||||
<button tuiCell="l" (click)="transfer()">
|
||||
<tui-avatar appearance="info" src="@tui.hard-drive-download" />
|
||||
<div tuiTitle>
|
||||
{{ 'Transfer' | i18n }}
|
||||
<div tuiSubtitle>
|
||||
{{ 'Transfer data from an existing StartOS data drive' | i18n }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
imports: [
|
||||
RouterModule,
|
||||
TuiAppearance,
|
||||
TuiCardLarge,
|
||||
TuiButton,
|
||||
TuiHeader,
|
||||
TuiCell,
|
||||
TuiIcon,
|
||||
TuiTitle,
|
||||
RecoverComponent,
|
||||
TuiAvatar,
|
||||
i18nPipe,
|
||||
],
|
||||
})
|
||||
export default class HomePage implements OnInit {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
export default class HomePage {
|
||||
private readonly router = inject(Router)
|
||||
private readonly stateService = inject(StateService)
|
||||
|
||||
error = false
|
||||
loading = true
|
||||
recover = false
|
||||
|
||||
async ngOnInit() {
|
||||
async startFresh() {
|
||||
this.stateService.setupType = 'fresh'
|
||||
this.stateService.recoverySource = undefined
|
||||
await this.router.navigate(['/password'])
|
||||
}
|
||||
|
||||
try {
|
||||
await this.api.getPubKey()
|
||||
} catch (e: any) {
|
||||
this.error = true
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
async restore() {
|
||||
this.stateService.setupType = 'restore'
|
||||
await this.router.navigate(['/restore'])
|
||||
}
|
||||
|
||||
async transfer() {
|
||||
this.stateService.setupType = 'transfer'
|
||||
await this.router.navigate(['/transfer'])
|
||||
}
|
||||
}
|
||||
|
||||
124
web/projects/setup-wizard/src/app/pages/keyboard.page.ts
Normal file
124
web/projects/setup-wizard/src/app/pages/keyboard.page.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Component, inject, signal } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import {
|
||||
getAllKeyboardsSorted,
|
||||
i18nPipe,
|
||||
Keyboard,
|
||||
LanguageCode,
|
||||
} from '@start9labs/shared'
|
||||
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
|
||||
import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core'
|
||||
import {
|
||||
TuiButtonLoading,
|
||||
TuiChevron,
|
||||
TuiDataListWrapper,
|
||||
TuiSelect,
|
||||
} from '@taiga-ui/kit'
|
||||
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
|
||||
import { ApiService } from '../services/api.service'
|
||||
import { StateService } from '../services/state.service'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<section tuiCardLarge="compact">
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>{{ 'Select Keyboard Layout' | i18n }}</h2>
|
||||
</header>
|
||||
<tui-textfield
|
||||
tuiChevron
|
||||
[stringify]="stringify"
|
||||
[tuiTextfieldCleaner]="false"
|
||||
>
|
||||
<label tuiLabel>{{ 'Keyboard' | i18n }}</label>
|
||||
@if (mobile) {
|
||||
<select tuiSelect [(ngModel)]="selected" [items]="keyboards"></select>
|
||||
} @else {
|
||||
<input tuiSelect [(ngModel)]="selected" />
|
||||
}
|
||||
@if (!mobile) {
|
||||
<tui-data-list-wrapper
|
||||
new
|
||||
*tuiTextfieldDropdown
|
||||
[items]="keyboards"
|
||||
/>
|
||||
}
|
||||
</tui-textfield>
|
||||
|
||||
<footer>
|
||||
<button
|
||||
tuiButton
|
||||
[disabled]="!selected"
|
||||
[loading]="saving()"
|
||||
(click)="continue()"
|
||||
>
|
||||
{{ 'Continue' | i18n }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
`,
|
||||
imports: [
|
||||
FormsModule,
|
||||
TuiCardLarge,
|
||||
TuiButton,
|
||||
TuiButtonLoading,
|
||||
TuiTextfield,
|
||||
TuiChevron,
|
||||
TuiSelect,
|
||||
TuiDataListWrapper,
|
||||
TuiHeader,
|
||||
TuiTitle,
|
||||
i18nPipe,
|
||||
],
|
||||
})
|
||||
export default class KeyboardPage {
|
||||
private readonly router = inject(Router)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly stateService = inject(StateService)
|
||||
|
||||
protected readonly mobile = inject(TUI_IS_MOBILE)
|
||||
// All keyboards, with language-specific keyboards at the top
|
||||
readonly keyboards = getAllKeyboardsSorted(
|
||||
this.stateService.language as LanguageCode,
|
||||
)
|
||||
selected =
|
||||
this.keyboards.find(k => k.layout === this.stateService.keyboard) ||
|
||||
this.keyboards[0]!
|
||||
|
||||
readonly saving = signal(false)
|
||||
|
||||
readonly stringify = (kb: Keyboard) => kb.name
|
||||
|
||||
async continue() {
|
||||
this.saving.set(true)
|
||||
|
||||
try {
|
||||
// Send keyboard to backend
|
||||
await this.api.setKeyboard({
|
||||
layout: this.selected.layout,
|
||||
keymap: this.selected.keymap,
|
||||
model: null,
|
||||
variant: null,
|
||||
options: [],
|
||||
})
|
||||
|
||||
this.stateService.keyboard = this.selected.layout
|
||||
await this.navigateToNextStep()
|
||||
} finally {
|
||||
this.saving.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
private async navigateToNextStep() {
|
||||
if (this.stateService.dataDriveGuid) {
|
||||
if (this.stateService.attach) {
|
||||
this.stateService.setupType = 'attach'
|
||||
await this.router.navigate(['/password'])
|
||||
} else {
|
||||
await this.router.navigate(['/home'])
|
||||
}
|
||||
} else {
|
||||
await this.router.navigate(['/drives'])
|
||||
}
|
||||
}
|
||||
}
|
||||
151
web/projects/setup-wizard/src/app/pages/language.page.ts
Normal file
151
web/projects/setup-wizard/src/app/pages/language.page.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Component, computed, inject, signal } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { i18nPipe, i18nService, Language, LANGUAGES } from '@start9labs/shared'
|
||||
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
|
||||
import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core'
|
||||
import {
|
||||
TuiButtonLoading,
|
||||
TuiChevron,
|
||||
TuiDataListWrapper,
|
||||
TuiSelect,
|
||||
} from '@taiga-ui/kit'
|
||||
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
|
||||
import { ApiService } from '../services/api.service'
|
||||
import { StateService } from '../services/state.service'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<section tuiCardLarge="compact">
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>
|
||||
<span class="inline-title">
|
||||
<img src="assets/img/icon.png" alt="Start9" />
|
||||
{{ 'Welcome to' | i18n }} StartOS
|
||||
</span>
|
||||
<span tuiSubtitle>{{ 'Select your language' | i18n }}</span>
|
||||
</h2>
|
||||
</header>
|
||||
<tui-textfield
|
||||
tuiChevron
|
||||
[stringify]="stringify"
|
||||
[tuiTextfieldCleaner]="false"
|
||||
>
|
||||
<label tuiLabel>{{ 'Language' | i18n }}</label>
|
||||
@if (mobile) {
|
||||
<select
|
||||
tuiSelect
|
||||
[(ngModel)]="selected"
|
||||
[items]="languages"
|
||||
(ngModelChange)="onLanguageChange($event)"
|
||||
></select>
|
||||
} @else {
|
||||
<input
|
||||
tuiSelect
|
||||
[(ngModel)]="selected"
|
||||
(ngModelChange)="onLanguageChange($event)"
|
||||
/>
|
||||
}
|
||||
@if (!mobile) {
|
||||
<tui-data-list-wrapper
|
||||
*tuiTextfieldDropdown
|
||||
new
|
||||
[items]="languages"
|
||||
[itemContent]="itemContent"
|
||||
/>
|
||||
}
|
||||
</tui-textfield>
|
||||
|
||||
<ng-template #itemContent let-item>
|
||||
@let lang = asLanguage(item);
|
||||
<div class="language-item">
|
||||
<span>{{ lang.nativeName }}</span>
|
||||
<small>{{ lang.name | i18n }}</small>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<footer>
|
||||
<button
|
||||
tuiButton
|
||||
[disabled]="!selected"
|
||||
[loading]="loading()"
|
||||
(click)="continue()"
|
||||
>
|
||||
{{ 'Continue' | i18n }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
`,
|
||||
styles: `
|
||||
.language-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
small {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
FormsModule,
|
||||
TuiCardLarge,
|
||||
TuiButton,
|
||||
TuiButtonLoading,
|
||||
TuiTextfield,
|
||||
TuiChevron,
|
||||
TuiSelect,
|
||||
TuiDataListWrapper,
|
||||
TuiHeader,
|
||||
TuiTitle,
|
||||
i18nPipe,
|
||||
],
|
||||
})
|
||||
export default class LanguagePage {
|
||||
private readonly router = inject(Router)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly stateService = inject(StateService)
|
||||
private readonly i18nService = inject(i18nService)
|
||||
|
||||
protected readonly mobile = inject(TUI_IS_MOBILE)
|
||||
readonly languages = LANGUAGES
|
||||
|
||||
selected =
|
||||
LANGUAGES.find(l => l.code === this.stateService.language) || LANGUAGES[0]
|
||||
|
||||
private readonly saving = signal(false)
|
||||
|
||||
// Show loading when either language is loading or saving is in progress
|
||||
readonly loading = computed(() => this.i18nService.loading() || this.saving())
|
||||
|
||||
readonly stringify = (lang: Language) => lang.nativeName
|
||||
readonly asLanguage = (item: unknown): Language => item as Language
|
||||
|
||||
constructor() {
|
||||
if (this.selected) {
|
||||
this.i18nService.setLang(this.selected.name)
|
||||
}
|
||||
}
|
||||
|
||||
onLanguageChange(language: Language) {
|
||||
if (language) {
|
||||
this.i18nService.setLang(language.name)
|
||||
}
|
||||
}
|
||||
|
||||
async continue() {
|
||||
if (this.selected) {
|
||||
this.stateService.language = this.selected.code
|
||||
|
||||
// Save language to backend
|
||||
this.saving.set(true)
|
||||
|
||||
try {
|
||||
await this.api.setLanguage({ language: this.selected.name })
|
||||
// Always go to keyboard selection
|
||||
await this.router.navigate(['/keyboard'])
|
||||
} finally {
|
||||
this.saving.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
DialogService,
|
||||
formatProgress,
|
||||
getErrorMessage,
|
||||
i18nKey,
|
||||
i18nPipe,
|
||||
InitializingComponent,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
@@ -26,17 +26,17 @@ import {
|
||||
tap,
|
||||
timer,
|
||||
} from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { ApiService } from '../services/api.service'
|
||||
import { StateService } from '../services/state.service'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
@if (error(); as err) {
|
||||
<section>
|
||||
<h1>{{ 'Error initializing server' }}</h1>
|
||||
<h1>{{ 'Error initializing server' | i18n }}</h1>
|
||||
<p>{{ err }}</p>
|
||||
<button tuiButton (click)="restart()">
|
||||
{{ 'Restart server' }}
|
||||
{{ 'Restart server' | i18n }}
|
||||
</button>
|
||||
</section>
|
||||
} @else {
|
||||
@@ -54,22 +54,21 @@ import { StateService } from 'src/app/services/state.service'
|
||||
padding: 1rem;
|
||||
margin: 1.5rem;
|
||||
text-align: center;
|
||||
// @TODO Theme
|
||||
background: #e0e0e0;
|
||||
color: #333;
|
||||
--tui-background-neutral-1: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`,
|
||||
imports: [InitializingComponent, TuiButton],
|
||||
imports: [InitializingComponent, TuiButton, i18nPipe],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export default class LoadingPage {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly dialog = inject(DialogService)
|
||||
private readonly router = inject(Router)
|
||||
|
||||
readonly type = inject(StateService).setupType
|
||||
readonly router = inject(Router)
|
||||
readonly progress = toSignal(
|
||||
from(this.getStatus()).pipe(
|
||||
filter(Boolean),
|
||||
@@ -99,12 +98,13 @@ export default class LoadingPage {
|
||||
try {
|
||||
const res = await this.api.getStatus()
|
||||
|
||||
if (!res) {
|
||||
this.router.navigate(['home'])
|
||||
} else if (res.status === 'complete') {
|
||||
this.router.navigate(['success'])
|
||||
} else {
|
||||
if (res.status === 'running') {
|
||||
return res
|
||||
} else if (res.status === 'complete') {
|
||||
this.router.navigate(['/success'])
|
||||
} else {
|
||||
// incomplete or needs-install - shouldn't happen on loading page
|
||||
this.router.navigate(['/language'])
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.error.set(getErrorMessage(e))
|
||||
@@ -119,7 +119,7 @@ export default class LoadingPage {
|
||||
try {
|
||||
await this.api.restart()
|
||||
this.dialog
|
||||
.openAlert('Wait 1-2 minutes and refresh the page' as i18nKey, {
|
||||
.openAlert('Wait 1-2 minutes and refresh the page', {
|
||||
label: 'Server is restarting',
|
||||
})
|
||||
.subscribe()
|
||||
|
||||
196
web/projects/setup-wizard/src/app/pages/password.page.ts
Normal file
196
web/projects/setup-wizard/src/app/pages/password.page.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import {
|
||||
AbstractControl,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms'
|
||||
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
|
||||
import { TuiAutoFocus, TuiMapperPipe, TuiValidator } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiError,
|
||||
TuiIcon,
|
||||
TuiTextfield,
|
||||
TuiTitle,
|
||||
} from '@taiga-ui/core'
|
||||
import {
|
||||
TuiFieldErrorPipe,
|
||||
TuiPassword,
|
||||
tuiValidationErrorsProvider,
|
||||
} from '@taiga-ui/kit'
|
||||
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
|
||||
import { StateService } from '../services/state.service'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<section tuiCardLarge="compact">
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>
|
||||
{{
|
||||
isRequired
|
||||
? ('Set Master Password' | i18n)
|
||||
: ('Set New Password (Optional)' | i18n)
|
||||
}}
|
||||
<span tuiSubtitle>
|
||||
{{
|
||||
isRequired
|
||||
? ('Make it good. Write it down.' | i18n)
|
||||
: ('Skip to keep your existing password.' | i18n)
|
||||
}}
|
||||
</span>
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
<tui-textfield>
|
||||
<label tuiLabel>
|
||||
{{
|
||||
isRequired ? ('Enter Password' | i18n) : ('New Password' | i18n)
|
||||
}}
|
||||
</label>
|
||||
<input
|
||||
tuiTextfield
|
||||
type="password"
|
||||
tuiAutoFocus
|
||||
maxlength="64"
|
||||
formControlName="password"
|
||||
/>
|
||||
<tui-icon tuiPassword />
|
||||
</tui-textfield>
|
||||
<tui-error
|
||||
formControlName="password"
|
||||
[error]="[] | tuiFieldError | async"
|
||||
/>
|
||||
|
||||
<tui-textfield [style.margin-top.rem]="1">
|
||||
<label tuiLabel>{{ 'Confirm Password' | i18n }}</label>
|
||||
<input
|
||||
tuiTextfield
|
||||
type="password"
|
||||
formControlName="confirm"
|
||||
[tuiValidator]="
|
||||
form.controls.password.value || '' | tuiMapper: validator
|
||||
"
|
||||
/>
|
||||
<tui-icon tuiPassword />
|
||||
</tui-textfield>
|
||||
<tui-error
|
||||
formControlName="confirm"
|
||||
[error]="[] | tuiFieldError | async"
|
||||
/>
|
||||
|
||||
<footer>
|
||||
<button
|
||||
tuiButton
|
||||
[disabled]="
|
||||
isRequired
|
||||
? form.invalid
|
||||
: form.controls.password.value && form.invalid
|
||||
"
|
||||
>
|
||||
{{ 'Finish' | i18n }}
|
||||
</button>
|
||||
@if (!isRequired) {
|
||||
<button
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
type="button"
|
||||
(click)="skip()"
|
||||
>
|
||||
{{ 'Skip' | i18n }}
|
||||
</button>
|
||||
}
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
`,
|
||||
styles: `
|
||||
footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
ReactiveFormsModule,
|
||||
TuiCardLarge,
|
||||
TuiButton,
|
||||
TuiError,
|
||||
TuiAutoFocus,
|
||||
TuiFieldErrorPipe,
|
||||
TuiTextfield,
|
||||
TuiPassword,
|
||||
TuiValidator,
|
||||
TuiIcon,
|
||||
TuiMapperPipe,
|
||||
TuiHeader,
|
||||
TuiTitle,
|
||||
i18nPipe,
|
||||
],
|
||||
providers: [
|
||||
tuiValidationErrorsProvider({
|
||||
required: 'Required',
|
||||
minlength: 'Must be 12 characters or greater',
|
||||
maxlength: 'Must be 64 character or less',
|
||||
match: 'Passwords do not match',
|
||||
}),
|
||||
],
|
||||
})
|
||||
export default class PasswordPage {
|
||||
private readonly router = inject(Router)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly stateService = inject(StateService)
|
||||
private readonly i18n = inject(i18nPipe)
|
||||
|
||||
// Password is required only for fresh install
|
||||
readonly isRequired = this.stateService.setupType === 'fresh'
|
||||
|
||||
readonly form = new FormGroup({
|
||||
password: new FormControl('', [
|
||||
...(this.isRequired ? [Validators.required] : []),
|
||||
Validators.minLength(12),
|
||||
Validators.maxLength(64),
|
||||
]),
|
||||
confirm: new FormControl(''),
|
||||
})
|
||||
|
||||
readonly validator = (value: string) => (control: AbstractControl) =>
|
||||
value === control.value
|
||||
? null
|
||||
: { match: this.i18n.transform('Passwords do not match') }
|
||||
|
||||
async skip() {
|
||||
// Skip means no new password - pass null
|
||||
await this.executeSetup(null)
|
||||
}
|
||||
|
||||
async submit() {
|
||||
await this.executeSetup(this.form.controls.password.value)
|
||||
}
|
||||
|
||||
private async executeSetup(password: string | null) {
|
||||
const loader = this.loader.open('Starting setup').subscribe()
|
||||
|
||||
try {
|
||||
if (this.stateService.setupType === 'attach') {
|
||||
await this.stateService.attachDrive(password)
|
||||
} else {
|
||||
// fresh, restore, or transfer - all use execute
|
||||
await this.stateService.executeSetup(password)
|
||||
}
|
||||
|
||||
await this.router.navigate(['/loading'])
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { ErrorService, ServerComponent } from '@start9labs/shared'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDialogService,
|
||||
TuiIcon,
|
||||
TuiLoader,
|
||||
TuiTitle,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiCardLarge, TuiCell } from '@taiga-ui/layout'
|
||||
import { CIFS, CifsResponse } from 'src/app/components/cifs.component'
|
||||
import { PasswordDirective } from 'src/app/components/password.directive'
|
||||
import { ApiService, StartOSDiskInfoFull } from 'src/app/services/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<section tuiCardLarge="compact">
|
||||
<header>Restore from Backup</header>
|
||||
@if (loading) {
|
||||
<tui-loader />
|
||||
} @else {
|
||||
<h2>Network Folder</h2>
|
||||
Restore StartOS data from a folder on another computer that is connected
|
||||
to the same network as your server.
|
||||
|
||||
<button tuiCell [style.box-shadow]="'none'" (click)="onCifs()">
|
||||
<tui-icon icon="@tui.folder" />
|
||||
<span tuiTitle>Open</span>
|
||||
</button>
|
||||
|
||||
<h2>Physical Drive</h2>
|
||||
<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
|
||||
support more than one external drive without additional power and can
|
||||
cause data corruption.
|
||||
</strong>
|
||||
|
||||
@for (server of servers; track $index) {
|
||||
<button
|
||||
[server]="server"
|
||||
(password)="select($event, server)"
|
||||
></button>
|
||||
}
|
||||
<footer>
|
||||
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
|
||||
Refresh
|
||||
</button>
|
||||
</footer>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
imports: [
|
||||
TuiCardLarge,
|
||||
TuiLoader,
|
||||
TuiButton,
|
||||
TuiCell,
|
||||
TuiIcon,
|
||||
TuiTitle,
|
||||
ServerComponent,
|
||||
PasswordDirective,
|
||||
],
|
||||
})
|
||||
export default class RecoverPage {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly router = inject(Router)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly stateService = inject(StateService)
|
||||
|
||||
loading = true
|
||||
servers: StartOSDiskInfoFull[] = []
|
||||
|
||||
async ngOnInit() {
|
||||
this.stateService.setupType = 'restore'
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.loading = true
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
async getDrives() {
|
||||
this.servers = []
|
||||
|
||||
try {
|
||||
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)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
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<CifsResponse>(CIFS, {
|
||||
label: 'Connect Network Folder',
|
||||
})
|
||||
.subscribe(({ cifs, serverId, password }) => {
|
||||
this.stateService.recoverySource = {
|
||||
type: 'backup',
|
||||
target: {
|
||||
type: 'cifs',
|
||||
...cifs,
|
||||
},
|
||||
serverId,
|
||||
password,
|
||||
}
|
||||
this.router.navigate(['storage'])
|
||||
})
|
||||
}
|
||||
}
|
||||
239
web/projects/setup-wizard/src/app/pages/restore.page.ts
Normal file
239
web/projects/setup-wizard/src/app/pages/restore.page.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { DialogService, ErrorService, i18nPipe } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDataList,
|
||||
TuiDropdown,
|
||||
TuiIcon,
|
||||
TuiLoader,
|
||||
TuiOptGroup,
|
||||
TuiTitle,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
|
||||
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import { ApiService } from '../services/api.service'
|
||||
import { StateService } from '../services/state.service'
|
||||
import { StartOSDiskInfoFull, StartOSDiskInfoWithId } from '../types'
|
||||
import { CIFS, CifsResult } from '../components/cifs.component'
|
||||
import { SELECT_NETWORK_BACKUP } from '../components/select-network-backup.dialog'
|
||||
import { UnlockPasswordDialog } from '../components/unlock-password.dialog'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<section tuiCardLarge="compact">
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>
|
||||
{{ 'Select Backup' | i18n }}
|
||||
<span tuiSubtitle>
|
||||
{{ 'Select the StartOS backup you want to restore' | i18n }}
|
||||
<a class="refresh" (click)="refresh()">
|
||||
<tui-icon icon="@tui.rotate-cw" />
|
||||
{{ 'Refresh' | i18n }}
|
||||
</a>
|
||||
</span>
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
@if (loading) {
|
||||
<tui-loader />
|
||||
} @else {
|
||||
<button
|
||||
tuiButton
|
||||
iconEnd="@tui.chevron-down"
|
||||
[tuiDropdown]="dropdown"
|
||||
[tuiDropdownLimitWidth]="'fixed'"
|
||||
[(tuiDropdownOpen)]="open"
|
||||
style="width: 100%"
|
||||
>
|
||||
{{ 'Select Backup' | i18n }}
|
||||
</button>
|
||||
|
||||
<ng-template #dropdown>
|
||||
<tui-data-list>
|
||||
<tui-opt-group>
|
||||
<button tuiOption new (click)="openCifs()">
|
||||
<tui-icon icon="@tui.folder-plus" />
|
||||
{{ 'Open Network Backup' | i18n }}
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
<tui-opt-group [label]="'Physical Backups' | i18n">
|
||||
@for (server of physicalServers; track server.id) {
|
||||
<button tuiOption new (click)="selectPhysicalBackup(server)">
|
||||
<div class="server-item">
|
||||
<span>{{ server.id }}</span>
|
||||
<small>
|
||||
{{ server.drive.vendor }} {{ server.drive.model }} ·
|
||||
{{ server.partition.logicalname }}
|
||||
</small>
|
||||
</div>
|
||||
</button>
|
||||
} @empty {
|
||||
<div class="no-items">{{ 'No physical backups' | i18n }}</div>
|
||||
}
|
||||
</tui-opt-group>
|
||||
</tui-data-list>
|
||||
</ng-template>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: `
|
||||
.refresh {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
cursor: pointer;
|
||||
color: var(--tui-text-action);
|
||||
|
||||
tui-icon {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.server-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
small {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.no-items {
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: var(--tui-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
TuiButton,
|
||||
TuiCardLarge,
|
||||
TuiDataList,
|
||||
TuiDropdown,
|
||||
TuiLoader,
|
||||
TuiIcon,
|
||||
TuiOptGroup,
|
||||
TuiTitle,
|
||||
TuiHeader,
|
||||
i18nPipe,
|
||||
],
|
||||
})
|
||||
export default class RestorePage {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly router = inject(Router)
|
||||
private readonly dialogs = inject(DialogService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly stateService = inject(StateService)
|
||||
|
||||
loading = true
|
||||
open = false
|
||||
physicalServers: StartOSDiskInfoFull[] = []
|
||||
|
||||
async ngOnInit() {
|
||||
await this.loadDrives()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.loading = true
|
||||
await this.loadDrives()
|
||||
}
|
||||
|
||||
openCifs() {
|
||||
this.open = false
|
||||
this.dialogs
|
||||
.openComponent<CifsResult>(CIFS, {
|
||||
label: 'Connect Network Folder',
|
||||
size: 's',
|
||||
})
|
||||
.subscribe(result => {
|
||||
if (result) {
|
||||
this.handleCifsResult(result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
selectPhysicalBackup(server: StartOSDiskInfoFull) {
|
||||
this.open = false
|
||||
this.showUnlockDialog(server.id, {
|
||||
type: 'disk',
|
||||
logicalname: server.partition.logicalname,
|
||||
})
|
||||
}
|
||||
|
||||
private handleCifsResult(result: CifsResult) {
|
||||
if (result.servers.length === 1) {
|
||||
this.showUnlockDialog(result.servers[0]!.id, {
|
||||
type: 'cifs',
|
||||
...result.cifs,
|
||||
})
|
||||
} else if (result.servers.length > 1) {
|
||||
this.showSelectNetworkBackupDialog(result.cifs, result.servers)
|
||||
}
|
||||
}
|
||||
|
||||
private showSelectNetworkBackupDialog(
|
||||
cifs: T.Cifs,
|
||||
servers: StartOSDiskInfoWithId[],
|
||||
) {
|
||||
this.dialogs
|
||||
.openComponent<StartOSDiskInfoWithId | null>(SELECT_NETWORK_BACKUP, {
|
||||
label: 'Select Network Backup',
|
||||
size: 's',
|
||||
data: { servers },
|
||||
})
|
||||
.subscribe(server => {
|
||||
if (server) {
|
||||
this.showUnlockDialog(server.id, { type: 'cifs', ...cifs })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private showUnlockDialog(
|
||||
serverId: string,
|
||||
target: { type: 'disk'; logicalname: string } | ({ type: 'cifs' } & T.Cifs),
|
||||
) {
|
||||
this.dialogs
|
||||
.openComponent<string | null>(
|
||||
new PolymorpheusComponent(UnlockPasswordDialog),
|
||||
{
|
||||
label: 'Unlock Backup',
|
||||
size: 's',
|
||||
},
|
||||
)
|
||||
.subscribe(password => {
|
||||
if (password) {
|
||||
this.stateService.recoverySource = {
|
||||
type: 'backup',
|
||||
target,
|
||||
serverId,
|
||||
password,
|
||||
}
|
||||
this.router.navigate(['/password'])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async loadDrives() {
|
||||
this.physicalServers = []
|
||||
|
||||
try {
|
||||
const drives = await this.api.getDisks()
|
||||
|
||||
this.physicalServers = 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)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import {
|
||||
DiskInfo,
|
||||
DriveComponent,
|
||||
ErrorService,
|
||||
i18nKey,
|
||||
LoadingService,
|
||||
toGuid,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiButton, TuiDialogService, TuiLoader } from '@taiga-ui/core'
|
||||
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 } from 'src/app/services/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<section tuiCardLarge="compact">
|
||||
@if (loading || drives.length) {
|
||||
<header>Select storage drive</header>
|
||||
This is the drive where your StartOS data will be stored.
|
||||
} @else {
|
||||
<header>No drives found</header>
|
||||
Please connect a storage drive to your server. Then click "Refresh".
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<tui-loader />
|
||||
}
|
||||
|
||||
@for (d of drives; track d) {
|
||||
<button tuiCell [drive]="d" [disabled]="isSmall(d)" (click)="select(d)">
|
||||
@if (isSmall(d)) {
|
||||
<span tuiSubtitle class="g-negative">Drive capacity too small</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<footer>
|
||||
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
|
||||
Refresh
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
`,
|
||||
imports: [TuiCardLarge, TuiLoader, TuiCell, TuiButton, DriveComponent],
|
||||
})
|
||||
export default class StoragePage {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly router = inject(Router)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly stateService = inject(StateService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
|
||||
drives: DiskInfo[] = []
|
||||
loading = true
|
||||
|
||||
async ngOnInit() {
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
isSmall({ capacity }: DiskInfo) {
|
||||
return capacity < 34359738368
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.loading = true
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
async getDrives() {
|
||||
this.loading = true
|
||||
try {
|
||||
const disks = await this.api.getDrives()
|
||||
if (this.stateService.setupType === 'fresh') {
|
||||
this.drives = disks
|
||||
} 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)
|
||||
)
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
select(drive: DiskInfo) {
|
||||
of(!toGuid(drive) && !drive.partitions.some(p => p.used))
|
||||
.pipe(
|
||||
switchMap(unused =>
|
||||
unused
|
||||
? of(true)
|
||||
: this.dialogs.open(TUI_CONFIRM, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content:
|
||||
'<strong>Drive contains data!</strong><p>All data stored on this drive will be permanently deleted.</p>',
|
||||
yes: 'Continue',
|
||||
no: 'Cancel',
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => {
|
||||
// for backup recoveries
|
||||
if (this.stateService.recoverySource?.type === 'backup') {
|
||||
this.setupEmbassy(
|
||||
drive.logicalname,
|
||||
this.stateService.recoverySource.password,
|
||||
)
|
||||
} else {
|
||||
// for migrations and fresh setups
|
||||
this.promptPassword(drive.logicalname)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private promptPassword(logicalname: string) {
|
||||
this.dialogs
|
||||
.open<string>(PASSWORD, {
|
||||
label: 'Set Password',
|
||||
size: 's',
|
||||
data: { storageDrive: true },
|
||||
})
|
||||
.subscribe(password => {
|
||||
this.setupEmbassy(logicalname, password)
|
||||
})
|
||||
}
|
||||
|
||||
private async setupEmbassy(
|
||||
logicalname: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
const loader = this.loader
|
||||
.open('Connecting to drive' as i18nKey)
|
||||
.subscribe()
|
||||
|
||||
try {
|
||||
await this.stateService.setupEmbassy(logicalname, password)
|
||||
await this.router.navigate(['loading'])
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,155 +6,224 @@ import {
|
||||
ViewChild,
|
||||
DOCUMENT,
|
||||
} from '@angular/core'
|
||||
import { DownloadHTMLService, ErrorService } from '@start9labs/shared'
|
||||
import { TuiButton, TuiIcon, TuiLoader, TuiSurface } from '@taiga-ui/core'
|
||||
import { TuiCardLarge } from '@taiga-ui/layout'
|
||||
import { DocumentationComponent } from 'src/app/components/documentation.component'
|
||||
import { MatrixComponent } from 'src/app/components/matrix.component'
|
||||
import { ApiService } from 'src/app/services/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { DownloadHTMLService, ErrorService, i18nPipe } from '@start9labs/shared'
|
||||
import { TuiIcon, TuiLoader, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiAvatar } from '@taiga-ui/kit'
|
||||
import { TuiCardLarge, TuiCell, TuiHeader } from '@taiga-ui/layout'
|
||||
import { ApiService } from '../services/api.service'
|
||||
import { StateService } from '../services/state.service'
|
||||
import { DocumentationComponent } from '../components/documentation.component'
|
||||
import { MatrixComponent } from '../components/matrix.component'
|
||||
import { SetupCompleteRes } from '../types'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<canvas matrix></canvas>
|
||||
<section tuiCardLarge>
|
||||
<h1 class="heading">
|
||||
<tui-icon icon="@tui.circle-check-big" class="g-positive" />
|
||||
Setup Complete!
|
||||
</h1>
|
||||
@if (stateService.kiosk) {
|
||||
<button tuiButton (click)="exitKiosk()">Continue to Login</button>
|
||||
} @else if (lanAddress) {
|
||||
@if (stateService.setupType === 'restore') {
|
||||
<h3>You can now safely unplug your backup drive</h3>
|
||||
} @else if (stateService.setupType === 'transfer') {
|
||||
<h3>You can now safely unplug your old StartOS data drive</h3>
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>
|
||||
<span class="inline-title">
|
||||
<tui-icon icon="@tui.circle-check-big" class="g-positive" />
|
||||
{{ 'Setup Complete!' | i18n }}
|
||||
</span>
|
||||
@if (!stateService.kiosk) {
|
||||
<span tuiSubtitle>
|
||||
{{
|
||||
stateService.setupType === 'restore'
|
||||
? ('You can unplug your backup drive' | i18n)
|
||||
: stateService.setupType === 'transfer'
|
||||
? ('You can unplug your transfer drive' | i18n)
|
||||
: ('http://start.local was for setup only. It will no longer work.'
|
||||
| i18n)
|
||||
}}
|
||||
</span>
|
||||
}
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
@if (!result) {
|
||||
<tui-loader />
|
||||
} @else {
|
||||
<!-- Step: Download Address Info (non-kiosk only) -->
|
||||
@if (!stateService.kiosk) {
|
||||
<button tuiCell="l" [disabled]="downloaded" (click)="download()">
|
||||
<tui-avatar appearance="secondary" src="@tui.download" />
|
||||
<div tuiTitle>
|
||||
{{ 'Download Address Info' | i18n }}
|
||||
<div tuiSubtitle>
|
||||
{{
|
||||
"Contains your server's permanent local address and Root CA"
|
||||
| i18n
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
@if (downloaded) {
|
||||
<tui-icon icon="@tui.circle-check" class="g-positive" />
|
||||
}
|
||||
</button>
|
||||
}
|
||||
|
||||
<h3>
|
||||
http://start.local was for setup purposes only. It will no longer
|
||||
work.
|
||||
</h3>
|
||||
<!-- Step: Remove USB Media (when restart needed) -->
|
||||
@if (result.needsRestart) {
|
||||
<button
|
||||
tuiCell="l"
|
||||
[class.disabled]="!stateService.kiosk && !downloaded"
|
||||
[disabled]="(!stateService.kiosk && !downloaded) || usbRemoved"
|
||||
(click)="usbRemoved = true"
|
||||
>
|
||||
<tui-avatar appearance="secondary" src="@tui.usb" />
|
||||
<div tuiTitle>
|
||||
{{ 'USB Removed' | i18n }}
|
||||
<div tuiSubtitle>
|
||||
{{
|
||||
'Remove the USB installation media from your server' | i18n
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
@if (usbRemoved) {
|
||||
<tui-icon icon="@tui.circle-check" class="g-positive" />
|
||||
}
|
||||
</button>
|
||||
|
||||
<button tuiCardLarge tuiSurface="floating" (click)="download()">
|
||||
<strong class="caps">Download address info</strong>
|
||||
<span>
|
||||
For future reference, this file contains your server's permanent
|
||||
local address, as well as its Root Certificate Authority (Root CA).
|
||||
</span>
|
||||
<strong class="caps">
|
||||
Download
|
||||
<tui-icon icon="@tui.download" />
|
||||
</strong>
|
||||
</button>
|
||||
<!-- Step: Restart Server -->
|
||||
<button
|
||||
tuiCell="l"
|
||||
[class.disabled]="!usbRemoved"
|
||||
[disabled]="!usbRemoved || rebooted || rebooting"
|
||||
(click)="reboot()"
|
||||
>
|
||||
<tui-avatar appearance="secondary" src="@tui.rotate-cw" />
|
||||
<div tuiTitle>
|
||||
{{ 'Restart Server' | i18n }}
|
||||
<div tuiSubtitle>
|
||||
@if (rebooting) {
|
||||
{{ 'Waiting for server to come back online' | i18n }}
|
||||
} @else if (rebooted) {
|
||||
{{ 'Server is back online' | i18n }}
|
||||
} @else {
|
||||
{{ 'Restart your server to complete setup' | i18n }}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (rebooting) {
|
||||
<tui-loader />
|
||||
} @else if (rebooted) {
|
||||
<tui-icon icon="@tui.circle-check" class="g-positive" />
|
||||
}
|
||||
</button>
|
||||
}
|
||||
|
||||
<a
|
||||
tuiCardLarge
|
||||
tuiSurface="floating"
|
||||
target="_blank"
|
||||
[attr.href]="disableLogin ? null : lanAddress"
|
||||
>
|
||||
<span>
|
||||
In the new tab, follow instructions to trust your server's Root CA
|
||||
and log in.
|
||||
</span>
|
||||
<strong class="caps">
|
||||
Open Local Address
|
||||
<tui-icon icon="@tui.external-link" />
|
||||
</strong>
|
||||
</a>
|
||||
<app-documentation hidden [lanAddress]="lanAddress" />
|
||||
} @else {
|
||||
<tui-loader />
|
||||
<!-- Step: Open Local Address (non-kiosk only) -->
|
||||
@if (!stateService.kiosk) {
|
||||
<button
|
||||
tuiCell="l"
|
||||
[class.disabled]="!canOpenAddress"
|
||||
[disabled]="!canOpenAddress"
|
||||
(click)="openLocalAddress()"
|
||||
>
|
||||
<tui-avatar appearance="secondary" src="@tui.external-link" />
|
||||
<div tuiTitle>
|
||||
{{ 'Open Local Address' | i18n }}
|
||||
<div tuiSubtitle>{{ lanAddress }}</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<app-documentation hidden [lanAddress]="lanAddress" />
|
||||
}
|
||||
|
||||
<!-- Step: Continue to Login (kiosk only) -->
|
||||
@if (stateService.kiosk) {
|
||||
<button
|
||||
tuiCell="l"
|
||||
[class.disabled]="result.needsRestart && !rebooted"
|
||||
[disabled]="result.needsRestart && !rebooted"
|
||||
(click)="exitKiosk()"
|
||||
>
|
||||
<tui-avatar appearance="secondary" src="@tui.log-in" />
|
||||
<div tuiTitle>
|
||||
{{ 'Continue to Login' | i18n }}
|
||||
<div tuiSubtitle>
|
||||
{{ 'Proceed to the StartOS login screen' | i18n }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: `
|
||||
.heading {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
.inline-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
font: var(--tui-font-heading-4);
|
||||
}
|
||||
|
||||
.caps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
[tuiCardLarge] {
|
||||
color: var(--tui-text-primary);
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
|
||||
&[data-appearance='floating'] {
|
||||
background: var(--tui-background-neutral-1);
|
||||
|
||||
&:hover {
|
||||
background: var(--tui-background-neutral-1-hover) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a[tuiCardLarge]:not([href]) {
|
||||
[tuiCell].disabled {
|
||||
opacity: var(--tui-disabled-opacity);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
h3 {
|
||||
text-align: left;
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
TuiCardLarge,
|
||||
TuiCell,
|
||||
TuiIcon,
|
||||
TuiButton,
|
||||
TuiSurface,
|
||||
TuiLoader,
|
||||
TuiAvatar,
|
||||
MatrixComponent,
|
||||
DocumentationComponent,
|
||||
TuiLoader,
|
||||
TuiHeader,
|
||||
TuiTitle,
|
||||
i18nPipe,
|
||||
],
|
||||
})
|
||||
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)
|
||||
private readonly i18n = inject(i18nPipe)
|
||||
|
||||
readonly stateService = inject(StateService)
|
||||
|
||||
lanAddress?: string
|
||||
cert?: string
|
||||
disableLogin = this.stateService.setupType === 'fresh'
|
||||
result?: SetupCompleteRes
|
||||
lanAddress = ''
|
||||
downloaded = false
|
||||
usbRemoved = false
|
||||
rebooting = false
|
||||
rebooted = false
|
||||
|
||||
get canOpenAddress(): boolean {
|
||||
if (!this.downloaded) return false
|
||||
if (this.result?.needsRestart && !this.rebooted) return false
|
||||
return true
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
setTimeout(() => this.complete(), 1000)
|
||||
setTimeout(() => this.complete(), 500)
|
||||
}
|
||||
|
||||
download() {
|
||||
const lanElem = this.document.getElementById('lan-addr')
|
||||
if (this.downloaded) return
|
||||
|
||||
if (lanElem) lanElem.innerHTML = this.lanAddress || ''
|
||||
const lanElem = this.document.getElementById('lan-addr')
|
||||
if (lanElem) lanElem.innerHTML = this.lanAddress
|
||||
|
||||
this.document
|
||||
.getElementById('cert')
|
||||
?.setAttribute(
|
||||
'href',
|
||||
URL.createObjectURL(
|
||||
new Blob([this.cert!], { type: 'application/octet-stream' }),
|
||||
new Blob([this.result!.rootCa], { type: 'application/octet-stream' }),
|
||||
),
|
||||
)
|
||||
|
||||
const html = this.documentation?.nativeElement.innerHTML || ''
|
||||
|
||||
this.downloadHtml.download('StartOS-info.html', html).then(_ => {
|
||||
this.disableLogin = false
|
||||
this.downloadHtml.download('StartOS-info.html', html).then(() => {
|
||||
this.downloaded = true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -162,17 +231,58 @@ export default class SuccessPage implements AfterViewInit {
|
||||
this.api.exit()
|
||||
}
|
||||
|
||||
openLocalAddress() {
|
||||
window.open(this.lanAddress, '_blank')
|
||||
}
|
||||
|
||||
async reboot() {
|
||||
this.rebooting = true
|
||||
|
||||
try {
|
||||
await this.api.exit()
|
||||
await this.pollForServer()
|
||||
this.rebooted = true
|
||||
this.rebooting = false
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
this.rebooting = false
|
||||
}
|
||||
}
|
||||
|
||||
private async complete() {
|
||||
try {
|
||||
const ret = await this.api.complete()
|
||||
if (!this.stateService.kiosk) {
|
||||
this.lanAddress = ret.lanAddress.replace(/^https:/, 'http:')
|
||||
this.cert = ret.rootCa
|
||||
this.result = await this.api.complete()
|
||||
|
||||
await this.api.exit()
|
||||
if (!this.stateService.kiosk) {
|
||||
this.lanAddress = `http://${this.result.hostname}.local`
|
||||
|
||||
if (!this.result.needsRestart) {
|
||||
await this.api.exit()
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
private async pollForServer(): Promise<void> {
|
||||
const maxAttempts = 60
|
||||
let attempts = 0
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
await this.api.echo({ message: 'ping' }, this.lanAddress)
|
||||
return
|
||||
} catch {
|
||||
await new Promise(resolve => setTimeout(resolve, 5000))
|
||||
attempts++
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
this.i18n.transform(
|
||||
'Server did not come back online. Please check your server and try accessing it manually.',
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,97 +1,172 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import {
|
||||
DialogService,
|
||||
DiskInfo,
|
||||
DriveComponent,
|
||||
ErrorService,
|
||||
i18nPipe,
|
||||
toGuid,
|
||||
} from '@start9labs/shared'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDialogOptions,
|
||||
TuiDialogService,
|
||||
TuiDataList,
|
||||
TuiDropdown,
|
||||
TuiIcon,
|
||||
TuiLoader,
|
||||
TuiTitle,
|
||||
} from '@taiga-ui/core'
|
||||
import { TUI_CONFIRM, TuiConfirmData } from '@taiga-ui/kit'
|
||||
import { TuiCardLarge, TuiCell } from '@taiga-ui/layout'
|
||||
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
|
||||
import { filter } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { ApiService } from '../services/api.service'
|
||||
import { StateService } from '../services/state.service'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<section tuiCardLarge="compact">
|
||||
<header>Transfer</header>
|
||||
Select the physical drive containing your StartOS data
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>
|
||||
{{ 'Transfer Data' | i18n }}
|
||||
<span tuiSubtitle>
|
||||
{{
|
||||
'Select the drive containing your existing StartOS data' | i18n
|
||||
}}
|
||||
<a class="refresh" (click)="refresh()">
|
||||
<tui-icon icon="@tui.rotate-cw" />
|
||||
{{ 'Refresh' | i18n }}
|
||||
</a>
|
||||
</span>
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
@if (loading) {
|
||||
<tui-loader />
|
||||
}
|
||||
@for (drive of drives; track drive) {
|
||||
<button tuiCell [drive]="drive" (click)="select(drive)"></button>
|
||||
}
|
||||
<footer>
|
||||
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
|
||||
Refresh
|
||||
} @else {
|
||||
<button
|
||||
tuiButton
|
||||
iconEnd="@tui.chevron-down"
|
||||
[tuiDropdown]="dropdown"
|
||||
[tuiDropdownLimitWidth]="'fixed'"
|
||||
[(tuiDropdownOpen)]="open"
|
||||
style="width: 100%"
|
||||
>
|
||||
{{ 'Select Drive' | i18n }}
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
<ng-template #dropdown>
|
||||
<tui-data-list>
|
||||
@for (drive of drives; track drive.logicalname) {
|
||||
<button tuiOption new (click)="select(drive)">
|
||||
<div class="drive-item">
|
||||
<span>{{ drive.vendor }} {{ drive.model }}</span>
|
||||
<small>{{ drive.logicalname }}</small>
|
||||
</div>
|
||||
</button>
|
||||
} @empty {
|
||||
<div class="no-items">
|
||||
{{ 'No StartOS data drives found' | i18n }}
|
||||
</div>
|
||||
}
|
||||
</tui-data-list>
|
||||
</ng-template>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
imports: [TuiCardLarge, TuiCell, TuiButton, TuiLoader, DriveComponent],
|
||||
styles: `
|
||||
.refresh {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
cursor: pointer;
|
||||
color: var(--tui-text-action);
|
||||
|
||||
tui-icon {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.drive-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
small {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.no-items {
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: var(--tui-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
TuiButton,
|
||||
TuiCardLarge,
|
||||
TuiDataList,
|
||||
TuiDropdown,
|
||||
TuiIcon,
|
||||
TuiLoader,
|
||||
TuiTitle,
|
||||
TuiHeader,
|
||||
i18nPipe,
|
||||
],
|
||||
})
|
||||
export default class TransferPage {
|
||||
private readonly apiService = inject(ApiService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly router = inject(Router)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly dialogs = inject(DialogService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly stateService = inject(StateService)
|
||||
|
||||
loading = true
|
||||
open = false
|
||||
drives: DiskInfo[] = []
|
||||
|
||||
async ngOnInit() {
|
||||
this.stateService.setupType = 'transfer'
|
||||
await this.getDrives()
|
||||
await this.loadDrives()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
await this.getDrives()
|
||||
this.loading = true
|
||||
await this.loadDrives()
|
||||
}
|
||||
|
||||
async getDrives() {
|
||||
this.loading = true
|
||||
select(drive: DiskInfo) {
|
||||
this.open = false
|
||||
|
||||
this.dialogs
|
||||
.openConfirm({
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content:
|
||||
'After transferring data from this drive, do not attempt to boot into it again as a Start9 Server. This may result in services malfunctioning, data corruption, or loss of funds.',
|
||||
yes: 'Continue',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => {
|
||||
const guid = toGuid(drive)
|
||||
if (guid) {
|
||||
this.stateService.recoverySource = {
|
||||
type: 'migrate',
|
||||
guid,
|
||||
}
|
||||
this.router.navigate(['/password'])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async loadDrives() {
|
||||
try {
|
||||
this.drives = await this.apiService
|
||||
.getDrives()
|
||||
.then(drives => drives.filter(toGuid))
|
||||
const allDrives = await this.api.getDisks()
|
||||
// Filter to only drives with StartOS data (guid)
|
||||
this.drives = allDrives.filter(toGuid)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
select(drive: DiskInfo) {
|
||||
this.dialogs
|
||||
.open(TUI_CONFIRM, OPTIONS)
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => {
|
||||
this.stateService.recoverySource = {
|
||||
type: 'migrate',
|
||||
guid: toGuid(drive) || '',
|
||||
}
|
||||
this.router.navigate([`storage`])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const OPTIONS: Partial<TuiDialogOptions<TuiConfirmData>> = {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content:
|
||||
'After transferring data from this drive, <b>do not</b> attempt to boot into it again as a Start9 Server. This may result in services malfunctioning, data corruption, or loss of funds.',
|
||||
yes: 'Continue',
|
||||
no: 'Cancel',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,50 +1,65 @@
|
||||
import * as jose from 'node-jose'
|
||||
import {
|
||||
DiskInfo,
|
||||
DiskListResponse,
|
||||
FollowLogsRes,
|
||||
PartitionInfo,
|
||||
FullKeyboard,
|
||||
SetLanguageParams,
|
||||
StartOSDiskInfo,
|
||||
} from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||
import { Observable } from 'rxjs'
|
||||
import {
|
||||
SetupStatusRes,
|
||||
InstallOsParams,
|
||||
InstallOsRes,
|
||||
AttachParams,
|
||||
SetupExecuteParams,
|
||||
SetupCompleteRes,
|
||||
EchoReq,
|
||||
} from '../types'
|
||||
|
||||
export abstract class ApiService {
|
||||
pubkey?: jose.JWK.Key
|
||||
|
||||
abstract getStatus(): Promise<T.SetupStatusRes | null> // setup.status
|
||||
// echo
|
||||
abstract echo(params: EchoReq, url: string): Promise<string>
|
||||
|
||||
// Status & Setup
|
||||
abstract getStatus(): Promise<SetupStatusRes> // setup.status
|
||||
abstract getPubKey(): Promise<void> // setup.get-pubkey
|
||||
abstract getDrives(): Promise<DiskListResponse> // setup.disk.list
|
||||
abstract setKeyboard(params: FullKeyboard): Promise<null> // setup.set-keyboard
|
||||
abstract setLanguage(params: SetLanguageParams): Promise<null> // setup.set-language
|
||||
|
||||
// Install
|
||||
abstract getDisks(): Promise<DiskInfo[]> // setup.disk.list
|
||||
abstract installOs(params: InstallOsParams): Promise<InstallOsRes> // setup.install-os
|
||||
|
||||
// Setup execution
|
||||
abstract attach(params: AttachParams): Promise<T.SetupProgress> // setup.attach
|
||||
abstract execute(params: SetupExecuteParams): Promise<T.SetupProgress> // setup.execute
|
||||
|
||||
// Recovery helpers
|
||||
abstract verifyCifs(
|
||||
cifs: T.VerifyCifsParams,
|
||||
): Promise<Record<string, StartOSDiskInfo>> // setup.cifs.verify
|
||||
abstract attach(importInfo: T.AttachParams): Promise<T.SetupProgress> // setup.attach
|
||||
abstract execute(setupInfo: T.SetupExecuteParams): Promise<T.SetupProgress> // setup.execute
|
||||
abstract complete(): Promise<T.SetupResult> // setup.complete
|
||||
|
||||
// Completion
|
||||
abstract complete(): Promise<SetupCompleteRes> // setup.complete
|
||||
abstract exit(): Promise<void> // setup.exit
|
||||
abstract shutdown(): Promise<void> // setup.shutdown
|
||||
|
||||
// Logs & Progress
|
||||
abstract initFollowLogs(): Promise<FollowLogsRes> // setup.logs.follow
|
||||
abstract restart(): Promise<void> // setup.restart
|
||||
abstract openWebsocket$<T>(guid: string): Observable<T>
|
||||
|
||||
// Restart (for error recovery)
|
||||
abstract restart(): Promise<void> // setup.restart
|
||||
|
||||
async encrypt(toEncrypt: string): Promise<T.EncryptedWire> {
|
||||
if (!this.pubkey) throw new Error('No pubkey found!')
|
||||
const encrypted = await jose.JWE.createEncrypt(this.pubkey!)
|
||||
.update(toEncrypt)
|
||||
.final()
|
||||
return {
|
||||
encrypted,
|
||||
}
|
||||
return { encrypted }
|
||||
}
|
||||
}
|
||||
|
||||
export type WebsocketConfig<T> = Omit<WebSocketSubjectConfig<T>, 'url'>
|
||||
|
||||
export type StartOSDiskInfoWithId = StartOSDiskInfo & {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type StartOSDiskInfoFull = StartOSDiskInfoWithId & {
|
||||
partition: PartitionInfo
|
||||
drive: DiskInfo
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Inject, Injectable, DOCUMENT } from '@angular/core'
|
||||
import {
|
||||
DiskListResponse,
|
||||
DiskInfo,
|
||||
encodeBase64,
|
||||
FollowLogsRes,
|
||||
FullKeyboard,
|
||||
HttpService,
|
||||
isRpcError,
|
||||
RpcError,
|
||||
RPCOptions,
|
||||
SetLanguageParams,
|
||||
StartOSDiskInfo,
|
||||
} from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
@@ -14,6 +16,15 @@ import * as jose from 'node-jose'
|
||||
import { Observable } from 'rxjs'
|
||||
import { webSocket } from 'rxjs/webSocket'
|
||||
import { ApiService } from './api.service'
|
||||
import {
|
||||
SetupStatusRes,
|
||||
InstallOsParams,
|
||||
InstallOsRes,
|
||||
AttachParams,
|
||||
SetupExecuteParams,
|
||||
SetupCompleteRes,
|
||||
EchoReq,
|
||||
} from '../types'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -36,39 +47,54 @@ export class LiveApiService extends ApiService {
|
||||
})
|
||||
}
|
||||
|
||||
async getStatus(): Promise<T.SetupStatusRes | null> {
|
||||
return this.rpcRequest<T.SetupStatusRes | null>({
|
||||
async echo(params: EchoReq, url: string): Promise<string> {
|
||||
return this.rpcRequest({ method: 'echo', params }, url)
|
||||
}
|
||||
|
||||
async getStatus() {
|
||||
return this.rpcRequest<SetupStatusRes>({
|
||||
method: 'setup.status',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* We want to update the pubkey, which means that we will call in clearnet the
|
||||
* getPubKey, and all the information is never in the clear, and only public
|
||||
* information is sent across the network. We don't want to expose that we do
|
||||
* this wil all public/private key, which means that there is no information loss
|
||||
* through the network.
|
||||
*/
|
||||
async getPubKey(): Promise<void> {
|
||||
async getPubKey() {
|
||||
const response: jose.JWK.Key = await this.rpcRequest({
|
||||
method: 'setup.get-pubkey',
|
||||
params: {},
|
||||
})
|
||||
|
||||
this.pubkey = response
|
||||
}
|
||||
|
||||
async getDrives(): Promise<DiskListResponse> {
|
||||
return this.rpcRequest<DiskListResponse>({
|
||||
async setKeyboard(params: FullKeyboard): Promise<null> {
|
||||
return this.rpcRequest({
|
||||
method: 'setup.set-keyboard',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
async setLanguage(params: SetLanguageParams): Promise<null> {
|
||||
return this.rpcRequest({
|
||||
method: 'setup.set-language',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
async getDisks() {
|
||||
return this.rpcRequest<DiskInfo[]>({
|
||||
method: 'setup.disk.list',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async verifyCifs(
|
||||
source: T.VerifyCifsParams,
|
||||
): Promise<Record<string, StartOSDiskInfo>> {
|
||||
async installOs(params: InstallOsParams) {
|
||||
return this.rpcRequest<InstallOsRes>({
|
||||
method: 'setup.install-os',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
async verifyCifs(source: T.VerifyCifsParams) {
|
||||
source.path = source.path.replace('/\\/g', '/')
|
||||
return this.rpcRequest<Record<string, StartOSDiskInfo>>({
|
||||
method: 'setup.cifs.verify',
|
||||
@@ -76,33 +102,36 @@ export class LiveApiService extends ApiService {
|
||||
})
|
||||
}
|
||||
|
||||
async attach(params: T.AttachParams): Promise<T.SetupProgress> {
|
||||
async attach(params: AttachParams) {
|
||||
return this.rpcRequest<T.SetupProgress>({
|
||||
method: 'setup.attach',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
async execute(setupInfo: T.SetupExecuteParams): Promise<T.SetupProgress> {
|
||||
if (setupInfo.recoverySource?.type === 'backup') {
|
||||
if (isCifsSource(setupInfo.recoverySource.target)) {
|
||||
setupInfo.recoverySource.target.path =
|
||||
setupInfo.recoverySource.target.path.replace('/\\/g', '/')
|
||||
async execute(params: SetupExecuteParams) {
|
||||
if (params.recoverySource?.type === 'backup') {
|
||||
const target = params.recoverySource.target
|
||||
if (target.type === 'cifs') {
|
||||
target.path = target.path.replace('/\\/g', '/')
|
||||
}
|
||||
}
|
||||
|
||||
return this.rpcRequest<T.SetupProgress>({
|
||||
method: 'setup.execute',
|
||||
params: setupInfo,
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
async initFollowLogs(): Promise<FollowLogsRes> {
|
||||
return this.rpcRequest({ method: 'setup.logs.follow', params: {} })
|
||||
async initFollowLogs() {
|
||||
return this.rpcRequest<FollowLogsRes>({
|
||||
method: 'setup.logs.follow',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async complete(): Promise<T.SetupResult> {
|
||||
const res = await this.rpcRequest<T.SetupResult>({
|
||||
async complete() {
|
||||
const res = await this.rpcRequest<SetupCompleteRes>({
|
||||
method: 'setup.complete',
|
||||
params: {},
|
||||
})
|
||||
@@ -113,23 +142,29 @@ export class LiveApiService extends ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
async exit(): Promise<void> {
|
||||
async exit() {
|
||||
await this.rpcRequest<void>({
|
||||
method: 'setup.exit',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async restart(): Promise<void> {
|
||||
async shutdown() {
|
||||
await this.rpcRequest<void>({
|
||||
method: 'setup.shutdown',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async restart() {
|
||||
await this.rpcRequest<void>({
|
||||
method: 'setup.restart',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
private async rpcRequest<T>(opts: RPCOptions): Promise<T> {
|
||||
const res = await this.http.rpcRequest<T>(opts)
|
||||
|
||||
private async rpcRequest<T>(opts: RPCOptions, url?: string): Promise<T> {
|
||||
const res = await this.http.rpcRequest<T>(opts, url)
|
||||
const rpcRes = res.body
|
||||
|
||||
if (isRpcError(rpcRes)) {
|
||||
@@ -139,9 +174,3 @@ export class LiveApiService extends ApiService {
|
||||
return rpcRes.result
|
||||
}
|
||||
}
|
||||
|
||||
function isCifsSource(
|
||||
source: T.BackupTargetFS | null,
|
||||
): source is T.Cifs & { type: 'cifs' } {
|
||||
return !!(source as T.Cifs)?.hostname
|
||||
}
|
||||
|
||||
@@ -1,111 +1,33 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import {
|
||||
DiskListResponse,
|
||||
DiskInfo,
|
||||
encodeBase64,
|
||||
FollowLogsRes,
|
||||
FullKeyboard,
|
||||
pauseFor,
|
||||
SetLanguageParams,
|
||||
StartOSDiskInfo,
|
||||
} from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import * as jose from 'node-jose'
|
||||
import { first, interval, map, Observable } from 'rxjs'
|
||||
import { interval, map, Observable } from 'rxjs'
|
||||
import { ApiService } from './api.service'
|
||||
import {
|
||||
SetupStatusRes,
|
||||
InstallOsParams,
|
||||
InstallOsRes,
|
||||
AttachParams,
|
||||
SetupExecuteParams,
|
||||
SetupCompleteRes,
|
||||
EchoReq,
|
||||
} from '../types'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class MockApiService extends ApiService {
|
||||
// fullProgress$(): Observable<T.FullProgress> {
|
||||
// const phases = [
|
||||
// {
|
||||
// name: 'Preparing Data',
|
||||
// progress: null,
|
||||
// },
|
||||
// {
|
||||
// name: 'Transferring Data',
|
||||
// progress: null,
|
||||
// },
|
||||
// {
|
||||
// name: 'Finalizing Setup',
|
||||
// progress: null,
|
||||
// },
|
||||
// ]
|
||||
|
||||
// return from(phases).pipe(
|
||||
// switchScan((acc, val, i) => {}, { overall: null, phases }),
|
||||
// )
|
||||
// }
|
||||
|
||||
// namedProgress$(namedProgress: T.NamedProgress): Observable<T.NamedProgress> {
|
||||
// return of(namedProgress).pipe(startWith(namedProgress))
|
||||
// }
|
||||
|
||||
// progress$(progress: T.Progress): Observable<T.Progress> {}
|
||||
|
||||
// websocket
|
||||
|
||||
// oldMockProgress$(): Promise<T.FullProgress> {
|
||||
// const numPhases = PROGRESS.phases.length
|
||||
|
||||
// return of(PROGRESS).pipe(
|
||||
// switchMap(full =>
|
||||
// from(PROGRESS.phases).pipe(
|
||||
// mergeScan((full, phase, i) => {
|
||||
// if (
|
||||
// !phase.progress ||
|
||||
// typeof phase.progress !== 'object' ||
|
||||
// !phase.progress.total
|
||||
// ) {
|
||||
// full.phases[i].progress = true
|
||||
|
||||
// if (
|
||||
// full.overall &&
|
||||
// typeof full.overall === 'object' &&
|
||||
// full.overall.total
|
||||
// ) {
|
||||
// const step = full.overall.total / numPhases
|
||||
// full.overall.done += step
|
||||
// }
|
||||
|
||||
// return of(full).pipe(delay(2000))
|
||||
// } else {
|
||||
// const total = phase.progress.total
|
||||
// const step = total / 4
|
||||
// let done = phase.progress.done
|
||||
|
||||
// return interval(1000).pipe(
|
||||
// takeWhile(() => done < total),
|
||||
// map(() => {
|
||||
// done += step
|
||||
|
||||
// console.error(done)
|
||||
|
||||
// if (
|
||||
// full.overall &&
|
||||
// typeof full.overall === 'object' &&
|
||||
// full.overall.total
|
||||
// ) {
|
||||
// const step = full.overall.total / numPhases / 4
|
||||
|
||||
// full.overall.done += step
|
||||
// }
|
||||
|
||||
// if (done === total) {
|
||||
// full.phases[i].progress = true
|
||||
|
||||
// if (i === numPhases - 1) {
|
||||
// full.overall = true
|
||||
// }
|
||||
// }
|
||||
// return full
|
||||
// }),
|
||||
// )
|
||||
// }
|
||||
// }, full),
|
||||
// ),
|
||||
// ),
|
||||
// )
|
||||
// }
|
||||
private statusIndex = 0
|
||||
private installCompleted = false
|
||||
|
||||
openWebsocket$<T>(guid: string): Observable<T> {
|
||||
if (guid === 'logs-guid') {
|
||||
@@ -117,24 +39,13 @@ export class MockApiService extends ApiService {
|
||||
})),
|
||||
) as Observable<T>
|
||||
} else if (guid === 'progress-guid') {
|
||||
// @TODO Matt mock progress
|
||||
return interval(1000).pipe(
|
||||
first(),
|
||||
map(() => ({
|
||||
overall: true,
|
||||
phases: [
|
||||
{
|
||||
name: 'Preparing Data',
|
||||
progress: true,
|
||||
},
|
||||
{
|
||||
name: 'Transferring Data',
|
||||
progress: true,
|
||||
},
|
||||
{
|
||||
name: 'Finalizing Setup',
|
||||
progress: true,
|
||||
},
|
||||
{ name: 'Preparing Data', progress: true },
|
||||
{ name: 'Transferring Data', progress: true },
|
||||
{ name: 'Finalizing Setup', progress: true },
|
||||
],
|
||||
})),
|
||||
) as Observable<T>
|
||||
@@ -143,40 +54,44 @@ export class MockApiService extends ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
private statusIndex = 0
|
||||
async getStatus(): Promise<T.SetupStatusRes | null> {
|
||||
await pauseFor(1000)
|
||||
async echo(params: EchoReq, url: string): Promise<string> {
|
||||
if (url) {
|
||||
const num = Math.floor(Math.random() * 10) + 1
|
||||
if (num > 8) return params.message
|
||||
throw new Error()
|
||||
}
|
||||
await pauseFor(500)
|
||||
return params.message
|
||||
}
|
||||
|
||||
async getStatus(): Promise<SetupStatusRes> {
|
||||
await pauseFor(500)
|
||||
|
||||
this.statusIndex++
|
||||
|
||||
switch (this.statusIndex) {
|
||||
case 2:
|
||||
return {
|
||||
status: 'running',
|
||||
progress: PROGRESS,
|
||||
guid: 'progress-guid',
|
||||
}
|
||||
case 3:
|
||||
return {
|
||||
status: 'complete',
|
||||
torAddresses: ['https://asdafsadasdasasdasdfasdfasdf.onion'],
|
||||
hostname: 'adjective-noun',
|
||||
lanAddress: 'https://adjective-noun.local',
|
||||
rootCa: encodeBase64(rootCA),
|
||||
}
|
||||
default:
|
||||
return null
|
||||
if (this.statusIndex === 1) {
|
||||
return { status: 'needs-install', keyboard: null }
|
||||
// return {
|
||||
// status: 'incomplete',
|
||||
// attach: false,
|
||||
// guid: 'mock-data-guid',
|
||||
// keyboard: null,
|
||||
// }
|
||||
}
|
||||
|
||||
if (this.statusIndex > 3) {
|
||||
return { status: 'complete' }
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'running',
|
||||
progress: PROGRESS,
|
||||
guid: 'progress-guid',
|
||||
}
|
||||
}
|
||||
|
||||
async getPubKey(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
|
||||
// randomly generated
|
||||
// const keystore = jose.JWK.createKeyStore()
|
||||
// this.pubkey = await keystore.generate('EC', 'P-256')
|
||||
|
||||
// generated from backend
|
||||
await pauseFor(300)
|
||||
this.pubkey = await jose.JWK.asKey({
|
||||
kty: 'EC',
|
||||
crv: 'P-256',
|
||||
@@ -185,88 +100,28 @@ export class MockApiService extends ApiService {
|
||||
})
|
||||
}
|
||||
|
||||
async getDrives(): Promise<DiskListResponse> {
|
||||
await pauseFor(1000)
|
||||
return [
|
||||
{
|
||||
logicalname: '/dev/nvme0n1p3',
|
||||
vendor: 'Unknown Vendor',
|
||||
model: 'Samsung SSD - 970 EVO Plus 2TB',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: 'pabcd',
|
||||
label: null,
|
||||
capacity: 1979120929996,
|
||||
used: null,
|
||||
startOs: {
|
||||
'1234-5678-9876-5432': {
|
||||
hostname: 'adjective-noun',
|
||||
version: '0.2.17',
|
||||
timestamp: new Date().toISOString(),
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
},
|
||||
},
|
||||
guid: null,
|
||||
},
|
||||
],
|
||||
capacity: 1979120929996,
|
||||
guid: 'uuid-uuid-uuid-uuid',
|
||||
},
|
||||
{
|
||||
logicalname: 'dcba',
|
||||
vendor: 'CT1000MX',
|
||||
model: '500SSD1',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: 'pbcba',
|
||||
label: null,
|
||||
capacity: 73264762332,
|
||||
used: null,
|
||||
startOs: {
|
||||
'1234-5678-9876-5432': {
|
||||
hostname: 'adjective-noun',
|
||||
version: '0.2.17',
|
||||
timestamp: new Date().toISOString(),
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
},
|
||||
},
|
||||
guid: null,
|
||||
},
|
||||
],
|
||||
capacity: 1000190509056,
|
||||
guid: null,
|
||||
},
|
||||
{
|
||||
logicalname: '/dev/sda',
|
||||
vendor: 'ASMT',
|
||||
model: '2115',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: 'pbcba',
|
||||
label: null,
|
||||
capacity: 73264762332,
|
||||
used: null,
|
||||
startOs: {
|
||||
'1234-5678-9876-5432': {
|
||||
hostname: 'adjective-noun',
|
||||
version: '0.2.17',
|
||||
timestamp: new Date().toISOString(),
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
},
|
||||
},
|
||||
guid: 'guid-guid-guid-guid',
|
||||
},
|
||||
],
|
||||
capacity: 1000190509,
|
||||
guid: null,
|
||||
},
|
||||
]
|
||||
async setKeyboard(_params: FullKeyboard): Promise<null> {
|
||||
await pauseFor(300)
|
||||
return null
|
||||
}
|
||||
|
||||
async setLanguage(params: SetLanguageParams): Promise<null> {
|
||||
await pauseFor(300)
|
||||
return null
|
||||
}
|
||||
|
||||
async getDisks(): Promise<DiskInfo[]> {
|
||||
await pauseFor(500)
|
||||
return MOCK_DISKS
|
||||
}
|
||||
|
||||
async installOs(params: InstallOsParams): Promise<InstallOsRes> {
|
||||
await pauseFor(2000)
|
||||
this.installCompleted = true
|
||||
return {
|
||||
guid: 'mock-data-guid',
|
||||
attach: !params.dataDrive.wipe,
|
||||
}
|
||||
}
|
||||
|
||||
async verifyCifs(
|
||||
@@ -282,21 +137,29 @@ export class MockApiService extends ApiService {
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: '',
|
||||
},
|
||||
'9876-5432-1234-5671': {
|
||||
hostname: 'adjective-noun',
|
||||
version: '0.3.6',
|
||||
timestamp: new Date().toISOString(),
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async attach(params: T.AttachParams): Promise<T.SetupProgress> {
|
||||
async attach(params: AttachParams): Promise<T.SetupProgress> {
|
||||
await pauseFor(1000)
|
||||
|
||||
this.statusIndex = 1 // Jump to running state
|
||||
return {
|
||||
progress: PROGRESS,
|
||||
guid: 'progress-guid',
|
||||
}
|
||||
}
|
||||
|
||||
async execute(setupInfo: T.SetupExecuteParams): Promise<T.SetupProgress> {
|
||||
async execute(params: SetupExecuteParams): Promise<T.SetupProgress> {
|
||||
await pauseFor(1000)
|
||||
|
||||
this.statusIndex = 1 // Jump to running state
|
||||
return {
|
||||
progress: PROGRESS,
|
||||
guid: 'progress-guid',
|
||||
@@ -304,33 +167,113 @@ export class MockApiService extends ApiService {
|
||||
}
|
||||
|
||||
async initFollowLogs(): Promise<FollowLogsRes> {
|
||||
await pauseFor(1000)
|
||||
await pauseFor(500)
|
||||
return {
|
||||
startCursor: 'fakestartcursor',
|
||||
guid: 'logs-guid',
|
||||
}
|
||||
}
|
||||
|
||||
async complete(): Promise<T.SetupResult> {
|
||||
await pauseFor(1000)
|
||||
async complete(): Promise<SetupCompleteRes> {
|
||||
await pauseFor(500)
|
||||
return {
|
||||
torAddresses: ['https://asdafsadasdasasdasdfasdfasdf.onion'],
|
||||
hostname: 'adjective-noun',
|
||||
lanAddress: 'https://adjective-noun.local',
|
||||
rootCa: encodeBase64(rootCA),
|
||||
rootCa: encodeBase64(ROOT_CA),
|
||||
needsRestart: this.installCompleted,
|
||||
}
|
||||
}
|
||||
|
||||
async exit(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
await pauseFor(500)
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
await pauseFor(500)
|
||||
}
|
||||
|
||||
async restart(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
await pauseFor(500)
|
||||
}
|
||||
}
|
||||
|
||||
const rootCA = `-----BEGIN CERTIFICATE-----
|
||||
const MOCK_DISKS: DiskInfo[] = [
|
||||
{
|
||||
logicalname: '/dev/sda',
|
||||
vendor: 'Samsung',
|
||||
model: 'SSD 970 EVO Plus',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: '/dev/sda1',
|
||||
label: null,
|
||||
capacity: 500000000000,
|
||||
used: null,
|
||||
startOs: {},
|
||||
guid: null,
|
||||
},
|
||||
],
|
||||
capacity: 500000000000,
|
||||
guid: null,
|
||||
},
|
||||
{
|
||||
logicalname: '/dev/sdb',
|
||||
vendor: 'Crucial',
|
||||
model: 'MX500',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: '/dev/sdb1',
|
||||
label: null,
|
||||
capacity: 1000000000000,
|
||||
used: null,
|
||||
startOs: {
|
||||
'1234-5678-9876-5432': {
|
||||
hostname: 'existing-server',
|
||||
version: '0.3.6',
|
||||
timestamp: new Date().toISOString(),
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
},
|
||||
},
|
||||
guid: 'existing-guid',
|
||||
},
|
||||
],
|
||||
capacity: 1000000000000,
|
||||
guid: 'existing-guid',
|
||||
},
|
||||
{
|
||||
logicalname: '/dev/sdc',
|
||||
vendor: 'WD',
|
||||
model: 'Blue SN570',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: '/dev/sdc1',
|
||||
label: 'Backup',
|
||||
capacity: 2000000000000,
|
||||
used: 500000000000,
|
||||
startOs: {
|
||||
'backup-server-id': {
|
||||
hostname: 'backup-server',
|
||||
version: '0.3.5',
|
||||
timestamp: new Date(Date.now() - 86400000).toISOString(),
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: '',
|
||||
},
|
||||
},
|
||||
guid: null,
|
||||
},
|
||||
],
|
||||
capacity: 2000000000000,
|
||||
guid: null,
|
||||
},
|
||||
]
|
||||
|
||||
const PROGRESS: T.FullProgress = {
|
||||
overall: null,
|
||||
phases: [],
|
||||
}
|
||||
|
||||
const ROOT_CA = `-----BEGIN CERTIFICATE-----
|
||||
MIIDpzCCAo+gAwIBAgIRAIIuOarlQETlUQEOZJGZYdIwDQYJKoZIhvcNAQELBQAw
|
||||
bTELMAkGA1UEBhMCVVMxFTATBgNVBAoMDEV4YW1wbGUgQ29ycDEOMAwGA1UECwwF
|
||||
U2FsZXMxCzAJBgNVBAgMAldBMRgwFgYDVQQDDA93d3cuZXhhbXBsZS5jb20xEDAO
|
||||
@@ -352,8 +295,3 @@ Rf3ZOPm9QP92YpWyYDkfAU04xdDo1vR0MYjKPkl4LjRqSU/tcCJnPMbJiwq+bWpX
|
||||
2WJoEBXB/p15Kn6JxjI0ze2SnSI48JZ8it4fvxrhOo0VoLNIuCuNXJOwU17Rdl1W
|
||||
YJidaq7je6k18AdgPA0Kh8y1XtfUH3fTaVw4
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
const PROGRESS = {
|
||||
overall: null,
|
||||
phases: [],
|
||||
}
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { ApiService } from './api.service'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { ApiService } from './api.service'
|
||||
|
||||
export type SetupType = 'fresh' | 'restore' | 'attach' | 'transfer'
|
||||
|
||||
export type RecoverySource =
|
||||
| {
|
||||
type: 'migrate'
|
||||
guid: string
|
||||
}
|
||||
| {
|
||||
type: 'backup'
|
||||
target:
|
||||
| { type: 'disk'; logicalname: string }
|
||||
| {
|
||||
type: 'cifs'
|
||||
hostname: string
|
||||
path: string
|
||||
username: string
|
||||
password: string | null
|
||||
}
|
||||
serverId: string
|
||||
password: string // plaintext, will be encrypted before sending
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -8,34 +30,68 @@ 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>
|
||||
// Determined at app init
|
||||
kiosk = false
|
||||
|
||||
async importDrive(guid: string, password: string): Promise<void> {
|
||||
// Set during install flow, or loaded from status response
|
||||
language = ''
|
||||
keyboard = ''
|
||||
|
||||
// From install response or status response (incomplete)
|
||||
dataDriveGuid = ''
|
||||
attach = false
|
||||
|
||||
// Set during setup flow
|
||||
setupType?: SetupType
|
||||
recoverySource?: RecoverySource
|
||||
|
||||
/**
|
||||
* Called for attach flow (existing data drive)
|
||||
*/
|
||||
async attachDrive(password: string | null): Promise<void> {
|
||||
await this.api.attach({
|
||||
guid,
|
||||
startOsPassword: await this.api.encrypt(password),
|
||||
kiosk: this.kiosk,
|
||||
guid: this.dataDriveGuid,
|
||||
password: password ? await this.api.encrypt(password) : null,
|
||||
})
|
||||
}
|
||||
|
||||
async setupEmbassy(
|
||||
storageLogicalname: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
/**
|
||||
* Called for fresh, restore, and transfer flows
|
||||
* password is required for fresh, optional for restore/transfer
|
||||
*/
|
||||
async executeSetup(password: string | null): Promise<void> {
|
||||
let recoverySource: T.RecoverySource<T.EncryptedWire> | null = null
|
||||
|
||||
if (this.recoverySource) {
|
||||
if (this.recoverySource.type === 'migrate') {
|
||||
recoverySource = this.recoverySource
|
||||
} else {
|
||||
// backup type - need to encrypt the backup password
|
||||
recoverySource = {
|
||||
type: 'backup',
|
||||
target: this.recoverySource.target,
|
||||
serverId: this.recoverySource.serverId,
|
||||
password: await this.api.encrypt(this.recoverySource.password),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.api.execute({
|
||||
startOsLogicalname: storageLogicalname,
|
||||
startOsPassword: await this.api.encrypt(password),
|
||||
recoverySource: this.recoverySource
|
||||
? this.recoverySource.type === 'migrate'
|
||||
? this.recoverySource
|
||||
: {
|
||||
...this.recoverySource,
|
||||
password: await this.api.encrypt(this.recoverySource.password),
|
||||
}
|
||||
: null,
|
||||
kiosk: this.kiosk,
|
||||
guid: this.dataDriveGuid,
|
||||
password: password ? await this.api.encrypt(password) : null,
|
||||
recoverySource,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state for a fresh start
|
||||
*/
|
||||
reset(): void {
|
||||
this.language = ''
|
||||
this.keyboard = ''
|
||||
this.dataDriveGuid = ''
|
||||
this.attach = false
|
||||
this.setupType = undefined
|
||||
this.recoverySource = undefined
|
||||
}
|
||||
}
|
||||
|
||||
94
web/projects/setup-wizard/src/app/types.ts
Normal file
94
web/projects/setup-wizard/src/app/types.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
DiskInfo,
|
||||
FullKeyboard,
|
||||
PartitionInfo,
|
||||
StartOSDiskInfo,
|
||||
} from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
|
||||
// === Echo ===
|
||||
|
||||
export type EchoReq = {
|
||||
message: string
|
||||
}
|
||||
|
||||
// === Setup Status ===
|
||||
|
||||
export type SetupStatusRes =
|
||||
| { status: 'needs-install'; keyboard: FullKeyboard | null }
|
||||
| {
|
||||
status: 'incomplete'
|
||||
guid: string
|
||||
attach: boolean
|
||||
keyboard: FullKeyboard | null
|
||||
}
|
||||
| { status: 'running'; progress: T.FullProgress; guid: string }
|
||||
| { status: 'complete' }
|
||||
|
||||
// === Install OS ===
|
||||
|
||||
export interface InstallOsParams {
|
||||
osDrive: string // e.g. /dev/sda
|
||||
dataDrive: {
|
||||
logicalname: string // e.g. /dev/sda, /dev/sdb3
|
||||
wipe: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface InstallOsRes {
|
||||
guid: string // data drive guid
|
||||
attach: boolean
|
||||
}
|
||||
|
||||
// === Attach ===
|
||||
|
||||
export interface AttachParams {
|
||||
password: T.EncryptedWire | null
|
||||
guid: string // data drive
|
||||
}
|
||||
|
||||
// === Execute ===
|
||||
|
||||
export interface SetupExecuteParams {
|
||||
guid: string
|
||||
password: T.EncryptedWire | null // null = keep existing password (for restore/transfer)
|
||||
recoverySource:
|
||||
| {
|
||||
type: 'migrate'
|
||||
guid: string
|
||||
}
|
||||
| {
|
||||
type: 'backup'
|
||||
target:
|
||||
| { type: 'disk'; logicalname: string }
|
||||
| {
|
||||
type: 'cifs'
|
||||
hostname: string
|
||||
path: string
|
||||
username: string
|
||||
password: string | null
|
||||
}
|
||||
password: T.EncryptedWire
|
||||
serverId: string
|
||||
}
|
||||
| null
|
||||
}
|
||||
|
||||
// === Complete ===
|
||||
|
||||
export interface SetupCompleteRes {
|
||||
hostname: string // unique.local
|
||||
rootCa: string
|
||||
needsRestart: boolean
|
||||
}
|
||||
|
||||
// === Disk Info Helpers ===
|
||||
|
||||
export type StartOSDiskInfoWithId = StartOSDiskInfo & {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type StartOSDiskInfoFull = StartOSDiskInfoWithId & {
|
||||
partition: PartitionInfo
|
||||
drive: DiskInfo
|
||||
}
|
||||
@@ -19,16 +19,29 @@ router-outlet + * {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
margin: 0 auto;
|
||||
|
||||
[tuiCardLarge] {
|
||||
width: 100%;
|
||||
background: var(--tui-background-elevation-2);
|
||||
margin: auto;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
:first-child {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: var(--tui-disabled-opacity);
|
||||
pointer-events: none;
|
||||
@@ -74,4 +87,4 @@ h2 {
|
||||
|
||||
[tuiCell]:not(:last-of-type) {
|
||||
box-shadow: 0 calc(0.5rem + 1px) 0 -0.5rem var(--tui-border-normal);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user