mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 02:11:53 +00:00
combine install and setup and refactor all
This commit is contained in:
committed by
Aiden McClelland
parent
645083913c
commit
42ef2bdf7e
2
.github/workflows/startos-iso.yaml
vendored
2
.github/workflows/startos-iso.yaml
vendored
@@ -251,10 +251,8 @@ jobs:
|
||||
mkdir -p patch-db/client/dist
|
||||
mkdir -p web/.angular
|
||||
mkdir -p web/dist/raw/ui
|
||||
mkdir -p web/dist/raw/install-wizard
|
||||
mkdir -p web/dist/raw/setup-wizard
|
||||
mkdir -p web/dist/static/ui
|
||||
mkdir -p web/dist/static/install-wizard
|
||||
mkdir -p web/dist/static/setup-wizard
|
||||
PLATFORM=${{ matrix.platform }} make -t compiled-${{ env.ARCH }}.tar
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ StartOS web UIs are written in [Angular/Typescript](https://angular.io/docs) and
|
||||
|
||||
StartOS conditionally serves one of three Web UIs, depending on the state of the system and user choice.
|
||||
|
||||
- **install-wizard** - UI for installing StartOS, served on localhost.
|
||||
- **setup-wizard** - UI for setting up StartOS, served on start.local.
|
||||
- **ui** - primary UI for administering StartOS, served on various hosts unique to the instance.
|
||||
|
||||
@@ -69,7 +68,6 @@ You can develop using mocks (recommended to start) or against a live server. Eit
|
||||
#### Start the standard development server
|
||||
|
||||
```sh
|
||||
npm run start:install
|
||||
npm run start:setup
|
||||
npm run start:ui
|
||||
```
|
||||
@@ -122,7 +120,6 @@ Translate the English dictionary below into `<language>`. Format the result as a
|
||||
#### Adding to StartOS
|
||||
|
||||
- In the `shared` project:
|
||||
|
||||
1. Create a new file (`language.ts`) in `src/i18n/dictionaries`
|
||||
2. Update the `I18N_PROVIDERS` array in `src/i18n/i18n.providers.ts` (2 places)
|
||||
3. Update the `languages` array in `/src/i18n/i18n.service.ts`
|
||||
@@ -131,7 +128,6 @@ Translate the English dictionary below into `<language>`. Format the result as a
|
||||
If you have any doubt about the above steps, check the [French example PR](https://github.com/Start9Labs/start-os/pull/2945/files) for reference.
|
||||
|
||||
- Here in this README:
|
||||
|
||||
1. Add the language to the list of supported languages below
|
||||
|
||||
### Updating the English dictionary
|
||||
|
||||
108
web/angular.json
108
web/angular.json
@@ -121,114 +121,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"install-wizard": {
|
||||
"projectType": "application",
|
||||
"schematics": {},
|
||||
"root": "projects/install-wizard",
|
||||
"sourceRoot": "projects/install-wizard/src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"outputPath": {
|
||||
"base": "dist/raw/install-wizard",
|
||||
"browser": ""
|
||||
},
|
||||
"index": "projects/install-wizard/src/index.html",
|
||||
"browser": "projects/install-wizard/src/main.ts",
|
||||
"polyfills": ["zone.js"],
|
||||
"tsConfig": "projects/install-wizard/tsconfig.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "projects/shared/assets",
|
||||
"output": "assets"
|
||||
},
|
||||
{
|
||||
"glob": "**/*.svg",
|
||||
"input": "node_modules/ionicons/dist/ionicons/svg",
|
||||
"output": "./svg"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/@taiga-ui/icons/src",
|
||||
"output": "assets/taiga-ui/icons"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"node_modules/@taiga-ui/core/styles/taiga-ui-theme.less"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "projects/install-wizard/src/environments/environment.ts",
|
||||
"with": "projects/install-wizard/src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"namedChunks": false,
|
||||
"aot": true,
|
||||
"extractLicenses": true,
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2mb",
|
||||
"maximumError": "5mb"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ci": {
|
||||
"progress": false
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true,
|
||||
"namedChunks": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"options": {
|
||||
"buildTarget": "install-wizard:build",
|
||||
"port": 8100
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "install-wizard:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "install-wizard:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular/build:extract-i18n",
|
||||
"options": {
|
||||
"buildTarget": "install-wizard:build"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-eslint/builder:lint",
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"projects/install-wizard/src/**/*.ts",
|
||||
"projects/install-wizard/src/**/*.html"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"setup-wizard": {
|
||||
"projectType": "application",
|
||||
"schematics": {},
|
||||
|
||||
@@ -4,7 +4,6 @@ module.exports = {
|
||||
'projects/ui/**/*.ts': () => 'npm run check:ui',
|
||||
'projects/shared/**/*.ts': () => 'npm run check:shared',
|
||||
'projects/marketplace/**/*.ts': () => 'npm run check:marketplace',
|
||||
'projects/install-wizard/**/*.ts': () => 'npm run check:install',
|
||||
'projects/setup-wizard/**/*.ts': () => 'npm run check:setup',
|
||||
'projects/start-tunnel/**/*.ts': () => 'npm run check:tunnel',
|
||||
}
|
||||
|
||||
@@ -6,26 +6,23 @@
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"check": "npm run check:shared && npm run check:marketplace && npm run check:ui && npm run check:install && npm run check:setup",
|
||||
"check": "npm run check:shared && npm run check:marketplace && npm run check:ui && npm run check:setup",
|
||||
"check:shared": "tsc --project projects/shared/tsconfig.json --noEmit --skipLibCheck",
|
||||
"check:marketplace": "tsc --project projects/marketplace/tsconfig.json --noEmit --skipLibCheck",
|
||||
"check:install": "tsc --project projects/install-wizard/tsconfig.json --noEmit --skipLibCheck",
|
||||
"check:setup": "tsc --project projects/setup-wizard/tsconfig.json --noEmit --skipLibCheck",
|
||||
"check:ui": "tsc --project projects/ui/tsconfig.json --noEmit --skipLibCheck",
|
||||
"check:tunnel": "tsc --project projects/start-tunnel/tsconfig.json --noEmit --skipLibCheck",
|
||||
"build:deps": "rimraf .angular/cache && (cd ../sdk && make bundle) && (cd ../patch-db/client && npm ci && npm run build)",
|
||||
"build:install": "ng run install-wizard:build",
|
||||
"build:setup": "ng run setup-wizard:build",
|
||||
"build:ui": "ng run ui:build",
|
||||
"build:ui:dev": "ng run ui:build:development",
|
||||
"build:tunnel": "ng run start-tunnel:build",
|
||||
"build:all": "npm run build:deps && npm run build:setup && npm run build:ui && npm run build:install",
|
||||
"build:all": "npm run build:deps && npm run build:setup && npm run build:ui",
|
||||
"build:shared": "ng build shared",
|
||||
"build:marketplace": "npm run build:shared && ng build marketplace",
|
||||
"analyze:ui": "ng build ui --stats-json --named-chunks && npx -y @angular-experts/hawkeye dist/raw/ui/stats.json",
|
||||
"publish:shared": "npm run build:shared && npm publish ./dist/shared --access public",
|
||||
"publish:marketplace": "npm run build:marketplace && npm publish ./dist/marketplace --access public",
|
||||
"start:install": "npm run-script build-config && ng serve --project install-wizard --host 0.0.0.0",
|
||||
"start:setup": "npm run-script build-config && ng serve --project setup-wizard --host 0.0.0.0",
|
||||
"start:ui": "npm run-script build-config && ng serve --project ui --host 0.0.0.0",
|
||||
"start:tunnel": "ng serve --project start-tunnel --host 0.0.0.0",
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
<tui-root>
|
||||
<main>
|
||||
<img class="logo" src="assets/img/icon.png" alt="Start9" />
|
||||
<section tuiCardLarge tuiSurface="floating" class="card">
|
||||
<header class="header">
|
||||
@if (selected) {
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="flat-grayscale"
|
||||
size="m"
|
||||
class="back"
|
||||
iconStart="@tui.chevron-left"
|
||||
[style.border-radius.rem]="10"
|
||||
(click)="selected = null"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
}
|
||||
<h1>{{ selected ? 'Install Type' : 'StartOS Install' }}</h1>
|
||||
@if (!selected) {
|
||||
<h2>Select Disk</h2>
|
||||
}
|
||||
<div [style.color]="'var(--tui-text-negative)'">{{ error }}</div>
|
||||
</header>
|
||||
<div class="pages">
|
||||
<div class="options" [class.options_selected]="selected">
|
||||
@for (drive of disks$ | async; track $index) {
|
||||
<button tuiCell [drive]="drive" (click)="selected = drive"></button>
|
||||
}
|
||||
</div>
|
||||
<div class="options">
|
||||
@if (guid) {
|
||||
<button tuiCell (click)="install()">
|
||||
<tui-icon icon="@tui.life-buoy" />
|
||||
<span tuiTitle>
|
||||
<strong [style.color]="'var(--tui-text-positive)'">
|
||||
Re-Install StartOS
|
||||
</strong>
|
||||
<span tuiSubtitle>Will preserve existing StartOS data</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button tuiCell [disabled]="!selected" (click)="warn()">
|
||||
<tui-icon icon="@tui.download" />
|
||||
<span tuiTitle>
|
||||
@if (guid) {
|
||||
<span [style.color]="'var(--tui-text-negative)'">
|
||||
Factory Reset
|
||||
</span>
|
||||
} @else {
|
||||
<span [style.color]="'var(--tui-text-positive)'">
|
||||
Install StartOS
|
||||
</span>
|
||||
}
|
||||
<span tuiSubtitle>Will delete existing data on disk</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</tui-root>
|
||||
@@ -1,71 +0,0 @@
|
||||
@use '@taiga-ui/core/styles/taiga-ui-local' as taiga;
|
||||
|
||||
::ng-deep html,
|
||||
::ng-deep body,
|
||||
tui-root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
color: var(--tui-text-primary);
|
||||
}
|
||||
|
||||
main {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--tui-background-accent-opposite-hover);
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 6rem;
|
||||
margin-bottom: -2rem;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.card {
|
||||
max-width: min(30rem, 90vw);
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding-top: 0.25rem;
|
||||
margin-bottom: -2rem;
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.back {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
.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;
|
||||
|
||||
&_selected {
|
||||
margin-left: -100%;
|
||||
}
|
||||
}
|
||||
|
||||
[tuiCell]:not(:last-of-type) {
|
||||
box-shadow: 0 calc(0.5rem + 1px) 0 -0.5rem var(--tui-border-normal);
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { TUI_CONFIRM } from '@taiga-ui/kit'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { DiskInfo, i18nKey, LoadingService, toGuid } from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { filter, from } from 'rxjs'
|
||||
import { SUCCESS, toWarning } from 'src/app/app.utils'
|
||||
import { ApiService } from 'src/app/services/api.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: 'app.component.html',
|
||||
styleUrls: ['app.component.scss'],
|
||||
standalone: false,
|
||||
})
|
||||
export class AppComponent {
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
|
||||
readonly disks$ = from(this.api.getDisks())
|
||||
selected: DiskInfo | null = null
|
||||
error = ''
|
||||
|
||||
get guid() {
|
||||
return toGuid(this.selected)
|
||||
}
|
||||
|
||||
async install(overwrite = false) {
|
||||
const loader = this.loader.open('Installing StartOS' as i18nKey).subscribe()
|
||||
const logicalname = this.selected?.logicalname || ''
|
||||
|
||||
try {
|
||||
await this.api.install({ logicalname, overwrite })
|
||||
this.reboot()
|
||||
} catch (e: any) {
|
||||
this.error = e.message
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
warn() {
|
||||
this.dialogs
|
||||
.open(TUI_CONFIRM, toWarning(this.selected))
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => {
|
||||
this.install(true)
|
||||
})
|
||||
}
|
||||
|
||||
private async reboot() {
|
||||
this.dialogs
|
||||
.open('1. Remove the USB stick<br />2. Click "Reboot" below', SUCCESS)
|
||||
.subscribe({
|
||||
complete: async () => {
|
||||
const loader = this.loader.open().subscribe()
|
||||
|
||||
try {
|
||||
await this.api.reboot()
|
||||
this.dialogs
|
||||
.open(
|
||||
window.location.host === 'localhost'
|
||||
? 'Please wait 1-2 minutes for your server to restart'
|
||||
: 'Please wait 1-2 minutes, then refresh this page to access the StartOS setup wizard.',
|
||||
{
|
||||
label: 'Rebooting',
|
||||
size: 's',
|
||||
closeable: false,
|
||||
dismissible: false,
|
||||
},
|
||||
)
|
||||
.subscribe()
|
||||
} catch (e: any) {
|
||||
this.error = e.message
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import {
|
||||
provideHttpClient,
|
||||
withFetch,
|
||||
withInterceptorsFromDi,
|
||||
} from '@angular/common/http'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
|
||||
import {
|
||||
DriveComponent,
|
||||
i18nPipe,
|
||||
RELATIVE_URL,
|
||||
WorkspaceConfig,
|
||||
} from '@start9labs/shared'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiIcon,
|
||||
TuiRoot,
|
||||
TuiSurface,
|
||||
TuiTitle,
|
||||
} from '@taiga-ui/core'
|
||||
import { NG_EVENT_PLUGINS } from '@taiga-ui/event-plugins'
|
||||
import { TuiCardLarge, TuiCell } from '@taiga-ui/layout'
|
||||
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 { AppComponent } from './app.component'
|
||||
|
||||
const {
|
||||
useMocks,
|
||||
ui: { api },
|
||||
} = require('../../../../config.json') as WorkspaceConfig
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent],
|
||||
imports: [
|
||||
BrowserAnimationsModule,
|
||||
TuiRoot,
|
||||
DriveComponent,
|
||||
TuiButton,
|
||||
TuiCardLarge,
|
||||
TuiCell,
|
||||
TuiIcon,
|
||||
TuiSurface,
|
||||
TuiTitle,
|
||||
i18nPipe,
|
||||
],
|
||||
providers: [
|
||||
NG_EVENT_PLUGINS,
|
||||
{
|
||||
provide: ApiService,
|
||||
useClass: useMocks ? MockApiService : LiveApiService,
|
||||
},
|
||||
{
|
||||
provide: RELATIVE_URL,
|
||||
useValue: `/${api.url}/${api.version}`,
|
||||
},
|
||||
provideHttpClient(withInterceptorsFromDi(), withFetch()),
|
||||
],
|
||||
bootstrap: [AppComponent],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { DiskInfo } from '@start9labs/shared'
|
||||
import { TuiDialogOptions } from '@taiga-ui/core'
|
||||
import { TuiConfirmData } from '@taiga-ui/kit'
|
||||
|
||||
export const SUCCESS: Partial<TuiDialogOptions<any>> = {
|
||||
label: 'Install Success!',
|
||||
closeable: false,
|
||||
size: 's',
|
||||
data: { button: 'Reboot' },
|
||||
}
|
||||
|
||||
export function toWarning(
|
||||
disk: DiskInfo | null,
|
||||
): Partial<TuiDialogOptions<TuiConfirmData>> {
|
||||
return {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content: `This action will COMPLETELY erase the disk ${
|
||||
disk?.vendor || 'Unknown Vendor'
|
||||
} - ${disk?.model || 'Unknown Model'} and install StartOS in its place`,
|
||||
yes: 'Continue',
|
||||
no: 'Cancel',
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { DiskInfo } from '@start9labs/shared'
|
||||
|
||||
export abstract class ApiService {
|
||||
abstract getDisks(): Promise<GetDisksRes> // install.disk.list
|
||||
abstract install(params: InstallReq): Promise<void> // install.execute
|
||||
abstract reboot(): Promise<void> // install.reboot
|
||||
}
|
||||
|
||||
export type GetDisksRes = DiskInfo[]
|
||||
|
||||
export type InstallReq = {
|
||||
logicalname: string
|
||||
overwrite: boolean
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import {
|
||||
HttpService,
|
||||
isRpcError,
|
||||
RpcError,
|
||||
RPCOptions,
|
||||
} from '@start9labs/shared'
|
||||
import { ApiService, GetDisksRes, InstallReq } from './api.service'
|
||||
|
||||
@Injectable()
|
||||
export class LiveApiService implements ApiService {
|
||||
constructor(private readonly http: HttpService) {}
|
||||
|
||||
async getDisks(): Promise<GetDisksRes> {
|
||||
return this.rpcRequest({
|
||||
method: 'install.disk.list',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async install(params: InstallReq): Promise<void> {
|
||||
return this.rpcRequest<void>({
|
||||
method: 'install.execute',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
async reboot(): Promise<void> {
|
||||
return this.rpcRequest<void>({
|
||||
method: 'install.reboot',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
private async rpcRequest<T>(opts: RPCOptions): Promise<T> {
|
||||
const res = await this.http.rpcRequest<T>(opts)
|
||||
|
||||
const rpcRes = res.body
|
||||
|
||||
if (isRpcError(rpcRes)) {
|
||||
throw new RpcError(rpcRes.error)
|
||||
}
|
||||
|
||||
return rpcRes.result
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { pauseFor } from '@start9labs/shared'
|
||||
import { ApiService, GetDisksRes, InstallReq } from './api.service'
|
||||
|
||||
@Injectable()
|
||||
export class MockApiService implements ApiService {
|
||||
async getDisks(): Promise<GetDisksRes> {
|
||||
await pauseFor(500)
|
||||
return [
|
||||
{
|
||||
logicalname: 'abcd',
|
||||
vendor: 'Samsung',
|
||||
model: 'T5',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: 'pabcd',
|
||||
label: null,
|
||||
capacity: 73264762332,
|
||||
used: null,
|
||||
startOs: {
|
||||
'1234-5678-9876-5432': {
|
||||
hostname: 'adjective-noun',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '0.2.17',
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
},
|
||||
},
|
||||
guid: null,
|
||||
},
|
||||
],
|
||||
capacity: 123456789123,
|
||||
guid: 'uuid-uuid-uuid-uuid',
|
||||
},
|
||||
{
|
||||
logicalname: 'dcba',
|
||||
vendor: 'Crucial',
|
||||
model: 'MX500',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: 'pbcba',
|
||||
label: null,
|
||||
capacity: 73264762332,
|
||||
used: null,
|
||||
startOs: {
|
||||
'1234-5678-9876-5432': {
|
||||
hostname: 'adjective-noun',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '0.2.17',
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
},
|
||||
},
|
||||
guid: null,
|
||||
},
|
||||
],
|
||||
capacity: 124456789123,
|
||||
guid: null,
|
||||
},
|
||||
{
|
||||
logicalname: 'wxyz',
|
||||
vendor: 'SanDisk',
|
||||
model: 'Specialness',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: 'pbcba',
|
||||
label: null,
|
||||
capacity: 73264762332,
|
||||
used: null,
|
||||
startOs: {
|
||||
'1234-5678-9876-5432': {
|
||||
hostname: 'adjective-noun',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '0.2.17',
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
},
|
||||
},
|
||||
guid: 'guid-guid-guid-guid',
|
||||
},
|
||||
],
|
||||
capacity: 123459789123,
|
||||
guid: null,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
async install(params: InstallReq): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
|
||||
async reboot(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// This file can be replaced during build by using the `fileReplacements` array.
|
||||
// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
|
||||
// The list of file replacements can be found in `angular.json`.
|
||||
|
||||
export const environment = {
|
||||
production: false,
|
||||
}
|
||||
|
||||
/*
|
||||
* For easier debugging in development mode, you can import the following file
|
||||
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
|
||||
*
|
||||
* This import should be commented out in production mode because it will have a negative impact
|
||||
* on performance if an error is thrown.
|
||||
*/
|
||||
// import 'zone.js/dist/zone-error'; // Included with Angular CLI.
|
||||
@@ -1,30 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>StartOS Install Wizard</title>
|
||||
|
||||
<base href="/" />
|
||||
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="msapplication-tap-highlight" content="no" />
|
||||
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
href="/assets/icons/favicon-96x96.png"
|
||||
sizes="96x96"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/icons/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/assets/icons/favicon.ico" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,12 +0,0 @@
|
||||
import { enableProdMode } from '@angular/core'
|
||||
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'
|
||||
import { AppModule } from './app/app.module'
|
||||
import { environment } from './environments/environment'
|
||||
|
||||
if (environment.production) {
|
||||
enableProdMode()
|
||||
}
|
||||
|
||||
platformBrowserDynamic()
|
||||
.bootstrapModule(AppModule)
|
||||
.catch(err => console.error(err))
|
||||
@@ -1,9 +0,0 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./"
|
||||
},
|
||||
"files": ["src/main.ts"],
|
||||
"include": ["src/**/*.d.ts"]
|
||||
}
|
||||
@@ -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,41 @@ 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':
|
||||
// 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
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -16,9 +16,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'
|
||||
|
||||
|
||||
@@ -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 { 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*</label>
|
||||
<input
|
||||
tuiTextfield
|
||||
formControlName="hostname"
|
||||
@@ -48,17 +47,17 @@ export interface CifsResponse {
|
||||
/>
|
||||
|
||||
<tui-textfield class="input">
|
||||
<label tuiLabel>Path *</label>
|
||||
<label tuiLabel>Path*</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*</label>
|
||||
<input
|
||||
tuiTextfield
|
||||
formControlName="username"
|
||||
@@ -81,11 +80,14 @@ export interface CifsResponse {
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
type="button"
|
||||
[disabled]="connecting"
|
||||
(click)="cancel()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button tuiButton [disabled]="form.invalid">Verify</button>
|
||||
<button tuiButton [disabled]="form.invalid" [loading]="connecting">
|
||||
Connect
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
`,
|
||||
@@ -97,18 +99,19 @@ 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,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
@@ -122,8 +125,9 @@ export interface CifsResponse {
|
||||
export class CifsComponent {
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
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 +153,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 +163,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.',
|
||||
'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,57 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { TuiButton } from '@taiga-ui/core'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
import { injectContext } from '@taiga-ui/polymorpheus'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [TuiButton],
|
||||
template: `
|
||||
<p>This drive contains existing StartOS data.</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong class="g-positive">Preserve</strong>
|
||||
to keep your data.
|
||||
</li>
|
||||
<li>
|
||||
<strong class="g-negative">Overwrite</strong>
|
||||
to discard
|
||||
</li>
|
||||
</ul>
|
||||
<footer>
|
||||
<button
|
||||
tuiButton
|
||||
appearance="flat-destructive"
|
||||
(click)="context.completeWith(false)"
|
||||
>
|
||||
Overwrite
|
||||
</button>
|
||||
<button
|
||||
tuiButton
|
||||
class="preserve-btn"
|
||||
(click)="context.completeWith(true)"
|
||||
>
|
||||
Preserve
|
||||
</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,71 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
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],
|
||||
template: `
|
||||
<p>Multiple backups found. Select which one to restore.</p>
|
||||
<tui-textfield [stringify]="stringify">
|
||||
<label tuiLabel>Backups</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,55 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
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],
|
||||
template: `
|
||||
<p>Enter the password that was used to encrypt this backup.</p>
|
||||
<tui-textfield>
|
||||
<label tuiLabel>Password</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
|
||||
</button>
|
||||
<button tuiButton [disabled]="!password" (click)="unlock()">
|
||||
Unlock
|
||||
</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()
|
||||
}
|
||||
}
|
||||
}
|
||||
355
web/projects/setup-wizard/src/app/pages/drives.page.ts
Normal file
355
web/projects/setup-wizard/src/app/pages/drives.page.ts
Normal file
@@ -0,0 +1,355 @@
|
||||
import { ChangeDetectorRef, Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import {
|
||||
DiskInfo,
|
||||
ErrorService,
|
||||
LoadingService,
|
||||
toGuid,
|
||||
} from '@start9labs/shared'
|
||||
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDialogService,
|
||||
TuiIcon,
|
||||
TuiLoader,
|
||||
TuiTextfield,
|
||||
TuiTitle,
|
||||
} from '@taiga-ui/core'
|
||||
import {
|
||||
TUI_CONFIRM,
|
||||
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'
|
||||
|
||||
const OS_DRIVE_TOOLTIP =
|
||||
'The drive where the StartOS operating system will be installed.'
|
||||
const DATA_DRIVE_TOOLTIP =
|
||||
'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.'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<section tuiCardLarge="compact">
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>Select Drives</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.
|
||||
</p>
|
||||
} @else {
|
||||
<tui-textfield [stringify]="stringify">
|
||||
<label tuiLabel>OS Drive</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</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' }} {{ drive.model || 'Drive' }}
|
||||
</span>
|
||||
<small>
|
||||
{{ formatCapacity(drive.capacity) }} · {{ drive.logicalname }}
|
||||
</small>
|
||||
</div>
|
||||
</ng-template>
|
||||
}
|
||||
|
||||
<footer>
|
||||
@if (drives.length === 0) {
|
||||
<button tuiButton appearance="secondary" (click)="refresh()">
|
||||
Refresh
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
tuiButton
|
||||
[disabled]="!selectedOsDrive || !selectedDataDrive"
|
||||
(click)="continue()"
|
||||
>
|
||||
Continue
|
||||
</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,
|
||||
],
|
||||
})
|
||||
export default class DrivesPage {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly router = inject(Router)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly stateService = inject(StateService)
|
||||
private readonly cdr = inject(ChangeDetectorRef)
|
||||
|
||||
protected readonly mobile = inject(TUI_IS_MOBILE)
|
||||
|
||||
readonly osDriveTooltip = OS_DRIVE_TOOLTIP
|
||||
readonly dataDriveTooltip = DATA_DRIVE_TOOLTIP
|
||||
|
||||
drives: DiskInfo[] = []
|
||||
loading = true
|
||||
selectedOsDrive: DiskInfo | null = null
|
||||
selectedDataDrive: DiskInfo | null = null
|
||||
preserveData: boolean | null = null
|
||||
|
||||
readonly stringify = (drive: DiskInfo | null) =>
|
||||
drive ? `${drive.vendor || 'Unknown'} ${drive.model || '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
|
||||
.open<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
|
||||
.open<boolean>(TUI_CONFIRM, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content: `<ul>
|
||||
<li class="g-negative">Data on the OS drive may be overwritten.</li>
|
||||
<li class="g-positive">your StartOS data on the data drive will be preserved.</li>
|
||||
</ul>`,
|
||||
yes: 'Continue',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => {
|
||||
this.installOs(false)
|
||||
})
|
||||
}
|
||||
|
||||
private showFullWarning(sameDevice: boolean) {
|
||||
const message = sameDevice
|
||||
? `<p class="g-negative">Data on this drive will be overwritten.</p>`
|
||||
: `<p class="g-negative">Data on both drives will be overwritten.</p>`
|
||||
|
||||
this.dialogs
|
||||
.open<boolean>(TUI_CONFIRM, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content: message,
|
||||
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
|
||||
|
||||
if (result.attach) {
|
||||
this.stateService.setupType = 'attach'
|
||||
await this.router.navigate(['/password'])
|
||||
} else {
|
||||
await this.router.navigate(['/home'])
|
||||
}
|
||||
} 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,70 @@
|
||||
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 { 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</h2>
|
||||
</header>
|
||||
|
||||
<button tuiCell="l" (click)="startFresh()">
|
||||
<tui-avatar appearance="positive" src="@tui.plus" />
|
||||
<div tuiTitle>
|
||||
Start Fresh
|
||||
<div tuiSubtitle>Set up a brand new server</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
|
||||
<div tuiSubtitle>Restore StartOS data from an encrypted backup</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
|
||||
<div tuiSubtitle>
|
||||
Transfer data from an existing StartOS data drive
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
imports: [
|
||||
RouterModule,
|
||||
TuiAppearance,
|
||||
TuiCardLarge,
|
||||
TuiButton,
|
||||
TuiHeader,
|
||||
TuiCell,
|
||||
TuiIcon,
|
||||
TuiTitle,
|
||||
RecoverComponent,
|
||||
TuiAvatar,
|
||||
],
|
||||
})
|
||||
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'])
|
||||
}
|
||||
}
|
||||
|
||||
105
web/projects/setup-wizard/src/app/pages/keyboard.page.ts
Normal file
105
web/projects/setup-wizard/src/app/pages/keyboard.page.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
|
||||
import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiChevron, TuiDataListWrapper, TuiSelect } from '@taiga-ui/kit'
|
||||
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
|
||||
import { StateService } from '../services/state.service'
|
||||
import { Keyboard, getKeyboardsForLanguage } from '../utils/languages'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<section tuiCardLarge="compact">
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>Select Keyboard Layout</h2>
|
||||
</header>
|
||||
<tui-textfield
|
||||
tuiChevron
|
||||
[stringify]="stringify"
|
||||
[tuiTextfieldCleaner]="false"
|
||||
>
|
||||
<label tuiLabel>Keyboard</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" (click)="continue()">
|
||||
Continue
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
FormsModule,
|
||||
TuiCardLarge,
|
||||
TuiButton,
|
||||
TuiTextfield,
|
||||
TuiChevron,
|
||||
TuiSelect,
|
||||
TuiDataListWrapper,
|
||||
TuiHeader,
|
||||
TuiTitle,
|
||||
],
|
||||
})
|
||||
export default class KeyboardPage {
|
||||
private readonly router = inject(Router)
|
||||
private readonly stateService = inject(StateService)
|
||||
|
||||
protected readonly mobile = inject(TUI_IS_MOBILE)
|
||||
readonly keyboards = getKeyboardsForLanguage(this.stateService.language)
|
||||
selected =
|
||||
this.keyboards.find(k => k.code === this.stateService.keyboard) ||
|
||||
this.keyboards[0]
|
||||
|
||||
readonly stringify = (kb: Keyboard) => kb.name
|
||||
|
||||
async back() {
|
||||
await this.router.navigate(['/language'])
|
||||
}
|
||||
|
||||
async continue() {
|
||||
if (this.selected) {
|
||||
this.stateService.keyboard = this.selected.code
|
||||
await this.navigateToNextStep()
|
||||
}
|
||||
}
|
||||
|
||||
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'])
|
||||
}
|
||||
}
|
||||
}
|
||||
139
web/projects/setup-wizard/src/app/pages/language.page.ts
Normal file
139
web/projects/setup-wizard/src/app/pages/language.page.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
|
||||
import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiChevron, TuiDataListWrapper, TuiSelect } from '@taiga-ui/kit'
|
||||
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
|
||||
import { StateService } from '../services/state.service'
|
||||
import {
|
||||
LANGUAGES,
|
||||
Language,
|
||||
getDefaultKeyboard,
|
||||
needsKeyboardSelection,
|
||||
} from '../utils/languages'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<section tuiCardLarge="compact">
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>
|
||||
<span class="inline-title">
|
||||
<img src="assets/img/icon.png" alt="Start9" />
|
||||
Welcome to StartOS
|
||||
</span>
|
||||
<span tuiSubtitle>Select your language</span>
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<tui-textfield
|
||||
tuiChevron
|
||||
[stringify]="stringify"
|
||||
[tuiTextfieldCleaner]="false"
|
||||
>
|
||||
<label tuiLabel>Language</label>
|
||||
@if (mobile) {
|
||||
<select tuiSelect [(ngModel)]="selected" [items]="languages"></select>
|
||||
} @else {
|
||||
<input tuiSelect [(ngModel)]="selected" />
|
||||
}
|
||||
@if (!mobile) {
|
||||
<tui-data-list-wrapper
|
||||
*tuiTextfieldDropdown
|
||||
new
|
||||
[items]="languages"
|
||||
[itemContent]="itemContent"
|
||||
/>
|
||||
}
|
||||
</tui-textfield>
|
||||
|
||||
<ng-template #itemContent let-item>
|
||||
<div class="language-item">
|
||||
<span>{{ item.nativeName }}</span>
|
||||
@if (item.name !== item.nativeName) {
|
||||
<small>{{ item.name }}</small>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<footer>
|
||||
<button tuiButton [disabled]="!selected" (click)="continue()">
|
||||
Continue
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
`,
|
||||
styles: `
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.language-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
small {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
FormsModule,
|
||||
TuiCardLarge,
|
||||
TuiButton,
|
||||
TuiTextfield,
|
||||
TuiChevron,
|
||||
TuiSelect,
|
||||
TuiDataListWrapper,
|
||||
TuiHeader,
|
||||
TuiTitle,
|
||||
],
|
||||
})
|
||||
export default class LanguagePage {
|
||||
private readonly router = inject(Router)
|
||||
private readonly stateService = inject(StateService)
|
||||
|
||||
protected readonly mobile = inject(TUI_IS_MOBILE)
|
||||
readonly languages = LANGUAGES
|
||||
selected =
|
||||
LANGUAGES.find(l => l.code === this.stateService.language) || LANGUAGES[0]
|
||||
|
||||
readonly stringify = (lang: Language) => lang.nativeName
|
||||
|
||||
async continue() {
|
||||
if (this.selected) {
|
||||
this.stateService.language = this.selected.code
|
||||
|
||||
if (this.stateService.kiosk) {
|
||||
// Check if we need keyboard selection
|
||||
if (needsKeyboardSelection(this.selected.code)) {
|
||||
await this.router.navigate(['/keyboard'])
|
||||
} else {
|
||||
// Auto-select the only keyboard option
|
||||
this.stateService.keyboard = getDefaultKeyboard(
|
||||
this.selected.code,
|
||||
).code
|
||||
await this.navigateToNextStep()
|
||||
}
|
||||
} else {
|
||||
await this.navigateToNextStep()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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'])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,18 +26,16 @@ 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</h1>
|
||||
<p>{{ err }}</p>
|
||||
<button tuiButton (click)="restart()">
|
||||
{{ 'Restart server' }}
|
||||
</button>
|
||||
<button tuiButton (click)="restart()">Restart server</button>
|
||||
</section>
|
||||
} @else {
|
||||
<app-initializing [initialSetup]="true" [progress]="progress()" />
|
||||
@@ -54,7 +52,6 @@ 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);
|
||||
@@ -67,9 +64,9 @@ 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 +96,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))
|
||||
|
||||
188
web/projects/setup-wizard/src/app/pages/password.page.ts
Normal file
188
web/projects/setup-wizard/src/app/pages/password.page.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
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, i18nKey, 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' : 'Set New Password (Optional)'
|
||||
}}
|
||||
<span tuiSubtitle>
|
||||
{{
|
||||
isRequired
|
||||
? 'Make it good. Write it down.'
|
||||
: 'Skip to keep your existing password.'
|
||||
}}
|
||||
</span>
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
<tui-textfield>
|
||||
<label tuiLabel>
|
||||
{{ isRequired ? 'Enter Password' : 'New Password' }}
|
||||
</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</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
|
||||
</button>
|
||||
@if (!isRequired) {
|
||||
<button
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
type="button"
|
||||
(click)="skip()"
|
||||
>
|
||||
Skip
|
||||
</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,
|
||||
],
|
||||
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)
|
||||
|
||||
// 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: '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...' as i18nKey).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'])
|
||||
})
|
||||
}
|
||||
}
|
||||
236
web/projects/setup-wizard/src/app/pages/restore.page.ts
Normal file
236
web/projects/setup-wizard/src/app/pages/restore.page.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDataList,
|
||||
TuiDialogService,
|
||||
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
|
||||
<span tuiSubtitle>
|
||||
Select the StartOS backup you want to restore
|
||||
<a class="refresh" (click)="refresh()">
|
||||
<tui-icon icon="@tui.rotate-cw" />
|
||||
Refresh
|
||||
</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
|
||||
</button>
|
||||
|
||||
<ng-template #dropdown>
|
||||
<tui-data-list>
|
||||
<tui-opt-group>
|
||||
<button tuiOption new (click)="openCifs()">
|
||||
<tui-icon icon="@tui.folder-plus" />
|
||||
Open Network Backup
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
<tui-opt-group label="Physical Backups">
|
||||
@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</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,
|
||||
],
|
||||
})
|
||||
export default class RestorePage {
|
||||
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
|
||||
open = false
|
||||
physicalServers: StartOSDiskInfoFull[] = []
|
||||
|
||||
async ngOnInit() {
|
||||
await this.loadDrives()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.loading = true
|
||||
await this.loadDrives()
|
||||
}
|
||||
|
||||
openCifs() {
|
||||
this.open = false
|
||||
this.dialogs
|
||||
.open<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
|
||||
.open<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
|
||||
.open<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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,121 +7,170 @@ import {
|
||||
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 { 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!
|
||||
</span>
|
||||
@if (!stateService.kiosk) {
|
||||
<span tuiSubtitle>
|
||||
{{
|
||||
stateService.setupType === 'restore'
|
||||
? 'You can unplug your backup drive'
|
||||
: stateService.setupType === 'transfer'
|
||||
? 'You can unplug your transfer drive'
|
||||
: 'http://start.local was for setup only. It will no longer work.'
|
||||
}}
|
||||
</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
|
||||
<div tuiSubtitle>
|
||||
Contains your server's permanent local address and Root CA
|
||||
</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>
|
||||
Remove USB Media
|
||||
<div tuiSubtitle>
|
||||
Remove the USB installation media from your server
|
||||
</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
|
||||
<div tuiSubtitle>
|
||||
@if (rebooting) {
|
||||
Waiting for server to come back online...
|
||||
} @else if (rebooted) {
|
||||
Server is back online
|
||||
} @else {
|
||||
Restart your server to complete setup
|
||||
}
|
||||
</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
|
||||
<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
|
||||
<div tuiSubtitle>Proceed to the StartOS login screen</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,
|
||||
],
|
||||
})
|
||||
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)
|
||||
@@ -129,32 +178,42 @@ export default class SuccessPage implements AfterViewInit {
|
||||
|
||||
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 +221,56 @@ 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 = `https://${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(
|
||||
'Server did not come back online. Please check your server and try accessing it manually.',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,97 +1,167 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import {
|
||||
DiskInfo,
|
||||
DriveComponent,
|
||||
ErrorService,
|
||||
toGuid,
|
||||
} from '@start9labs/shared'
|
||||
import { DiskInfo, ErrorService, toGuid } from '@start9labs/shared'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDataList,
|
||||
TuiDialogOptions,
|
||||
TuiDialogService,
|
||||
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
|
||||
<span tuiSubtitle>
|
||||
Select the drive containing your existing StartOS data
|
||||
<a class="refresh" (click)="refresh()">
|
||||
<tui-icon icon="@tui.rotate-cw" />
|
||||
Refresh
|
||||
</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
|
||||
</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</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,
|
||||
],
|
||||
})
|
||||
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 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
|
||||
|
||||
const WARNING_OPTIONS: Partial<TuiDialogOptions<TuiConfirmData>> = {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content: `After transferring data from this drive, <strong>do not</strong>
|
||||
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',
|
||||
},
|
||||
}
|
||||
|
||||
this.dialogs
|
||||
.open(TUI_CONFIRM, WARNING_OPTIONS)
|
||||
.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,56 @@
|
||||
import * as jose from 'node-jose'
|
||||
import {
|
||||
DiskInfo,
|
||||
DiskListResponse,
|
||||
FollowLogsRes,
|
||||
PartitionInfo,
|
||||
StartOSDiskInfo,
|
||||
} from '@start9labs/shared'
|
||||
import { DiskInfo, FollowLogsRes, 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
|
||||
|
||||
// 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
|
||||
|
||||
// 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,6 +1,6 @@
|
||||
import { Inject, Injectable, DOCUMENT } from '@angular/core'
|
||||
import {
|
||||
DiskListResponse,
|
||||
DiskInfo,
|
||||
encodeBase64,
|
||||
FollowLogsRes,
|
||||
HttpService,
|
||||
@@ -14,6 +14,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 +45,40 @@ 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 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 +86,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 +126,22 @@ export class LiveApiService extends ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
async exit(): Promise<void> {
|
||||
async exit() {
|
||||
await this.rpcRequest<void>({
|
||||
method: 'setup.exit',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async restart(): Promise<void> {
|
||||
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 +151,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,6 +1,6 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import {
|
||||
DiskListResponse,
|
||||
DiskInfo,
|
||||
encodeBase64,
|
||||
FollowLogsRes,
|
||||
pauseFor,
|
||||
@@ -8,104 +8,24 @@ import {
|
||||
} 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 +37,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 +52,39 @@ 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' }
|
||||
return { status: 'incomplete', attach: false, guid: 'mock-data-guid' }
|
||||
}
|
||||
|
||||
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 +93,18 @@ 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 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 +120,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 +150,109 @@ 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 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 +274,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,72 @@ 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 = '' // only used if kiosk
|
||||
|
||||
// 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,
|
||||
startOsPassword: password ? await this.api.encrypt(password) : null,
|
||||
language: this.language,
|
||||
kiosk: this.kiosk ? { keyboard: this.keyboard } : 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,
|
||||
startOsLogicalname: this.dataDriveGuid,
|
||||
startOsPassword: password ? await this.api.encrypt(password) : null,
|
||||
language: this.language,
|
||||
kiosk: this.kiosk ? { keyboard: this.keyboard } : 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
|
||||
}
|
||||
}
|
||||
|
||||
88
web/projects/setup-wizard/src/app/types.ts
Normal file
88
web/projects/setup-wizard/src/app/types.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { DiskInfo, 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' }
|
||||
| { status: 'incomplete'; guid: string; attach: boolean }
|
||||
| { 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 {
|
||||
startOsPassword: T.EncryptedWire | null
|
||||
guid: string // data drive
|
||||
language: string
|
||||
kiosk: { keyboard: string } | null
|
||||
}
|
||||
|
||||
// === Execute ===
|
||||
|
||||
export interface SetupExecuteParams {
|
||||
startOsLogicalname: string
|
||||
startOsPassword: T.EncryptedWire | null // null = keep existing password (for restore/transfer)
|
||||
language: string
|
||||
kiosk: { keyboard: string } | null
|
||||
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
|
||||
}
|
||||
59
web/projects/setup-wizard/src/app/utils/languages.ts
Normal file
59
web/projects/setup-wizard/src/app/utils/languages.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
export interface Language {
|
||||
code: string
|
||||
name: string
|
||||
nativeName: string
|
||||
}
|
||||
|
||||
export interface Keyboard {
|
||||
code: string
|
||||
name: string
|
||||
}
|
||||
|
||||
export const LANGUAGES: Language[] = [
|
||||
{ code: 'en', name: 'English', nativeName: 'English' },
|
||||
{ code: 'es', name: 'Spanish', nativeName: 'Español' },
|
||||
{ code: 'de', name: 'German', nativeName: 'Deutsch' },
|
||||
{ code: 'fr', name: 'French', nativeName: 'Français' },
|
||||
{ code: 'pl', name: 'Polish', nativeName: 'Polski' },
|
||||
]
|
||||
|
||||
export const KEYBOARDS_BY_LANGUAGE: Record<string, Keyboard[]> = {
|
||||
en: [
|
||||
{ code: 'us', name: 'US English' },
|
||||
{ code: 'gb', name: 'UK English' },
|
||||
],
|
||||
es: [
|
||||
{ code: 'es', name: 'Spanish' },
|
||||
{ code: 'latam', name: 'Latin American' },
|
||||
],
|
||||
de: [{ code: 'de', name: 'German' }],
|
||||
fr: [{ code: 'fr', name: 'French' }],
|
||||
pl: [{ code: 'pl', name: 'Polish' }],
|
||||
}
|
||||
|
||||
/**
|
||||
* Get available keyboards for a language.
|
||||
* Returns array of keyboards (may be 1 or more).
|
||||
*/
|
||||
export function getKeyboardsForLanguage(languageCode: string): Keyboard[] {
|
||||
return (
|
||||
KEYBOARDS_BY_LANGUAGE[languageCode] || [{ code: 'us', name: 'US English' }]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if keyboard selection is needed for a language.
|
||||
* Returns true if there are multiple keyboard options.
|
||||
*/
|
||||
export function needsKeyboardSelection(languageCode: string): boolean {
|
||||
const keyboards = getKeyboardsForLanguage(languageCode)
|
||||
return keyboards.length > 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default keyboard for a language.
|
||||
* Returns the first keyboard option.
|
||||
*/
|
||||
export function getDefaultKeyboard(languageCode: string): Keyboard {
|
||||
return getKeyboardsForLanguage(languageCode)[0]!
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { UnitConversionPipesModule } from '../pipes/unit-conversion/unit-conversion.module'
|
||||
|
||||
@Component({
|
||||
selector: 'button[drive]',
|
||||
template: `
|
||||
<tui-icon icon="@tui.save" />
|
||||
<span tuiTitle>
|
||||
<strong>{{ drive.logicalname }}</strong>
|
||||
<span tuiSubtitle>
|
||||
{{ drive.vendor || 'Unknown Vendor' }} -
|
||||
{{ drive.model || 'Unknown Model' }}
|
||||
</span>
|
||||
<span tuiSubtitle>Capacity: {{ drive.capacity | convertBytes }}</span>
|
||||
<ng-content />
|
||||
</span>
|
||||
`,
|
||||
imports: [TuiIcon, TuiTitle, UnitConversionPipesModule],
|
||||
})
|
||||
export class DriveComponent {
|
||||
@Input() drive!: {
|
||||
logicalname: string | null
|
||||
vendor: string | null
|
||||
model: string | null
|
||||
capacity: number
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ export * from './classes/rpc-error'
|
||||
export * from './components/initializing/logs-window.component'
|
||||
export * from './components/initializing/initializing.component'
|
||||
export * from './components/ticker.component'
|
||||
export * from './components/drive.component'
|
||||
export * from './components/markdown.component'
|
||||
export * from './components/prompt.component'
|
||||
export * from './components/server.component'
|
||||
|
||||
@@ -10,7 +10,7 @@ export type AccessType =
|
||||
export type WorkspaceConfig = {
|
||||
gitHash: string
|
||||
useMocks: boolean
|
||||
// each key corresponds to a project and values adjust settings for that project, eg: ui, install-wizard, setup-wizard
|
||||
// each key corresponds to a project and values adjust settings for that project, eg: ui, setup-wizard
|
||||
ui: {
|
||||
api: {
|
||||
url: string
|
||||
|
||||
@@ -22,7 +22,6 @@ import { from, interval, map, shareReplay, startWith, Subject, tap } from 'rxjs'
|
||||
import { mockPatchData } from './mock-patch'
|
||||
import { AuthService } from '../auth.service'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { MarketplacePkg } from '@start9labs/marketplace'
|
||||
import { WebSocketSubject } from 'rxjs/webSocket'
|
||||
import { toAuthorityUrl } from 'src/app/utils/acme'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user