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 patch-db/client/dist
|
||||||
mkdir -p web/.angular
|
mkdir -p web/.angular
|
||||||
mkdir -p web/dist/raw/ui
|
mkdir -p web/dist/raw/ui
|
||||||
mkdir -p web/dist/raw/install-wizard
|
|
||||||
mkdir -p web/dist/raw/setup-wizard
|
mkdir -p web/dist/raw/setup-wizard
|
||||||
mkdir -p web/dist/static/ui
|
mkdir -p web/dist/static/ui
|
||||||
mkdir -p web/dist/static/install-wizard
|
|
||||||
mkdir -p web/dist/static/setup-wizard
|
mkdir -p web/dist/static/setup-wizard
|
||||||
PLATFORM=${{ matrix.platform }} make -t compiled-${{ env.ARCH }}.tar
|
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.
|
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.
|
- **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.
|
- **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
|
#### Start the standard development server
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm run start:install
|
|
||||||
npm run start:setup
|
npm run start:setup
|
||||||
npm run start:ui
|
npm run start:ui
|
||||||
```
|
```
|
||||||
@@ -122,7 +120,6 @@ Translate the English dictionary below into `<language>`. Format the result as a
|
|||||||
#### Adding to StartOS
|
#### Adding to StartOS
|
||||||
|
|
||||||
- In the `shared` project:
|
- In the `shared` project:
|
||||||
|
|
||||||
1. Create a new file (`language.ts`) in `src/i18n/dictionaries`
|
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)
|
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`
|
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.
|
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:
|
- Here in this README:
|
||||||
|
|
||||||
1. Add the language to the list of supported languages below
|
1. Add the language to the list of supported languages below
|
||||||
|
|
||||||
### Updating the English dictionary
|
### 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": {
|
"setup-wizard": {
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
"schematics": {},
|
"schematics": {},
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ module.exports = {
|
|||||||
'projects/ui/**/*.ts': () => 'npm run check:ui',
|
'projects/ui/**/*.ts': () => 'npm run check:ui',
|
||||||
'projects/shared/**/*.ts': () => 'npm run check:shared',
|
'projects/shared/**/*.ts': () => 'npm run check:shared',
|
||||||
'projects/marketplace/**/*.ts': () => 'npm run check:marketplace',
|
'projects/marketplace/**/*.ts': () => 'npm run check:marketplace',
|
||||||
'projects/install-wizard/**/*.ts': () => 'npm run check:install',
|
|
||||||
'projects/setup-wizard/**/*.ts': () => 'npm run check:setup',
|
'projects/setup-wizard/**/*.ts': () => 'npm run check:setup',
|
||||||
'projects/start-tunnel/**/*.ts': () => 'npm run check:tunnel',
|
'projects/start-tunnel/**/*.ts': () => 'npm run check:tunnel',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,26 +6,23 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"ng": "ng",
|
"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:shared": "tsc --project projects/shared/tsconfig.json --noEmit --skipLibCheck",
|
||||||
"check:marketplace": "tsc --project projects/marketplace/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:setup": "tsc --project projects/setup-wizard/tsconfig.json --noEmit --skipLibCheck",
|
||||||
"check:ui": "tsc --project projects/ui/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",
|
"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: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:setup": "ng run setup-wizard:build",
|
||||||
"build:ui": "ng run ui:build",
|
"build:ui": "ng run ui:build",
|
||||||
"build:ui:dev": "ng run ui:build:development",
|
"build:ui:dev": "ng run ui:build:development",
|
||||||
"build:tunnel": "ng run start-tunnel:build",
|
"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:shared": "ng build shared",
|
||||||
"build:marketplace": "npm run build:shared && ng build marketplace",
|
"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",
|
"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:shared": "npm run build:shared && npm publish ./dist/shared --access public",
|
||||||
"publish:marketplace": "npm run build:marketplace && npm publish ./dist/marketplace --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: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: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",
|
"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 { Component, inject, DOCUMENT } from '@angular/core'
|
||||||
import { Router } from '@angular/router'
|
import { Router } from '@angular/router'
|
||||||
import { ErrorService } from '@start9labs/shared'
|
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'
|
import { StateService } from './services/state.service'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -18,19 +18,41 @@ export class AppComponent {
|
|||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
try {
|
try {
|
||||||
|
// Determine if we're in kiosk mode
|
||||||
this.stateService.kiosk = ['localhost', '127.0.0.1'].includes(
|
this.stateService.kiosk = ['localhost', '127.0.0.1'].includes(
|
||||||
this.document.location.hostname,
|
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) {
|
switch (status.status) {
|
||||||
route = inProgress.status === 'complete' ? '/success' : '/loading'
|
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) {
|
} catch (e: any) {
|
||||||
this.errorService.handleError(e)
|
this.errorService.handleError(e)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ import {
|
|||||||
} from '@start9labs/shared'
|
} from '@start9labs/shared'
|
||||||
import { tuiButtonOptionsProvider, TuiRoot } from '@taiga-ui/core'
|
import { tuiButtonOptionsProvider, TuiRoot } from '@taiga-ui/core'
|
||||||
import { NG_EVENT_PLUGINS } from '@taiga-ui/event-plugins'
|
import { NG_EVENT_PLUGINS } from '@taiga-ui/event-plugins'
|
||||||
import { ApiService } from 'src/app/services/api.service'
|
import { ApiService } from './services/api.service'
|
||||||
import { LiveApiService } from 'src/app/services/live-api.service'
|
import { LiveApiService } from './services/live-api.service'
|
||||||
import { MockApiService } from 'src/app/services/mock-api.service'
|
import { MockApiService } from './services/mock-api.service'
|
||||||
import { AppComponent } from './app.component'
|
import { AppComponent } from './app.component'
|
||||||
import { ROUTES } from './app.routes'
|
import { ROUTES } from './app.routes'
|
||||||
|
|
||||||
|
|||||||
@@ -1,33 +1,48 @@
|
|||||||
import { Routes } from '@angular/router'
|
import { Routes } from '@angular/router'
|
||||||
|
|
||||||
export const ROUTES: Routes = [
|
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',
|
path: 'home',
|
||||||
loadComponent: () => import('src/app/pages/home.page'),
|
loadComponent: () => import('./pages/home.page'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'attach',
|
path: 'restore',
|
||||||
loadComponent: () => import('src/app/pages/attach.page'),
|
loadComponent: () => import('./pages/restore.page'),
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'recover',
|
|
||||||
loadComponent: () => import('src/app/pages/recover.page'),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'transfer',
|
path: 'transfer',
|
||||||
loadComponent: () => import('src/app/pages/transfer.page'),
|
loadComponent: () => import('./pages/transfer.page'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'storage',
|
path: 'password',
|
||||||
loadComponent: () => import('src/app/pages/storage.page'),
|
loadComponent: () => import('./pages/password.page'),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Shared
|
||||||
{
|
{
|
||||||
path: 'loading',
|
path: 'loading',
|
||||||
loadComponent: () => import('src/app/pages/loading.page'),
|
loadComponent: () => import('./pages/loading.page'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'success',
|
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 {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
FormsModule,
|
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
Validators,
|
Validators,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { i18nKey, LoadingService, StartOSDiskInfo } from '@start9labs/shared'
|
|
||||||
import { T } from '@start9labs/start-sdk'
|
import { T } from '@start9labs/start-sdk'
|
||||||
import {
|
import {
|
||||||
TuiButton,
|
TuiButton,
|
||||||
TuiDialogContext,
|
TuiDialogContext,
|
||||||
TuiDialogService,
|
TuiDialogService,
|
||||||
TuiError,
|
TuiError,
|
||||||
|
TuiIcon,
|
||||||
TuiTextfield,
|
TuiTextfield,
|
||||||
} from '@taiga-ui/core'
|
} from '@taiga-ui/core'
|
||||||
import {
|
import {
|
||||||
TUI_VALIDATION_ERRORS,
|
TUI_VALIDATION_ERRORS,
|
||||||
|
TuiButtonLoading,
|
||||||
TuiFieldErrorPipe,
|
TuiFieldErrorPipe,
|
||||||
TuiPassword,
|
TuiPassword,
|
||||||
} from '@taiga-ui/kit'
|
} from '@taiga-ui/kit'
|
||||||
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||||
import { SERVERS, ServersResponse } from 'src/app/components/servers.component'
|
import { ApiService } from '../services/api.service'
|
||||||
import { ApiService } from 'src/app/services/api.service'
|
import { StartOSDiskInfoWithId } from '../types'
|
||||||
|
|
||||||
export interface CifsResponse {
|
export interface CifsResult {
|
||||||
cifs: T.Cifs
|
cifs: T.Cifs
|
||||||
serverId: string
|
servers: StartOSDiskInfoWithId[]
|
||||||
password: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
template: `
|
template: `
|
||||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||||
<tui-textfield>
|
<tui-textfield>
|
||||||
<label tuiLabel>Hostname *</label>
|
<label tuiLabel>Hostname*</label>
|
||||||
<input
|
<input
|
||||||
tuiTextfield
|
tuiTextfield
|
||||||
formControlName="hostname"
|
formControlName="hostname"
|
||||||
@@ -48,17 +47,17 @@ export interface CifsResponse {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<tui-textfield class="input">
|
<tui-textfield class="input">
|
||||||
<label tuiLabel>Path *</label>
|
<label tuiLabel>Path*</label>
|
||||||
<input
|
<input
|
||||||
tuiTextfield
|
tuiTextfield
|
||||||
formControlName="path"
|
formControlName="path"
|
||||||
placeholder="/Desktop/my-folder'"
|
placeholder="/Desktop/my-folder"
|
||||||
/>
|
/>
|
||||||
</tui-textfield>
|
</tui-textfield>
|
||||||
<tui-error formControlName="path" [error]="[] | tuiFieldError | async" />
|
<tui-error formControlName="path" [error]="[] | tuiFieldError | async" />
|
||||||
|
|
||||||
<tui-textfield class="input">
|
<tui-textfield class="input">
|
||||||
<label tuiLabel>Username *</label>
|
<label tuiLabel>Username*</label>
|
||||||
<input
|
<input
|
||||||
tuiTextfield
|
tuiTextfield
|
||||||
formControlName="username"
|
formControlName="username"
|
||||||
@@ -81,11 +80,14 @@ export interface CifsResponse {
|
|||||||
tuiButton
|
tuiButton
|
||||||
appearance="secondary"
|
appearance="secondary"
|
||||||
type="button"
|
type="button"
|
||||||
|
[disabled]="connecting"
|
||||||
(click)="cancel()"
|
(click)="cancel()"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button tuiButton [disabled]="form.invalid">Verify</button>
|
<button tuiButton [disabled]="form.invalid" [loading]="connecting">
|
||||||
|
Connect
|
||||||
|
</button>
|
||||||
</footer>
|
</footer>
|
||||||
</form>
|
</form>
|
||||||
`,
|
`,
|
||||||
@@ -97,18 +99,19 @@ export interface CifsResponse {
|
|||||||
footer {
|
footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin-top: 1rem;
|
margin-top: 1.5rem;
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
imports: [
|
imports: [
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
TuiButton,
|
TuiButton,
|
||||||
|
TuiButtonLoading,
|
||||||
TuiTextfield,
|
TuiTextfield,
|
||||||
TuiPassword,
|
TuiPassword,
|
||||||
TuiError,
|
TuiError,
|
||||||
TuiFieldErrorPipe,
|
TuiFieldErrorPipe,
|
||||||
|
TuiIcon,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
@@ -122,8 +125,9 @@ export interface CifsResponse {
|
|||||||
export class CifsComponent {
|
export class CifsComponent {
|
||||||
private readonly dialogs = inject(TuiDialogService)
|
private readonly dialogs = inject(TuiDialogService)
|
||||||
private readonly api = inject(ApiService)
|
private readonly api = inject(ApiService)
|
||||||
private readonly loader = inject(LoadingService)
|
private readonly context = injectContext<TuiDialogContext<CifsResult>>()
|
||||||
private readonly context = injectContext<TuiDialogContext<CifsResponse>>()
|
|
||||||
|
connecting = false
|
||||||
|
|
||||||
readonly form = new FormGroup({
|
readonly form = new FormGroup({
|
||||||
hostname: new FormControl('', {
|
hostname: new FormControl('', {
|
||||||
@@ -149,9 +153,7 @@ export class CifsComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async submit(): Promise<void> {
|
async submit(): Promise<void> {
|
||||||
const loader = this.loader
|
this.connecting = true
|
||||||
.open('Connecting to shared folder' as i18nKey)
|
|
||||||
.subscribe()
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const diskInfo = await this.api.verifyCifs({
|
const diskInfo = await this.api.verifyCifs({
|
||||||
@@ -161,36 +163,25 @@ export class CifsComponent {
|
|||||||
: null,
|
: 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) {
|
} catch (e) {
|
||||||
loader.unsubscribe()
|
this.connecting = false
|
||||||
this.onFail()
|
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() {
|
private onFail() {
|
||||||
this.dialogs
|
this.dialogs
|
||||||
.open(
|
.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',
|
label: 'Connection Failed',
|
||||||
size: 's',
|
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 { Component, inject } from '@angular/core'
|
||||||
import { RouterModule } from '@angular/router'
|
import { Router } from '@angular/router'
|
||||||
import { ErrorService } from '@start9labs/shared'
|
import { TuiAppearance, TuiTitle } from '@taiga-ui/core'
|
||||||
import { TuiButton, TuiIcon, TuiTitle } from '@taiga-ui/core'
|
import { TuiAvatar } from '@taiga-ui/kit'
|
||||||
import { TuiCardLarge, TuiCell } from '@taiga-ui/layout'
|
import { TuiCardLarge, TuiCell, TuiHeader } from '@taiga-ui/layout'
|
||||||
import { RecoverComponent } from 'src/app/components/recover.component'
|
import { StateService } from '../services/state.service'
|
||||||
import { ApiService } from 'src/app/services/api.service'
|
|
||||||
import { StateService } from 'src/app/services/state.service'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
template: `
|
template: `
|
||||||
<img class="logo" src="assets/img/icon.png" alt="Start9" />
|
<div tuiCardLarge="compact">
|
||||||
@if (!loading) {
|
<header tuiHeader>
|
||||||
<section tuiCardLarge="compact">
|
<h2 tuiTitle>Select Setup Flow</h2>
|
||||||
<header [style.padding-top.rem]="1.25">
|
</header>
|
||||||
@if (recover) {
|
|
||||||
<button
|
<button tuiCell="l" (click)="startFresh()">
|
||||||
tuiIconButton
|
<tui-avatar appearance="positive" src="@tui.plus" />
|
||||||
appearance="flat-grayscale"
|
<div tuiTitle>
|
||||||
class="back"
|
Start Fresh
|
||||||
iconStart="@tui.chevron-left"
|
<div tuiSubtitle>Set up a brand new server</div>
|
||||||
(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>
|
</div>
|
||||||
</section>
|
</button>
|
||||||
}
|
|
||||||
`,
|
|
||||||
styles: `
|
|
||||||
@use '@taiga-ui/core/styles/taiga-ui-local' as taiga;
|
|
||||||
|
|
||||||
.logo {
|
<button tuiCell="l" (click)="restore()">
|
||||||
width: 6rem;
|
<tui-avatar appearance="warning" src="@tui.archive-restore" />
|
||||||
margin: auto auto -2rem;
|
<div tuiTitle>
|
||||||
z-index: 1;
|
Restore from Backup
|
||||||
|
<div tuiSubtitle>Restore StartOS data from an encrypted backup</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
&:only-child {
|
<button tuiCell="l" (click)="transfer()">
|
||||||
margin: auto;
|
<tui-avatar appearance="info" src="@tui.hard-drive-download" />
|
||||||
}
|
<div tuiTitle>
|
||||||
|
Transfer
|
||||||
+ * {
|
<div tuiSubtitle>
|
||||||
margin-top: 0;
|
Transfer data from an existing StartOS data drive
|
||||||
}
|
</div>
|
||||||
}
|
</div>
|
||||||
|
</button>
|
||||||
.back {
|
</div>
|
||||||
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%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
`,
|
`,
|
||||||
imports: [
|
imports: [
|
||||||
RouterModule,
|
TuiAppearance,
|
||||||
TuiCardLarge,
|
TuiCardLarge,
|
||||||
TuiButton,
|
TuiHeader,
|
||||||
TuiCell,
|
TuiCell,
|
||||||
TuiIcon,
|
|
||||||
TuiTitle,
|
TuiTitle,
|
||||||
RecoverComponent,
|
TuiAvatar,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export default class HomePage implements OnInit {
|
export default class HomePage {
|
||||||
private readonly api = inject(ApiService)
|
private readonly router = inject(Router)
|
||||||
private readonly errorService = inject(ErrorService)
|
|
||||||
private readonly stateService = inject(StateService)
|
private readonly stateService = inject(StateService)
|
||||||
|
|
||||||
error = false
|
async startFresh() {
|
||||||
loading = true
|
|
||||||
recover = false
|
|
||||||
|
|
||||||
async ngOnInit() {
|
|
||||||
this.stateService.setupType = 'fresh'
|
this.stateService.setupType = 'fresh'
|
||||||
|
this.stateService.recoverySource = undefined
|
||||||
|
await this.router.navigate(['/password'])
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
async restore() {
|
||||||
await this.api.getPubKey()
|
this.stateService.setupType = 'restore'
|
||||||
} catch (e: any) {
|
await this.router.navigate(['/restore'])
|
||||||
this.error = true
|
}
|
||||||
this.errorService.handleError(e)
|
|
||||||
} finally {
|
async transfer() {
|
||||||
this.loading = false
|
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,
|
tap,
|
||||||
timer,
|
timer,
|
||||||
} from 'rxjs'
|
} from 'rxjs'
|
||||||
import { ApiService } from 'src/app/services/api.service'
|
import { ApiService } from '../services/api.service'
|
||||||
import { StateService } from 'src/app/services/state.service'
|
import { StateService } from '../services/state.service'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
template: `
|
template: `
|
||||||
@if (error(); as err) {
|
@if (error(); as err) {
|
||||||
<section>
|
<section>
|
||||||
<h1>{{ 'Error initializing server' }}</h1>
|
<h1>Error initializing server</h1>
|
||||||
<p>{{ err }}</p>
|
<p>{{ err }}</p>
|
||||||
<button tuiButton (click)="restart()">
|
<button tuiButton (click)="restart()">Restart server</button>
|
||||||
{{ 'Restart server' }}
|
|
||||||
</button>
|
|
||||||
</section>
|
</section>
|
||||||
} @else {
|
} @else {
|
||||||
<app-initializing [initialSetup]="true" [progress]="progress()" />
|
<app-initializing [initialSetup]="true" [progress]="progress()" />
|
||||||
@@ -54,7 +52,6 @@ import { StateService } from 'src/app/services/state.service'
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
margin: 1.5rem;
|
margin: 1.5rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
// @TODO Theme
|
|
||||||
background: #e0e0e0;
|
background: #e0e0e0;
|
||||||
color: #333;
|
color: #333;
|
||||||
--tui-background-neutral-1: rgba(0, 0, 0, 0.1);
|
--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 api = inject(ApiService)
|
||||||
private readonly loader = inject(LoadingService)
|
private readonly loader = inject(LoadingService)
|
||||||
private readonly dialog = inject(DialogService)
|
private readonly dialog = inject(DialogService)
|
||||||
|
private readonly router = inject(Router)
|
||||||
|
|
||||||
readonly type = inject(StateService).setupType
|
readonly type = inject(StateService).setupType
|
||||||
readonly router = inject(Router)
|
|
||||||
readonly progress = toSignal(
|
readonly progress = toSignal(
|
||||||
from(this.getStatus()).pipe(
|
from(this.getStatus()).pipe(
|
||||||
filter(Boolean),
|
filter(Boolean),
|
||||||
@@ -99,12 +96,13 @@ export default class LoadingPage {
|
|||||||
try {
|
try {
|
||||||
const res = await this.api.getStatus()
|
const res = await this.api.getStatus()
|
||||||
|
|
||||||
if (!res) {
|
if (res.status === 'running') {
|
||||||
this.router.navigate(['home'])
|
|
||||||
} else if (res.status === 'complete') {
|
|
||||||
this.router.navigate(['success'])
|
|
||||||
} else {
|
|
||||||
return res
|
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) {
|
} catch (e: any) {
|
||||||
this.error.set(getErrorMessage(e))
|
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,
|
DOCUMENT,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { DownloadHTMLService, ErrorService } from '@start9labs/shared'
|
import { DownloadHTMLService, ErrorService } from '@start9labs/shared'
|
||||||
import { TuiButton, TuiIcon, TuiLoader, TuiSurface } from '@taiga-ui/core'
|
import { TuiIcon, TuiLoader, TuiTitle } from '@taiga-ui/core'
|
||||||
import { TuiCardLarge } from '@taiga-ui/layout'
|
import { TuiAvatar } from '@taiga-ui/kit'
|
||||||
import { DocumentationComponent } from 'src/app/components/documentation.component'
|
import { TuiCardLarge, TuiCell, TuiHeader } from '@taiga-ui/layout'
|
||||||
import { MatrixComponent } from 'src/app/components/matrix.component'
|
import { ApiService } from '../services/api.service'
|
||||||
import { ApiService } from 'src/app/services/api.service'
|
import { StateService } from '../services/state.service'
|
||||||
import { StateService } from 'src/app/services/state.service'
|
import { DocumentationComponent } from '../components/documentation.component'
|
||||||
|
import { MatrixComponent } from '../components/matrix.component'
|
||||||
|
import { SetupCompleteRes } from '../types'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
template: `
|
template: `
|
||||||
<canvas matrix></canvas>
|
<canvas matrix></canvas>
|
||||||
<section tuiCardLarge>
|
<section tuiCardLarge>
|
||||||
<h1 class="heading">
|
<header tuiHeader>
|
||||||
<tui-icon icon="@tui.circle-check-big" class="g-positive" />
|
<h2 tuiTitle>
|
||||||
Setup Complete!
|
<span class="inline-title">
|
||||||
</h1>
|
<tui-icon icon="@tui.circle-check-big" class="g-positive" />
|
||||||
@if (stateService.kiosk) {
|
Setup Complete!
|
||||||
<button tuiButton (click)="exitKiosk()">Continue to Login</button>
|
</span>
|
||||||
} @else if (lanAddress) {
|
@if (!stateService.kiosk) {
|
||||||
@if (stateService.setupType === 'restore') {
|
<span tuiSubtitle>
|
||||||
<h3>You can now safely unplug your backup drive</h3>
|
{{
|
||||||
} @else if (stateService.setupType === 'transfer') {
|
stateService.setupType === 'restore'
|
||||||
<h3>You can now safely unplug your old StartOS data drive</h3>
|
? '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>
|
<!-- Step: Remove USB Media (when restart needed) -->
|
||||||
http://start.local was for setup purposes only. It will no longer
|
@if (result.needsRestart) {
|
||||||
work.
|
<button
|
||||||
</h3>
|
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()">
|
<!-- Step: Restart Server -->
|
||||||
<strong class="caps">Download address info</strong>
|
<button
|
||||||
<span>
|
tuiCell="l"
|
||||||
For future reference, this file contains your server's permanent
|
[class.disabled]="!usbRemoved"
|
||||||
local address, as well as its Root Certificate Authority (Root CA).
|
[disabled]="!usbRemoved || rebooted || rebooting"
|
||||||
</span>
|
(click)="reboot()"
|
||||||
<strong class="caps">
|
>
|
||||||
Download
|
<tui-avatar appearance="secondary" src="@tui.rotate-cw" />
|
||||||
<tui-icon icon="@tui.download" />
|
<div tuiTitle>
|
||||||
</strong>
|
Restart Server
|
||||||
</button>
|
<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
|
<!-- Step: Open Local Address (non-kiosk only) -->
|
||||||
tuiCardLarge
|
@if (!stateService.kiosk) {
|
||||||
tuiSurface="floating"
|
<button
|
||||||
target="_blank"
|
tuiCell="l"
|
||||||
[attr.href]="disableLogin ? null : lanAddress"
|
[class.disabled]="!canOpenAddress"
|
||||||
>
|
[disabled]="!canOpenAddress"
|
||||||
<span>
|
(click)="openLocalAddress()"
|
||||||
In the new tab, follow instructions to trust your server's Root CA
|
>
|
||||||
and log in.
|
<tui-avatar appearance="secondary" src="@tui.external-link" />
|
||||||
</span>
|
<div tuiTitle>
|
||||||
<strong class="caps">
|
Open Local Address
|
||||||
Open Local Address
|
<div tuiSubtitle>{{ lanAddress }}</div>
|
||||||
<tui-icon icon="@tui.external-link" />
|
</div>
|
||||||
</strong>
|
</button>
|
||||||
</a>
|
|
||||||
<app-documentation hidden [lanAddress]="lanAddress" />
|
<app-documentation hidden [lanAddress]="lanAddress" />
|
||||||
} @else {
|
}
|
||||||
<tui-loader />
|
|
||||||
|
<!-- 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>
|
</section>
|
||||||
`,
|
`,
|
||||||
styles: `
|
styles: `
|
||||||
.heading {
|
.inline-title {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
gap: 1rem;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin: 0;
|
|
||||||
font: var(--tui-font-heading-4);
|
|
||||||
}
|
|
||||||
|
|
||||||
.caps {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
text-transform: uppercase;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[tuiCardLarge] {
|
[tuiCell].disabled {
|
||||||
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]) {
|
|
||||||
opacity: var(--tui-disabled-opacity);
|
opacity: var(--tui-disabled-opacity);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
h3 {
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
`,
|
`,
|
||||||
imports: [
|
imports: [
|
||||||
TuiCardLarge,
|
TuiCardLarge,
|
||||||
|
TuiCell,
|
||||||
TuiIcon,
|
TuiIcon,
|
||||||
TuiButton,
|
TuiLoader,
|
||||||
TuiSurface,
|
TuiAvatar,
|
||||||
MatrixComponent,
|
MatrixComponent,
|
||||||
DocumentationComponent,
|
DocumentationComponent,
|
||||||
TuiLoader,
|
TuiHeader,
|
||||||
|
TuiTitle,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export default class SuccessPage implements AfterViewInit {
|
export default class SuccessPage implements AfterViewInit {
|
||||||
@ViewChild(DocumentationComponent, { read: ElementRef })
|
@ViewChild(DocumentationComponent, { read: ElementRef })
|
||||||
private readonly documentation?: ElementRef<HTMLElement>
|
private readonly documentation?: ElementRef<HTMLElement>
|
||||||
|
|
||||||
private readonly document = inject(DOCUMENT)
|
private readonly document = inject(DOCUMENT)
|
||||||
private readonly errorService = inject(ErrorService)
|
private readonly errorService = inject(ErrorService)
|
||||||
private readonly api = inject(ApiService)
|
private readonly api = inject(ApiService)
|
||||||
@@ -129,32 +178,42 @@ export default class SuccessPage implements AfterViewInit {
|
|||||||
|
|
||||||
readonly stateService = inject(StateService)
|
readonly stateService = inject(StateService)
|
||||||
|
|
||||||
lanAddress?: string
|
result?: SetupCompleteRes
|
||||||
cert?: string
|
lanAddress = ''
|
||||||
disableLogin = this.stateService.setupType === 'fresh'
|
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() {
|
ngAfterViewInit() {
|
||||||
setTimeout(() => this.complete(), 1000)
|
setTimeout(() => this.complete(), 500)
|
||||||
}
|
}
|
||||||
|
|
||||||
download() {
|
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
|
this.document
|
||||||
.getElementById('cert')
|
.getElementById('cert')
|
||||||
?.setAttribute(
|
?.setAttribute(
|
||||||
'href',
|
'href',
|
||||||
URL.createObjectURL(
|
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 || ''
|
const html = this.documentation?.nativeElement.innerHTML || ''
|
||||||
|
|
||||||
this.downloadHtml.download('StartOS-info.html', html).then(_ => {
|
this.downloadHtml.download('StartOS-info.html', html).then(() => {
|
||||||
this.disableLogin = false
|
this.downloaded = true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,17 +221,56 @@ export default class SuccessPage implements AfterViewInit {
|
|||||||
this.api.exit()
|
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() {
|
private async complete() {
|
||||||
try {
|
try {
|
||||||
const ret = await this.api.complete()
|
this.result = await this.api.complete()
|
||||||
if (!this.stateService.kiosk) {
|
|
||||||
this.lanAddress = ret.lanAddress.replace(/^https:/, 'http:')
|
|
||||||
this.cert = ret.rootCa
|
|
||||||
|
|
||||||
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) {
|
} catch (e: any) {
|
||||||
this.errorService.handleError(e)
|
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 { Component, inject } from '@angular/core'
|
||||||
import { Router } from '@angular/router'
|
import { Router } from '@angular/router'
|
||||||
import {
|
import { DiskInfo, ErrorService, toGuid } from '@start9labs/shared'
|
||||||
DiskInfo,
|
|
||||||
DriveComponent,
|
|
||||||
ErrorService,
|
|
||||||
toGuid,
|
|
||||||
} from '@start9labs/shared'
|
|
||||||
import {
|
import {
|
||||||
TuiButton,
|
TuiButton,
|
||||||
|
TuiDataList,
|
||||||
TuiDialogOptions,
|
TuiDialogOptions,
|
||||||
TuiDialogService,
|
TuiDialogService,
|
||||||
|
TuiDropdown,
|
||||||
|
TuiIcon,
|
||||||
TuiLoader,
|
TuiLoader,
|
||||||
|
TuiTitle,
|
||||||
} from '@taiga-ui/core'
|
} from '@taiga-ui/core'
|
||||||
import { TUI_CONFIRM, TuiConfirmData } from '@taiga-ui/kit'
|
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 { filter } from 'rxjs'
|
||||||
import { ApiService } from 'src/app/services/api.service'
|
import { ApiService } from '../services/api.service'
|
||||||
import { StateService } from 'src/app/services/state.service'
|
import { StateService } from '../services/state.service'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
template: `
|
template: `
|
||||||
<section tuiCardLarge="compact">
|
<section tuiCardLarge="compact">
|
||||||
<header>Transfer</header>
|
<header tuiHeader>
|
||||||
Select the physical drive containing your StartOS data
|
<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) {
|
@if (loading) {
|
||||||
<tui-loader />
|
<tui-loader />
|
||||||
}
|
} @else {
|
||||||
@for (drive of drives; track drive) {
|
<button
|
||||||
<button tuiCell [drive]="drive" (click)="select(drive)"></button>
|
tuiButton
|
||||||
}
|
iconEnd="@tui.chevron-down"
|
||||||
<footer>
|
[tuiDropdown]="dropdown"
|
||||||
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
|
[tuiDropdownLimitWidth]="'fixed'"
|
||||||
Refresh
|
[(tuiDropdownOpen)]="open"
|
||||||
|
style="width: 100%"
|
||||||
|
>
|
||||||
|
Select Drive
|
||||||
</button>
|
</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>
|
</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 {
|
export default class TransferPage {
|
||||||
private readonly apiService = inject(ApiService)
|
private readonly api = inject(ApiService)
|
||||||
private readonly router = inject(Router)
|
private readonly router = inject(Router)
|
||||||
private readonly dialogs = inject(TuiDialogService)
|
private readonly dialogs = inject(TuiDialogService)
|
||||||
private readonly errorService = inject(ErrorService)
|
private readonly errorService = inject(ErrorService)
|
||||||
private readonly stateService = inject(StateService)
|
private readonly stateService = inject(StateService)
|
||||||
|
|
||||||
loading = true
|
loading = true
|
||||||
|
open = false
|
||||||
drives: DiskInfo[] = []
|
drives: DiskInfo[] = []
|
||||||
|
|
||||||
async ngOnInit() {
|
async ngOnInit() {
|
||||||
this.stateService.setupType = 'transfer'
|
await this.loadDrives()
|
||||||
await this.getDrives()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async refresh() {
|
async refresh() {
|
||||||
await this.getDrives()
|
this.loading = true
|
||||||
|
await this.loadDrives()
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDrives() {
|
select(drive: DiskInfo) {
|
||||||
this.loading = true
|
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 {
|
try {
|
||||||
this.drives = await this.apiService
|
const allDrives = await this.api.getDisks()
|
||||||
.getDrives()
|
// Filter to only drives with StartOS data (guid)
|
||||||
.then(drives => drives.filter(toGuid))
|
this.drives = allDrives.filter(toGuid)
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
this.errorService.handleError(e)
|
this.errorService.handleError(e)
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false
|
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 * as jose from 'node-jose'
|
||||||
import {
|
import { DiskInfo, FollowLogsRes, StartOSDiskInfo } from '@start9labs/shared'
|
||||||
DiskInfo,
|
|
||||||
DiskListResponse,
|
|
||||||
FollowLogsRes,
|
|
||||||
PartitionInfo,
|
|
||||||
StartOSDiskInfo,
|
|
||||||
} from '@start9labs/shared'
|
|
||||||
import { T } from '@start9labs/start-sdk'
|
import { T } from '@start9labs/start-sdk'
|
||||||
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
|
|
||||||
import { Observable } from 'rxjs'
|
import { Observable } from 'rxjs'
|
||||||
|
import {
|
||||||
|
SetupStatusRes,
|
||||||
|
InstallOsParams,
|
||||||
|
InstallOsRes,
|
||||||
|
AttachParams,
|
||||||
|
SetupExecuteParams,
|
||||||
|
SetupCompleteRes,
|
||||||
|
EchoReq,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
export abstract class ApiService {
|
export abstract class ApiService {
|
||||||
pubkey?: jose.JWK.Key
|
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 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(
|
abstract verifyCifs(
|
||||||
cifs: T.VerifyCifsParams,
|
cifs: T.VerifyCifsParams,
|
||||||
): Promise<Record<string, StartOSDiskInfo>> // setup.cifs.verify
|
): 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
|
// Completion
|
||||||
abstract complete(): Promise<T.SetupResult> // setup.complete
|
abstract complete(): Promise<SetupCompleteRes> // setup.complete
|
||||||
abstract exit(): Promise<void> // setup.exit
|
abstract exit(): Promise<void> // setup.exit
|
||||||
|
|
||||||
|
// Logs & Progress
|
||||||
abstract initFollowLogs(): Promise<FollowLogsRes> // setup.logs.follow
|
abstract initFollowLogs(): Promise<FollowLogsRes> // setup.logs.follow
|
||||||
abstract restart(): Promise<void> // setup.restart
|
|
||||||
abstract openWebsocket$<T>(guid: string): Observable<T>
|
abstract openWebsocket$<T>(guid: string): Observable<T>
|
||||||
|
|
||||||
|
// Restart (for error recovery)
|
||||||
|
abstract restart(): Promise<void> // setup.restart
|
||||||
|
|
||||||
async encrypt(toEncrypt: string): Promise<T.EncryptedWire> {
|
async encrypt(toEncrypt: string): Promise<T.EncryptedWire> {
|
||||||
if (!this.pubkey) throw new Error('No pubkey found!')
|
if (!this.pubkey) throw new Error('No pubkey found!')
|
||||||
const encrypted = await jose.JWE.createEncrypt(this.pubkey!)
|
const encrypted = await jose.JWE.createEncrypt(this.pubkey!)
|
||||||
.update(toEncrypt)
|
.update(toEncrypt)
|
||||||
.final()
|
.final()
|
||||||
return {
|
return { encrypted }
|
||||||
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 { Inject, Injectable, DOCUMENT } from '@angular/core'
|
||||||
import {
|
import {
|
||||||
DiskListResponse,
|
DiskInfo,
|
||||||
encodeBase64,
|
encodeBase64,
|
||||||
FollowLogsRes,
|
FollowLogsRes,
|
||||||
HttpService,
|
HttpService,
|
||||||
@@ -14,6 +14,15 @@ import * as jose from 'node-jose'
|
|||||||
import { Observable } from 'rxjs'
|
import { Observable } from 'rxjs'
|
||||||
import { webSocket } from 'rxjs/webSocket'
|
import { webSocket } from 'rxjs/webSocket'
|
||||||
import { ApiService } from './api.service'
|
import { ApiService } from './api.service'
|
||||||
|
import {
|
||||||
|
SetupStatusRes,
|
||||||
|
InstallOsParams,
|
||||||
|
InstallOsRes,
|
||||||
|
AttachParams,
|
||||||
|
SetupExecuteParams,
|
||||||
|
SetupCompleteRes,
|
||||||
|
EchoReq,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
@@ -36,39 +45,40 @@ export class LiveApiService extends ApiService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async getStatus(): Promise<T.SetupStatusRes | null> {
|
async echo(params: EchoReq, url: string): Promise<string> {
|
||||||
return this.rpcRequest<T.SetupStatusRes | null>({
|
return this.rpcRequest({ method: 'echo', params }, url)
|
||||||
|
}
|
||||||
|
|
||||||
|
async getStatus() {
|
||||||
|
return this.rpcRequest<SetupStatusRes>({
|
||||||
method: 'setup.status',
|
method: 'setup.status',
|
||||||
params: {},
|
params: {},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
async getPubKey() {
|
||||||
* 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> {
|
|
||||||
const response: jose.JWK.Key = await this.rpcRequest({
|
const response: jose.JWK.Key = await this.rpcRequest({
|
||||||
method: 'setup.get-pubkey',
|
method: 'setup.get-pubkey',
|
||||||
params: {},
|
params: {},
|
||||||
})
|
})
|
||||||
|
|
||||||
this.pubkey = response
|
this.pubkey = response
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDrives(): Promise<DiskListResponse> {
|
async getDisks() {
|
||||||
return this.rpcRequest<DiskListResponse>({
|
return this.rpcRequest<DiskInfo[]>({
|
||||||
method: 'setup.disk.list',
|
method: 'setup.disk.list',
|
||||||
params: {},
|
params: {},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyCifs(
|
async installOs(params: InstallOsParams) {
|
||||||
source: T.VerifyCifsParams,
|
return this.rpcRequest<InstallOsRes>({
|
||||||
): Promise<Record<string, StartOSDiskInfo>> {
|
method: 'setup.install-os',
|
||||||
|
params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async verifyCifs(source: T.VerifyCifsParams) {
|
||||||
source.path = source.path.replace('/\\/g', '/')
|
source.path = source.path.replace('/\\/g', '/')
|
||||||
return this.rpcRequest<Record<string, StartOSDiskInfo>>({
|
return this.rpcRequest<Record<string, StartOSDiskInfo>>({
|
||||||
method: 'setup.cifs.verify',
|
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>({
|
return this.rpcRequest<T.SetupProgress>({
|
||||||
method: 'setup.attach',
|
method: 'setup.attach',
|
||||||
params,
|
params,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute(setupInfo: T.SetupExecuteParams): Promise<T.SetupProgress> {
|
async execute(params: SetupExecuteParams) {
|
||||||
if (setupInfo.recoverySource?.type === 'backup') {
|
if (params.recoverySource?.type === 'backup') {
|
||||||
if (isCifsSource(setupInfo.recoverySource.target)) {
|
const target = params.recoverySource.target
|
||||||
setupInfo.recoverySource.target.path =
|
if (target.type === 'cifs') {
|
||||||
setupInfo.recoverySource.target.path.replace('/\\/g', '/')
|
target.path = target.path.replace('/\\/g', '/')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.rpcRequest<T.SetupProgress>({
|
return this.rpcRequest<T.SetupProgress>({
|
||||||
method: 'setup.execute',
|
method: 'setup.execute',
|
||||||
params: setupInfo,
|
params,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async initFollowLogs(): Promise<FollowLogsRes> {
|
async initFollowLogs() {
|
||||||
return this.rpcRequest({ method: 'setup.logs.follow', params: {} })
|
return this.rpcRequest<FollowLogsRes>({
|
||||||
|
method: 'setup.logs.follow',
|
||||||
|
params: {},
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async complete(): Promise<T.SetupResult> {
|
async complete() {
|
||||||
const res = await this.rpcRequest<T.SetupResult>({
|
const res = await this.rpcRequest<SetupCompleteRes>({
|
||||||
method: 'setup.complete',
|
method: 'setup.complete',
|
||||||
params: {},
|
params: {},
|
||||||
})
|
})
|
||||||
@@ -113,23 +126,22 @@ export class LiveApiService extends ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async exit(): Promise<void> {
|
async exit() {
|
||||||
await this.rpcRequest<void>({
|
await this.rpcRequest<void>({
|
||||||
method: 'setup.exit',
|
method: 'setup.exit',
|
||||||
params: {},
|
params: {},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async restart(): Promise<void> {
|
async restart() {
|
||||||
await this.rpcRequest<void>({
|
await this.rpcRequest<void>({
|
||||||
method: 'setup.restart',
|
method: 'setup.restart',
|
||||||
params: {},
|
params: {},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private async rpcRequest<T>(opts: RPCOptions): Promise<T> {
|
private async rpcRequest<T>(opts: RPCOptions, url?: string): Promise<T> {
|
||||||
const res = await this.http.rpcRequest<T>(opts)
|
const res = await this.http.rpcRequest<T>(opts, url)
|
||||||
|
|
||||||
const rpcRes = res.body
|
const rpcRes = res.body
|
||||||
|
|
||||||
if (isRpcError(rpcRes)) {
|
if (isRpcError(rpcRes)) {
|
||||||
@@ -139,9 +151,3 @@ export class LiveApiService extends ApiService {
|
|||||||
return rpcRes.result
|
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 { Injectable } from '@angular/core'
|
||||||
import {
|
import {
|
||||||
DiskListResponse,
|
DiskInfo,
|
||||||
encodeBase64,
|
encodeBase64,
|
||||||
FollowLogsRes,
|
FollowLogsRes,
|
||||||
pauseFor,
|
pauseFor,
|
||||||
@@ -8,104 +8,24 @@ import {
|
|||||||
} from '@start9labs/shared'
|
} from '@start9labs/shared'
|
||||||
import { T } from '@start9labs/start-sdk'
|
import { T } from '@start9labs/start-sdk'
|
||||||
import * as jose from 'node-jose'
|
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 { ApiService } from './api.service'
|
||||||
|
import {
|
||||||
|
SetupStatusRes,
|
||||||
|
InstallOsParams,
|
||||||
|
InstallOsRes,
|
||||||
|
AttachParams,
|
||||||
|
SetupExecuteParams,
|
||||||
|
SetupCompleteRes,
|
||||||
|
EchoReq,
|
||||||
|
} from '../types'
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
})
|
})
|
||||||
export class MockApiService extends ApiService {
|
export class MockApiService extends ApiService {
|
||||||
// fullProgress$(): Observable<T.FullProgress> {
|
private statusIndex = 0
|
||||||
// const phases = [
|
private installCompleted = false
|
||||||
// {
|
|
||||||
// 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),
|
|
||||||
// ),
|
|
||||||
// ),
|
|
||||||
// )
|
|
||||||
// }
|
|
||||||
|
|
||||||
openWebsocket$<T>(guid: string): Observable<T> {
|
openWebsocket$<T>(guid: string): Observable<T> {
|
||||||
if (guid === 'logs-guid') {
|
if (guid === 'logs-guid') {
|
||||||
@@ -117,24 +37,13 @@ export class MockApiService extends ApiService {
|
|||||||
})),
|
})),
|
||||||
) as Observable<T>
|
) as Observable<T>
|
||||||
} else if (guid === 'progress-guid') {
|
} else if (guid === 'progress-guid') {
|
||||||
// @TODO Matt mock progress
|
|
||||||
return interval(1000).pipe(
|
return interval(1000).pipe(
|
||||||
first(),
|
|
||||||
map(() => ({
|
map(() => ({
|
||||||
overall: true,
|
overall: true,
|
||||||
phases: [
|
phases: [
|
||||||
{
|
{ name: 'Preparing Data', progress: true },
|
||||||
name: 'Preparing Data',
|
{ name: 'Transferring Data', progress: true },
|
||||||
progress: true,
|
{ name: 'Finalizing Setup', progress: true },
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Transferring Data',
|
|
||||||
progress: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Finalizing Setup',
|
|
||||||
progress: true,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
})),
|
})),
|
||||||
) as Observable<T>
|
) as Observable<T>
|
||||||
@@ -143,40 +52,39 @@ export class MockApiService extends ApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private statusIndex = 0
|
async echo(params: EchoReq, url: string): Promise<string> {
|
||||||
async getStatus(): Promise<T.SetupStatusRes | null> {
|
if (url) {
|
||||||
await pauseFor(1000)
|
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++
|
this.statusIndex++
|
||||||
|
|
||||||
switch (this.statusIndex) {
|
if (this.statusIndex === 1) {
|
||||||
case 2:
|
// return { status: 'needs-install' }
|
||||||
return {
|
return { status: 'incomplete', attach: false, guid: 'mock-data-guid' }
|
||||||
status: 'running',
|
}
|
||||||
progress: PROGRESS,
|
|
||||||
guid: 'progress-guid',
|
if (this.statusIndex > 3) {
|
||||||
}
|
return { status: 'complete' }
|
||||||
case 3:
|
}
|
||||||
return {
|
|
||||||
status: 'complete',
|
return {
|
||||||
torAddresses: ['https://asdafsadasdasasdasdfasdfasdf.onion'],
|
status: 'running',
|
||||||
hostname: 'adjective-noun',
|
progress: PROGRESS,
|
||||||
lanAddress: 'https://adjective-noun.local',
|
guid: 'progress-guid',
|
||||||
rootCa: encodeBase64(rootCA),
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return null
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPubKey(): Promise<void> {
|
async getPubKey(): Promise<void> {
|
||||||
await pauseFor(1000)
|
await pauseFor(300)
|
||||||
|
|
||||||
// randomly generated
|
|
||||||
// const keystore = jose.JWK.createKeyStore()
|
|
||||||
// this.pubkey = await keystore.generate('EC', 'P-256')
|
|
||||||
|
|
||||||
// generated from backend
|
|
||||||
this.pubkey = await jose.JWK.asKey({
|
this.pubkey = await jose.JWK.asKey({
|
||||||
kty: 'EC',
|
kty: 'EC',
|
||||||
crv: 'P-256',
|
crv: 'P-256',
|
||||||
@@ -185,88 +93,18 @@ export class MockApiService extends ApiService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDrives(): Promise<DiskListResponse> {
|
async getDisks(): Promise<DiskInfo[]> {
|
||||||
await pauseFor(1000)
|
await pauseFor(500)
|
||||||
return [
|
return MOCK_DISKS
|
||||||
{
|
}
|
||||||
logicalname: '/dev/nvme0n1p3',
|
|
||||||
vendor: 'Unknown Vendor',
|
async installOs(params: InstallOsParams): Promise<InstallOsRes> {
|
||||||
model: 'Samsung SSD - 970 EVO Plus 2TB',
|
await pauseFor(2000)
|
||||||
partitions: [
|
this.installCompleted = true
|
||||||
{
|
return {
|
||||||
logicalname: 'pabcd',
|
guid: 'mock-data-guid',
|
||||||
label: null,
|
attach: !params.dataDrive.wipe,
|
||||||
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 verifyCifs(
|
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',
|
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||||
wrappedKey: '',
|
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)
|
await pauseFor(1000)
|
||||||
|
this.statusIndex = 1 // Jump to running state
|
||||||
return {
|
return {
|
||||||
progress: PROGRESS,
|
progress: PROGRESS,
|
||||||
guid: 'progress-guid',
|
guid: 'progress-guid',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async execute(setupInfo: T.SetupExecuteParams): Promise<T.SetupProgress> {
|
async execute(params: SetupExecuteParams): Promise<T.SetupProgress> {
|
||||||
await pauseFor(1000)
|
await pauseFor(1000)
|
||||||
|
this.statusIndex = 1 // Jump to running state
|
||||||
return {
|
return {
|
||||||
progress: PROGRESS,
|
progress: PROGRESS,
|
||||||
guid: 'progress-guid',
|
guid: 'progress-guid',
|
||||||
@@ -304,33 +150,109 @@ export class MockApiService extends ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async initFollowLogs(): Promise<FollowLogsRes> {
|
async initFollowLogs(): Promise<FollowLogsRes> {
|
||||||
await pauseFor(1000)
|
await pauseFor(500)
|
||||||
return {
|
return {
|
||||||
startCursor: 'fakestartcursor',
|
startCursor: 'fakestartcursor',
|
||||||
guid: 'logs-guid',
|
guid: 'logs-guid',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async complete(): Promise<T.SetupResult> {
|
async complete(): Promise<SetupCompleteRes> {
|
||||||
await pauseFor(1000)
|
await pauseFor(500)
|
||||||
return {
|
return {
|
||||||
torAddresses: ['https://asdafsadasdasasdasdfasdfasdf.onion'],
|
|
||||||
hostname: 'adjective-noun',
|
hostname: 'adjective-noun',
|
||||||
lanAddress: 'https://adjective-noun.local',
|
rootCa: encodeBase64(ROOT_CA),
|
||||||
rootCa: encodeBase64(rootCA),
|
needsRestart: this.installCompleted,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async exit(): Promise<void> {
|
async exit(): Promise<void> {
|
||||||
await pauseFor(1000)
|
await pauseFor(500)
|
||||||
}
|
}
|
||||||
|
|
||||||
async restart(): Promise<void> {
|
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
|
MIIDpzCCAo+gAwIBAgIRAIIuOarlQETlUQEOZJGZYdIwDQYJKoZIhvcNAQELBQAw
|
||||||
bTELMAkGA1UEBhMCVVMxFTATBgNVBAoMDEV4YW1wbGUgQ29ycDEOMAwGA1UECwwF
|
bTELMAkGA1UEBhMCVVMxFTATBgNVBAoMDEV4YW1wbGUgQ29ycDEOMAwGA1UECwwF
|
||||||
U2FsZXMxCzAJBgNVBAgMAldBMRgwFgYDVQQDDA93d3cuZXhhbXBsZS5jb20xEDAO
|
U2FsZXMxCzAJBgNVBAgMAldBMRgwFgYDVQQDDA93d3cuZXhhbXBsZS5jb20xEDAO
|
||||||
@@ -352,8 +274,3 @@ Rf3ZOPm9QP92YpWyYDkfAU04xdDo1vR0MYjKPkl4LjRqSU/tcCJnPMbJiwq+bWpX
|
|||||||
2WJoEBXB/p15Kn6JxjI0ze2SnSI48JZ8it4fvxrhOo0VoLNIuCuNXJOwU17Rdl1W
|
2WJoEBXB/p15Kn6JxjI0ze2SnSI48JZ8it4fvxrhOo0VoLNIuCuNXJOwU17Rdl1W
|
||||||
YJidaq7je6k18AdgPA0Kh8y1XtfUH3fTaVw4
|
YJidaq7je6k18AdgPA0Kh8y1XtfUH3fTaVw4
|
||||||
-----END CERTIFICATE-----`
|
-----END CERTIFICATE-----`
|
||||||
|
|
||||||
const PROGRESS = {
|
|
||||||
overall: null,
|
|
||||||
phases: [],
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,28 @@
|
|||||||
import { inject, Injectable } from '@angular/core'
|
import { inject, Injectable } from '@angular/core'
|
||||||
import { ApiService } from './api.service'
|
|
||||||
import { T } from '@start9labs/start-sdk'
|
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({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
@@ -8,34 +30,72 @@ import { T } from '@start9labs/start-sdk'
|
|||||||
export class StateService {
|
export class StateService {
|
||||||
private readonly api = inject(ApiService)
|
private readonly api = inject(ApiService)
|
||||||
|
|
||||||
kiosk?: boolean
|
// Determined at app init
|
||||||
setupType?: 'fresh' | 'restore' | 'attach' | 'transfer'
|
kiosk = false
|
||||||
recoverySource?: T.RecoverySource<string>
|
|
||||||
|
|
||||||
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({
|
await this.api.attach({
|
||||||
guid,
|
guid: this.dataDriveGuid,
|
||||||
startOsPassword: await this.api.encrypt(password),
|
startOsPassword: password ? await this.api.encrypt(password) : null,
|
||||||
kiosk: this.kiosk,
|
language: this.language,
|
||||||
|
kiosk: this.kiosk ? { keyboard: this.keyboard } : null,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async setupEmbassy(
|
/**
|
||||||
storageLogicalname: string,
|
* Called for fresh, restore, and transfer flows
|
||||||
password: string,
|
* password is required for fresh, optional for restore/transfer
|
||||||
): Promise<void> {
|
*/
|
||||||
|
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({
|
await this.api.execute({
|
||||||
startOsLogicalname: storageLogicalname,
|
startOsLogicalname: this.dataDriveGuid,
|
||||||
startOsPassword: await this.api.encrypt(password),
|
startOsPassword: password ? await this.api.encrypt(password) : null,
|
||||||
recoverySource: this.recoverySource
|
language: this.language,
|
||||||
? this.recoverySource.type === 'migrate'
|
kiosk: this.kiosk ? { keyboard: this.keyboard } : null,
|
||||||
? this.recoverySource
|
recoverySource,
|
||||||
: {
|
|
||||||
...this.recoverySource,
|
|
||||||
password: await this.api.encrypt(this.recoverySource.password),
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
kiosk: this.kiosk,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
justify-content: center;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
|
||||||
[tuiCardLarge] {
|
[tuiCardLarge] {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--tui-background-elevation-2);
|
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 {
|
button:disabled {
|
||||||
opacity: var(--tui-disabled-opacity);
|
opacity: var(--tui-disabled-opacity);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
@@ -74,4 +87,4 @@ h2 {
|
|||||||
|
|
||||||
[tuiCell]:not(:last-of-type) {
|
[tuiCell]:not(:last-of-type) {
|
||||||
box-shadow: 0 calc(0.5rem + 1px) 0 -0.5rem var(--tui-border-normal);
|
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/logs-window.component'
|
||||||
export * from './components/initializing/initializing.component'
|
export * from './components/initializing/initializing.component'
|
||||||
export * from './components/ticker.component'
|
export * from './components/ticker.component'
|
||||||
export * from './components/drive.component'
|
|
||||||
export * from './components/markdown.component'
|
export * from './components/markdown.component'
|
||||||
export * from './components/prompt.component'
|
export * from './components/prompt.component'
|
||||||
export * from './components/server.component'
|
export * from './components/server.component'
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ export type AccessType =
|
|||||||
export type WorkspaceConfig = {
|
export type WorkspaceConfig = {
|
||||||
gitHash: string
|
gitHash: string
|
||||||
useMocks: boolean
|
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: {
|
ui: {
|
||||||
api: {
|
api: {
|
||||||
url: string
|
url: string
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ import { from, interval, map, shareReplay, startWith, Subject, tap } from 'rxjs'
|
|||||||
import { mockPatchData } from './mock-patch'
|
import { mockPatchData } from './mock-patch'
|
||||||
import { AuthService } from '../auth.service'
|
import { AuthService } from '../auth.service'
|
||||||
import { T } from '@start9labs/start-sdk'
|
import { T } from '@start9labs/start-sdk'
|
||||||
import { MarketplacePkg } from '@start9labs/marketplace'
|
|
||||||
import { WebSocketSubject } from 'rxjs/webSocket'
|
import { WebSocketSubject } from 'rxjs/webSocket'
|
||||||
import { toAuthorityUrl } from 'src/app/utils/acme'
|
import { toAuthorityUrl } from 'src/app/utils/acme'
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user