mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 20:14:49 +00:00
Feature/consolidate setup (#3092)
* start consolidating * add start-cli flash-os * combine install and setup and refactor all * use http * undo mock * fix translation * translations * use dialogservice wrapper * better ST messaging on setup * only warn on update if breakages (#3097) * finish setup wizard and ui language-keyboard feature * fix typo * wip: localization * remove start-tunnel readme * switch to posix strings for language internal * revert mock * translate backend strings * fix missing about text * help text for args * feat: add "Add new gateway" option (#3098) * feat: add "Add new gateway" option * Update web/projects/ui/src/app/routes/portal/components/form/controls/select.component.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * add translation --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: Matt Hill <mattnine@protonmail.com> * fix dns selection * keyboard keymap also * ability to shutdown after install * revert mock * working setup flow + manifest localization * (mostly) redundant localization on frontend * version bump * omit live medium from disk list and better space management * ignore missing package archive on 035 migration * fix device migration * add i18n helper to sdk * fix install over 0.3.5.1 * fix grub config --------- Co-authored-by: Matt Hill <mattnine@protonmail.com> Co-authored-by: Matt Hill <MattDHill@users.noreply.github.com> Co-authored-by: Alex Inkin <alexander@inkin.ru> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -4,7 +4,6 @@ StartOS web UIs are written in [Angular/Typescript](https://angular.io/docs) and
|
||||
|
||||
StartOS conditionally serves one of three Web UIs, depending on the state of the system and user choice.
|
||||
|
||||
- **install-wizard** - UI for installing StartOS, served on localhost.
|
||||
- **setup-wizard** - UI for setting up StartOS, served on start.local.
|
||||
- **ui** - primary UI for administering StartOS, served on various hosts unique to the instance.
|
||||
|
||||
@@ -69,7 +68,6 @@ You can develop using mocks (recommended to start) or against a live server. Eit
|
||||
#### Start the standard development server
|
||||
|
||||
```sh
|
||||
npm run start:install
|
||||
npm run start:setup
|
||||
npm run start:ui
|
||||
```
|
||||
@@ -122,7 +120,6 @@ Translate the English dictionary below into `<language>`. Format the result as a
|
||||
#### Adding to StartOS
|
||||
|
||||
- In the `shared` project:
|
||||
|
||||
1. Create a new file (`language.ts`) in `src/i18n/dictionaries`
|
||||
2. Update the `I18N_PROVIDERS` array in `src/i18n/i18n.providers.ts` (2 places)
|
||||
3. Update the `languages` array in `/src/i18n/i18n.service.ts`
|
||||
@@ -131,7 +128,6 @@ Translate the English dictionary below into `<language>`. Format the result as a
|
||||
If you have any doubt about the above steps, check the [French example PR](https://github.com/Start9Labs/start-os/pull/2945/files) for reference.
|
||||
|
||||
- Here in this README:
|
||||
|
||||
1. Add the language to the list of supported languages below
|
||||
|
||||
### Updating the English dictionary
|
||||
|
||||
108
web/angular.json
108
web/angular.json
@@ -121,114 +121,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"install-wizard": {
|
||||
"projectType": "application",
|
||||
"schematics": {},
|
||||
"root": "projects/install-wizard",
|
||||
"sourceRoot": "projects/install-wizard/src",
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"outputPath": {
|
||||
"base": "dist/raw/install-wizard",
|
||||
"browser": ""
|
||||
},
|
||||
"index": "projects/install-wizard/src/index.html",
|
||||
"browser": "projects/install-wizard/src/main.ts",
|
||||
"polyfills": ["zone.js"],
|
||||
"tsConfig": "projects/install-wizard/tsconfig.json",
|
||||
"inlineStyleLanguage": "scss",
|
||||
"assets": [
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "projects/shared/assets",
|
||||
"output": "assets"
|
||||
},
|
||||
{
|
||||
"glob": "**/*.svg",
|
||||
"input": "node_modules/ionicons/dist/ionicons/svg",
|
||||
"output": "./svg"
|
||||
},
|
||||
{
|
||||
"glob": "**/*",
|
||||
"input": "node_modules/@taiga-ui/icons/src",
|
||||
"output": "assets/taiga-ui/icons"
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"node_modules/@taiga-ui/core/styles/taiga-ui-theme.less"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "projects/install-wizard/src/environments/environment.ts",
|
||||
"with": "projects/install-wizard/src/environments/environment.prod.ts"
|
||||
}
|
||||
],
|
||||
"optimization": true,
|
||||
"outputHashing": "all",
|
||||
"sourceMap": false,
|
||||
"namedChunks": false,
|
||||
"aot": true,
|
||||
"extractLicenses": true,
|
||||
"budgets": [
|
||||
{
|
||||
"type": "initial",
|
||||
"maximumWarning": "2mb",
|
||||
"maximumError": "5mb"
|
||||
}
|
||||
]
|
||||
},
|
||||
"ci": {
|
||||
"progress": false
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
"extractLicenses": false,
|
||||
"sourceMap": true,
|
||||
"namedChunks": true
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular/build:dev-server",
|
||||
"options": {
|
||||
"buildTarget": "install-wizard:build",
|
||||
"port": 8100
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "install-wizard:build:production"
|
||||
},
|
||||
"development": {
|
||||
"buildTarget": "install-wizard:build:development"
|
||||
}
|
||||
},
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular/build:extract-i18n",
|
||||
"options": {
|
||||
"buildTarget": "install-wizard:build"
|
||||
}
|
||||
},
|
||||
"lint": {
|
||||
"builder": "@angular-eslint/builder:lint",
|
||||
"options": {
|
||||
"lintFilePatterns": [
|
||||
"projects/install-wizard/src/**/*.ts",
|
||||
"projects/install-wizard/src/**/*.html"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"setup-wizard": {
|
||||
"projectType": "application",
|
||||
"schematics": {},
|
||||
|
||||
@@ -4,7 +4,6 @@ module.exports = {
|
||||
'projects/ui/**/*.ts': () => 'npm run check:ui',
|
||||
'projects/shared/**/*.ts': () => 'npm run check:shared',
|
||||
'projects/marketplace/**/*.ts': () => 'npm run check:marketplace',
|
||||
'projects/install-wizard/**/*.ts': () => 'npm run check:install',
|
||||
'projects/setup-wizard/**/*.ts': () => 'npm run check:setup',
|
||||
'projects/start-tunnel/**/*.ts': () => 'npm run check:tunnel',
|
||||
}
|
||||
|
||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "startos-ui",
|
||||
"version": "0.4.0-alpha.17",
|
||||
"version": "0.4.0-alpha.18",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "startos-ui",
|
||||
"version": "0.4.0-alpha.17",
|
||||
"version": "0.4.0-alpha.18",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@angular/animations": "^20.3.0",
|
||||
|
||||
@@ -1,31 +1,28 @@
|
||||
{
|
||||
"name": "startos-ui",
|
||||
"version": "0.4.0-alpha.17",
|
||||
"version": "0.4.0-alpha.18",
|
||||
"author": "Start9 Labs, Inc",
|
||||
"homepage": "https://start9.com/",
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"ng": "ng",
|
||||
"check": "npm run check:shared && npm run check:marketplace && npm run check:ui && npm run check:install && npm run check:setup",
|
||||
"check": "npm run check:shared && npm run check:marketplace && npm run check:ui && npm run check:setup",
|
||||
"check:shared": "tsc --project projects/shared/tsconfig.json --noEmit --skipLibCheck",
|
||||
"check:marketplace": "tsc --project projects/marketplace/tsconfig.json --noEmit --skipLibCheck",
|
||||
"check:install": "tsc --project projects/install-wizard/tsconfig.json --noEmit --skipLibCheck",
|
||||
"check:setup": "tsc --project projects/setup-wizard/tsconfig.json --noEmit --skipLibCheck",
|
||||
"check:ui": "tsc --project projects/ui/tsconfig.json --noEmit --skipLibCheck",
|
||||
"check:tunnel": "tsc --project projects/start-tunnel/tsconfig.json --noEmit --skipLibCheck",
|
||||
"build:deps": "rimraf .angular/cache && (cd ../sdk && make bundle) && (cd ../patch-db/client && npm ci && npm run build)",
|
||||
"build:install": "ng run install-wizard:build",
|
||||
"build:setup": "ng run setup-wizard:build",
|
||||
"build:ui": "ng run ui:build",
|
||||
"build:ui:dev": "ng run ui:build:development",
|
||||
"build:tunnel": "ng run start-tunnel:build",
|
||||
"build:all": "npm run build:deps && npm run build:setup && npm run build:ui && npm run build:install",
|
||||
"build:all": "npm run build:deps && npm run build:setup && npm run build:ui",
|
||||
"build:shared": "ng build shared",
|
||||
"build:marketplace": "npm run build:shared && ng build marketplace",
|
||||
"analyze:ui": "ng build ui --stats-json --named-chunks && npx -y @angular-experts/hawkeye dist/raw/ui/stats.json",
|
||||
"publish:shared": "npm run build:shared && npm publish ./dist/shared --access public",
|
||||
"publish:marketplace": "npm run build:marketplace && npm publish ./dist/marketplace --access public",
|
||||
"start:install": "npm run-script build-config && ng serve --project install-wizard --host 0.0.0.0",
|
||||
"start:setup": "npm run-script build-config && ng serve --project setup-wizard --host 0.0.0.0",
|
||||
"start:ui": "npm run-script build-config && ng serve --project ui --host 0.0.0.0",
|
||||
"start:tunnel": "ng serve --project start-tunnel --host 0.0.0.0",
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
<tui-root>
|
||||
<main>
|
||||
<img class="logo" src="assets/img/icon.png" alt="Start9" />
|
||||
<section tuiCardLarge tuiSurface="floating" class="card">
|
||||
<header class="header">
|
||||
@if (selected) {
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="flat-grayscale"
|
||||
size="m"
|
||||
class="back"
|
||||
iconStart="@tui.chevron-left"
|
||||
[style.border-radius.rem]="10"
|
||||
(click)="selected = null"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
}
|
||||
<h1>{{ selected ? 'Install Type' : 'StartOS Install' }}</h1>
|
||||
@if (!selected) {
|
||||
<h2>Select Disk</h2>
|
||||
}
|
||||
<div [style.color]="'var(--tui-text-negative)'">{{ error }}</div>
|
||||
</header>
|
||||
<div class="pages">
|
||||
<div class="options" [class.options_selected]="selected">
|
||||
@for (drive of disks$ | async; track $index) {
|
||||
<button tuiCell [drive]="drive" (click)="selected = drive"></button>
|
||||
}
|
||||
</div>
|
||||
<div class="options">
|
||||
@if (guid) {
|
||||
<button tuiCell (click)="install()">
|
||||
<tui-icon icon="@tui.life-buoy" />
|
||||
<span tuiTitle>
|
||||
<strong [style.color]="'var(--tui-text-positive)'">
|
||||
Re-Install StartOS
|
||||
</strong>
|
||||
<span tuiSubtitle>Will preserve existing StartOS data</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button tuiCell [disabled]="!selected" (click)="warn()">
|
||||
<tui-icon icon="@tui.download" />
|
||||
<span tuiTitle>
|
||||
@if (guid) {
|
||||
<span [style.color]="'var(--tui-text-negative)'">
|
||||
Factory Reset
|
||||
</span>
|
||||
} @else {
|
||||
<span [style.color]="'var(--tui-text-positive)'">
|
||||
Install StartOS
|
||||
</span>
|
||||
}
|
||||
<span tuiSubtitle>Will delete existing data on disk</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</tui-root>
|
||||
@@ -1,71 +0,0 @@
|
||||
@use '@taiga-ui/core/styles/taiga-ui-local' as taiga;
|
||||
|
||||
::ng-deep html,
|
||||
::ng-deep body,
|
||||
tui-root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
color: var(--tui-text-primary);
|
||||
}
|
||||
|
||||
main {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--tui-background-accent-opposite-hover);
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 6rem;
|
||||
margin-bottom: -2rem;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.card {
|
||||
max-width: min(30rem, 90vw);
|
||||
}
|
||||
|
||||
.header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
padding-top: 0.25rem;
|
||||
margin-bottom: -2rem;
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.back {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
.pages {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.options {
|
||||
@include taiga.transition(margin);
|
||||
|
||||
min-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
|
||||
&_selected {
|
||||
margin-left: -100%;
|
||||
}
|
||||
}
|
||||
|
||||
[tuiCell]:not(:last-of-type) {
|
||||
box-shadow: 0 calc(0.5rem + 1px) 0 -0.5rem var(--tui-border-normal);
|
||||
}
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
import { TUI_CONFIRM } from '@taiga-ui/kit'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { DiskInfo, i18nKey, LoadingService, toGuid } from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { filter, from } from 'rxjs'
|
||||
import { SUCCESS, toWarning } from 'src/app/app.utils'
|
||||
import { ApiService } from 'src/app/services/api.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: 'app.component.html',
|
||||
styleUrls: ['app.component.scss'],
|
||||
standalone: false,
|
||||
})
|
||||
export class AppComponent {
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
|
||||
readonly disks$ = from(this.api.getDisks())
|
||||
selected: DiskInfo | null = null
|
||||
error = ''
|
||||
|
||||
get guid() {
|
||||
return toGuid(this.selected)
|
||||
}
|
||||
|
||||
async install(overwrite = false) {
|
||||
const loader = this.loader.open('Installing StartOS' as i18nKey).subscribe()
|
||||
const logicalname = this.selected?.logicalname || ''
|
||||
|
||||
try {
|
||||
await this.api.install({ logicalname, overwrite })
|
||||
this.reboot()
|
||||
} catch (e: any) {
|
||||
this.error = e.message
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
warn() {
|
||||
this.dialogs
|
||||
.open(TUI_CONFIRM, toWarning(this.selected))
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => {
|
||||
this.install(true)
|
||||
})
|
||||
}
|
||||
|
||||
private async reboot() {
|
||||
this.dialogs
|
||||
.open('1. Remove the USB stick<br />2. Click "Reboot" below', SUCCESS)
|
||||
.subscribe({
|
||||
complete: async () => {
|
||||
const loader = this.loader.open().subscribe()
|
||||
|
||||
try {
|
||||
await this.api.reboot()
|
||||
this.dialogs
|
||||
.open(
|
||||
window.location.host === 'localhost'
|
||||
? 'Please wait 1-2 minutes for your server to restart'
|
||||
: 'Please wait 1-2 minutes, then refresh this page to access the StartOS setup wizard.',
|
||||
{
|
||||
label: 'Rebooting',
|
||||
size: 's',
|
||||
closeable: false,
|
||||
dismissible: false,
|
||||
},
|
||||
)
|
||||
.subscribe()
|
||||
} catch (e: any) {
|
||||
this.error = e.message
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
import {
|
||||
provideHttpClient,
|
||||
withFetch,
|
||||
withInterceptorsFromDi,
|
||||
} from '@angular/common/http'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
|
||||
import {
|
||||
DriveComponent,
|
||||
i18nPipe,
|
||||
RELATIVE_URL,
|
||||
WorkspaceConfig,
|
||||
} from '@start9labs/shared'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiIcon,
|
||||
TuiRoot,
|
||||
TuiSurface,
|
||||
TuiTitle,
|
||||
} from '@taiga-ui/core'
|
||||
import { NG_EVENT_PLUGINS } from '@taiga-ui/event-plugins'
|
||||
import { TuiCardLarge, TuiCell } from '@taiga-ui/layout'
|
||||
import { ApiService } from 'src/app/services/api.service'
|
||||
import { LiveApiService } from 'src/app/services/live-api.service'
|
||||
import { MockApiService } from 'src/app/services/mock-api.service'
|
||||
import { AppComponent } from './app.component'
|
||||
|
||||
const {
|
||||
useMocks,
|
||||
ui: { api },
|
||||
} = require('../../../../config.json') as WorkspaceConfig
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent],
|
||||
imports: [
|
||||
BrowserAnimationsModule,
|
||||
TuiRoot,
|
||||
DriveComponent,
|
||||
TuiButton,
|
||||
TuiCardLarge,
|
||||
TuiCell,
|
||||
TuiIcon,
|
||||
TuiSurface,
|
||||
TuiTitle,
|
||||
i18nPipe,
|
||||
],
|
||||
providers: [
|
||||
NG_EVENT_PLUGINS,
|
||||
{
|
||||
provide: ApiService,
|
||||
useClass: useMocks ? MockApiService : LiveApiService,
|
||||
},
|
||||
{
|
||||
provide: RELATIVE_URL,
|
||||
useValue: `/${api.url}/${api.version}`,
|
||||
},
|
||||
provideHttpClient(withInterceptorsFromDi(), withFetch()),
|
||||
],
|
||||
bootstrap: [AppComponent],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { DiskInfo } from '@start9labs/shared'
|
||||
import { TuiDialogOptions } from '@taiga-ui/core'
|
||||
import { TuiConfirmData } from '@taiga-ui/kit'
|
||||
|
||||
export const SUCCESS: Partial<TuiDialogOptions<any>> = {
|
||||
label: 'Install Success!',
|
||||
closeable: false,
|
||||
size: 's',
|
||||
data: { button: 'Reboot' },
|
||||
}
|
||||
|
||||
export function toWarning(
|
||||
disk: DiskInfo | null,
|
||||
): Partial<TuiDialogOptions<TuiConfirmData>> {
|
||||
return {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content: `This action will COMPLETELY erase the disk ${
|
||||
disk?.vendor || 'Unknown Vendor'
|
||||
} - ${disk?.model || 'Unknown Model'} and install StartOS in its place`,
|
||||
yes: 'Continue',
|
||||
no: 'Cancel',
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { DiskInfo } from '@start9labs/shared'
|
||||
|
||||
export abstract class ApiService {
|
||||
abstract getDisks(): Promise<GetDisksRes> // install.disk.list
|
||||
abstract install(params: InstallReq): Promise<void> // install.execute
|
||||
abstract reboot(): Promise<void> // install.reboot
|
||||
}
|
||||
|
||||
export type GetDisksRes = DiskInfo[]
|
||||
|
||||
export type InstallReq = {
|
||||
logicalname: string
|
||||
overwrite: boolean
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import {
|
||||
HttpService,
|
||||
isRpcError,
|
||||
RpcError,
|
||||
RPCOptions,
|
||||
} from '@start9labs/shared'
|
||||
import { ApiService, GetDisksRes, InstallReq } from './api.service'
|
||||
|
||||
@Injectable()
|
||||
export class LiveApiService implements ApiService {
|
||||
constructor(private readonly http: HttpService) {}
|
||||
|
||||
async getDisks(): Promise<GetDisksRes> {
|
||||
return this.rpcRequest({
|
||||
method: 'install.disk.list',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async install(params: InstallReq): Promise<void> {
|
||||
return this.rpcRequest<void>({
|
||||
method: 'install.execute',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
async reboot(): Promise<void> {
|
||||
return this.rpcRequest<void>({
|
||||
method: 'install.reboot',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
private async rpcRequest<T>(opts: RPCOptions): Promise<T> {
|
||||
const res = await this.http.rpcRequest<T>(opts)
|
||||
|
||||
const rpcRes = res.body
|
||||
|
||||
if (isRpcError(rpcRes)) {
|
||||
throw new RpcError(rpcRes.error)
|
||||
}
|
||||
|
||||
return rpcRes.result
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { pauseFor } from '@start9labs/shared'
|
||||
import { ApiService, GetDisksRes, InstallReq } from './api.service'
|
||||
|
||||
@Injectable()
|
||||
export class MockApiService implements ApiService {
|
||||
async getDisks(): Promise<GetDisksRes> {
|
||||
await pauseFor(500)
|
||||
return [
|
||||
{
|
||||
logicalname: 'abcd',
|
||||
vendor: 'Samsung',
|
||||
model: 'T5',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: 'pabcd',
|
||||
label: null,
|
||||
capacity: 73264762332,
|
||||
used: null,
|
||||
startOs: {
|
||||
'1234-5678-9876-5432': {
|
||||
hostname: 'adjective-noun',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '0.2.17',
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
},
|
||||
},
|
||||
guid: null,
|
||||
},
|
||||
],
|
||||
capacity: 123456789123,
|
||||
guid: 'uuid-uuid-uuid-uuid',
|
||||
},
|
||||
{
|
||||
logicalname: 'dcba',
|
||||
vendor: 'Crucial',
|
||||
model: 'MX500',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: 'pbcba',
|
||||
label: null,
|
||||
capacity: 73264762332,
|
||||
used: null,
|
||||
startOs: {
|
||||
'1234-5678-9876-5432': {
|
||||
hostname: 'adjective-noun',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '0.2.17',
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
},
|
||||
},
|
||||
guid: null,
|
||||
},
|
||||
],
|
||||
capacity: 124456789123,
|
||||
guid: null,
|
||||
},
|
||||
{
|
||||
logicalname: 'wxyz',
|
||||
vendor: 'SanDisk',
|
||||
model: 'Specialness',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: 'pbcba',
|
||||
label: null,
|
||||
capacity: 73264762332,
|
||||
used: null,
|
||||
startOs: {
|
||||
'1234-5678-9876-5432': {
|
||||
hostname: 'adjective-noun',
|
||||
timestamp: new Date().toISOString(),
|
||||
version: '0.2.17',
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
},
|
||||
},
|
||||
guid: 'guid-guid-guid-guid',
|
||||
},
|
||||
],
|
||||
capacity: 123459789123,
|
||||
guid: null,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
async install(params: InstallReq): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
|
||||
async reboot(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export const environment = {
|
||||
production: true,
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
// This file can be replaced during build by using the `fileReplacements` array.
|
||||
// `ng build --prod` replaces `environment.ts` with `environment.prod.ts`.
|
||||
// The list of file replacements can be found in `angular.json`.
|
||||
|
||||
export const environment = {
|
||||
production: false,
|
||||
}
|
||||
|
||||
/*
|
||||
* For easier debugging in development mode, you can import the following file
|
||||
* to ignore zone related error stack frames such as `zone.run`, `zoneDelegate.invokeTask`.
|
||||
*
|
||||
* This import should be commented out in production mode because it will have a negative impact
|
||||
* on performance if an error is thrown.
|
||||
*/
|
||||
// import 'zone.js/dist/zone-error'; // Included with Angular CLI.
|
||||
@@ -1,30 +0,0 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>StartOS Install Wizard</title>
|
||||
|
||||
<base href="/" />
|
||||
|
||||
<meta name="color-scheme" content="light dark" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"
|
||||
/>
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<meta name="msapplication-tap-highlight" content="no" />
|
||||
|
||||
<link
|
||||
rel="icon"
|
||||
type="image/png"
|
||||
href="/assets/icons/favicon-96x96.png"
|
||||
sizes="96x96"
|
||||
/>
|
||||
<link rel="icon" type="image/svg+xml" href="/assets/icons/favicon.svg" />
|
||||
<link rel="shortcut icon" href="/assets/icons/favicon.ico" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,12 +0,0 @@
|
||||
import { enableProdMode } from '@angular/core'
|
||||
import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'
|
||||
import { AppModule } from './app/app.module'
|
||||
import { environment } from './environments/environment'
|
||||
|
||||
if (environment.production) {
|
||||
enableProdMode()
|
||||
}
|
||||
|
||||
platformBrowserDynamic()
|
||||
.bootstrapModule(AppModule)
|
||||
.catch(err => console.error(err))
|
||||
@@ -1,9 +0,0 @@
|
||||
/* To learn more about this file see: https://angular.io/config/tsconfig. */
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./"
|
||||
},
|
||||
"files": ["src/main.ts"],
|
||||
"include": ["src/**/*.d.ts"]
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
</div>
|
||||
<span class="category-title" [tuiSkeleton]="categories ? false : 3">
|
||||
{{
|
||||
cat.key === 'ai' ? (cat.key | uppercase) : (cat.value.name | titlecase)
|
||||
cat.key === 'ai' ? (cat.key | uppercase) : (cat.value.name | localize | titlecase)
|
||||
}}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
@@ -2,12 +2,13 @@ import { TuiIcon, TuiAppearance } from '@taiga-ui/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { TuiSkeleton } from '@taiga-ui/kit'
|
||||
import { LocalizePipe } from '@start9labs/shared'
|
||||
|
||||
import { CategoriesComponent } from './categories.component'
|
||||
import { RouterModule } from '@angular/router'
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule, CommonModule, TuiAppearance, TuiIcon, TuiSkeleton],
|
||||
imports: [RouterModule, CommonModule, TuiAppearance, TuiIcon, TuiSkeleton, LocalizePipe],
|
||||
declarations: [CategoriesComponent],
|
||||
exports: [CategoriesComponent],
|
||||
})
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { KeyValue } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { ChangeDetectionStrategy, Component, inject, Input } from '@angular/core'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { ExverPipesModule, i18nPipe } from '@start9labs/shared'
|
||||
import { ExverPipesModule, i18nPipe, i18nService } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { TuiAvatar, TuiLineClamp } from '@taiga-ui/kit'
|
||||
import { MarketplacePkgBase } from '../../../types'
|
||||
@@ -97,6 +97,8 @@ import { MarketplacePkgBase } from '../../../types'
|
||||
imports: [RouterModule, TuiAvatar, ExverPipesModule, TuiLineClamp, i18nPipe],
|
||||
})
|
||||
export class MarketplaceDepItemComponent {
|
||||
private readonly i18nService = inject(i18nService)
|
||||
|
||||
@Input({ required: true })
|
||||
pkg!: MarketplacePkgBase
|
||||
|
||||
@@ -109,6 +111,7 @@ export class MarketplaceDepItemComponent {
|
||||
}
|
||||
|
||||
getTitle(key: string): string {
|
||||
return this.pkg.dependencyMetadata[key]?.title || key
|
||||
const title = this.pkg.dependencyMetadata[key]?.title
|
||||
return title ? this.i18nService.localize(title) : key
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { SharedPipesModule, TickerComponent } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
|
||||
@Component({
|
||||
selector: 'marketplace-package-hero',
|
||||
@@ -125,7 +126,7 @@ export class MarketplacePackageHeroComponent {
|
||||
id: string
|
||||
title: string
|
||||
version: string
|
||||
description: { short: string }
|
||||
description: { short: T.LocaleString }
|
||||
icon: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { ChangeDetectionStrategy, Component, input } from '@angular/core'
|
||||
import { MarkdownPipe } from '@start9labs/shared'
|
||||
import { LocalizePipe, MarkdownPipe } from '@start9labs/shared'
|
||||
import { NgDompurifyPipe } from '@taiga-ui/dompurify'
|
||||
import { MarketplacePkgBase } from '../../types'
|
||||
|
||||
@@ -9,7 +9,7 @@ import { MarketplacePkgBase } from '../../types'
|
||||
<div class="background-border box-shadow-lg shadow-color-light">
|
||||
<div class="box-container">
|
||||
<h2 class="additional-detail-title">New in {{ pkg().version }}</h2>
|
||||
<p [innerHTML]="pkg().releaseNotes | markdown | dompurify"></p>
|
||||
<p [innerHTML]="pkg().releaseNotes | localize | markdown | dompurify"></p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
@@ -21,7 +21,7 @@ import { MarketplacePkgBase } from '../../types'
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [NgDompurifyPipe, MarkdownPipe],
|
||||
imports: [NgDompurifyPipe, MarkdownPipe, LocalizePipe],
|
||||
})
|
||||
export class MarketplaceReleaseNotesComponent {
|
||||
readonly pkg = input.required<MarketplacePkgBase>()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Component, inject, DOCUMENT } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { ApiService } from 'src/app/services/api.service'
|
||||
import { ApiService } from './services/api.service'
|
||||
import { StateService } from './services/state.service'
|
||||
|
||||
@Component({
|
||||
@@ -18,19 +18,49 @@ export class AppComponent {
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
// Determine if we're in kiosk mode
|
||||
this.stateService.kiosk = ['localhost', '127.0.0.1'].includes(
|
||||
this.document.location.hostname,
|
||||
)
|
||||
|
||||
const inProgress = await this.api.getStatus()
|
||||
// Get pubkey for encryption
|
||||
await this.api.getPubKey()
|
||||
|
||||
let route = 'home'
|
||||
// Check setup status to determine initial route
|
||||
const status = await this.api.getStatus()
|
||||
|
||||
if (inProgress) {
|
||||
route = inProgress.status === 'complete' ? '/success' : '/loading'
|
||||
switch (status.status) {
|
||||
case 'needs-install':
|
||||
// Restore keyboard from status if it was previously set
|
||||
if (status.keyboard) {
|
||||
this.stateService.keyboard = status.keyboard.layout
|
||||
}
|
||||
// Start the install flow
|
||||
await this.router.navigate(['/language'])
|
||||
break
|
||||
|
||||
case 'incomplete':
|
||||
// Store the data drive info from status
|
||||
this.stateService.dataDriveGuid = status.guid
|
||||
this.stateService.attach = status.attach
|
||||
// Restore keyboard from status if it was previously set
|
||||
if (status.keyboard) {
|
||||
this.stateService.keyboard = status.keyboard.layout
|
||||
}
|
||||
|
||||
await this.router.navigate(['/language'])
|
||||
break
|
||||
|
||||
case 'running':
|
||||
// Setup is in progress, show loading page
|
||||
await this.router.navigate(['/loading'])
|
||||
break
|
||||
|
||||
case 'complete':
|
||||
// Setup execution finished, show success page
|
||||
await this.router.navigate(['/success'])
|
||||
break
|
||||
}
|
||||
|
||||
await this.router.navigate([route])
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { PreloadAllModules, RouterModule } from '@angular/router'
|
||||
import { WA_LOCATION } from '@ng-web-apis/common'
|
||||
import initArgon from '@start9labs/argon2'
|
||||
import {
|
||||
I18N_PROVIDERS,
|
||||
provideSetupLogsService,
|
||||
RELATIVE_URL,
|
||||
VERSION,
|
||||
@@ -16,9 +17,9 @@ import {
|
||||
} from '@start9labs/shared'
|
||||
import { tuiButtonOptionsProvider, TuiRoot } from '@taiga-ui/core'
|
||||
import { NG_EVENT_PLUGINS } from '@taiga-ui/event-plugins'
|
||||
import { ApiService } from 'src/app/services/api.service'
|
||||
import { LiveApiService } from 'src/app/services/live-api.service'
|
||||
import { MockApiService } from 'src/app/services/mock-api.service'
|
||||
import { ApiService } from './services/api.service'
|
||||
import { LiveApiService } from './services/live-api.service'
|
||||
import { MockApiService } from './services/mock-api.service'
|
||||
import { AppComponent } from './app.component'
|
||||
import { ROUTES } from './app.routes'
|
||||
|
||||
@@ -41,6 +42,7 @@ const version = require('../../../../package.json').version
|
||||
],
|
||||
providers: [
|
||||
NG_EVENT_PLUGINS,
|
||||
I18N_PROVIDERS,
|
||||
provideSetupLogsService(ApiService),
|
||||
tuiButtonOptionsProvider({ size: 'm' }),
|
||||
{
|
||||
|
||||
@@ -1,33 +1,48 @@
|
||||
import { Routes } from '@angular/router'
|
||||
|
||||
export const ROUTES: Routes = [
|
||||
{ path: '', redirectTo: '/home', pathMatch: 'full' },
|
||||
// Entry point - app.component handles initial routing based on setup.status
|
||||
{ path: '', redirectTo: '/language', pathMatch: 'full' },
|
||||
|
||||
// Install flow
|
||||
{
|
||||
path: 'language',
|
||||
loadComponent: () => import('./pages/language.page'),
|
||||
},
|
||||
{
|
||||
path: 'keyboard',
|
||||
loadComponent: () => import('./pages/keyboard.page'),
|
||||
},
|
||||
{
|
||||
path: 'drives',
|
||||
loadComponent: () => import('./pages/drives.page'),
|
||||
},
|
||||
|
||||
// Setup flow (after install or for pre-installed devices)
|
||||
{
|
||||
path: 'home',
|
||||
loadComponent: () => import('src/app/pages/home.page'),
|
||||
loadComponent: () => import('./pages/home.page'),
|
||||
},
|
||||
{
|
||||
path: 'attach',
|
||||
loadComponent: () => import('src/app/pages/attach.page'),
|
||||
},
|
||||
{
|
||||
path: 'recover',
|
||||
loadComponent: () => import('src/app/pages/recover.page'),
|
||||
path: 'restore',
|
||||
loadComponent: () => import('./pages/restore.page'),
|
||||
},
|
||||
{
|
||||
path: 'transfer',
|
||||
loadComponent: () => import('src/app/pages/transfer.page'),
|
||||
loadComponent: () => import('./pages/transfer.page'),
|
||||
},
|
||||
{
|
||||
path: 'storage',
|
||||
loadComponent: () => import('src/app/pages/storage.page'),
|
||||
path: 'password',
|
||||
loadComponent: () => import('./pages/password.page'),
|
||||
},
|
||||
|
||||
// Shared
|
||||
{
|
||||
path: 'loading',
|
||||
loadComponent: () => import('src/app/pages/loading.page'),
|
||||
loadComponent: () => import('./pages/loading.page'),
|
||||
},
|
||||
{
|
||||
path: 'success',
|
||||
loadComponent: () => import('src/app/pages/success.page'),
|
||||
loadComponent: () => import('./pages/success.page'),
|
||||
},
|
||||
]
|
||||
|
||||
@@ -3,39 +3,38 @@ import { Component, inject } from '@angular/core'
|
||||
import {
|
||||
FormControl,
|
||||
FormGroup,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms'
|
||||
import { i18nKey, LoadingService, StartOSDiskInfo } from '@start9labs/shared'
|
||||
import { DialogService, i18nPipe } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDialogContext,
|
||||
TuiDialogService,
|
||||
TuiError,
|
||||
TuiIcon,
|
||||
TuiTextfield,
|
||||
} from '@taiga-ui/core'
|
||||
import {
|
||||
TUI_VALIDATION_ERRORS,
|
||||
TuiButtonLoading,
|
||||
TuiFieldErrorPipe,
|
||||
TuiPassword,
|
||||
} from '@taiga-ui/kit'
|
||||
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import { SERVERS, ServersResponse } from 'src/app/components/servers.component'
|
||||
import { ApiService } from 'src/app/services/api.service'
|
||||
import { ApiService } from '../services/api.service'
|
||||
import { StartOSDiskInfoWithId } from '../types'
|
||||
|
||||
export interface CifsResponse {
|
||||
export interface CifsResult {
|
||||
cifs: T.Cifs
|
||||
serverId: string
|
||||
password: string
|
||||
servers: StartOSDiskInfoWithId[]
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
<tui-textfield>
|
||||
<label tuiLabel>Hostname *</label>
|
||||
<label tuiLabel>{{ 'Hostname' | i18n }}*</label>
|
||||
<input
|
||||
tuiTextfield
|
||||
formControlName="hostname"
|
||||
@@ -48,17 +47,17 @@ export interface CifsResponse {
|
||||
/>
|
||||
|
||||
<tui-textfield class="input">
|
||||
<label tuiLabel>Path *</label>
|
||||
<label tuiLabel>{{ 'Path' | i18n }}*</label>
|
||||
<input
|
||||
tuiTextfield
|
||||
formControlName="path"
|
||||
placeholder="/Desktop/my-folder'"
|
||||
placeholder="/Desktop/my-folder"
|
||||
/>
|
||||
</tui-textfield>
|
||||
<tui-error formControlName="path" [error]="[] | tuiFieldError | async" />
|
||||
|
||||
<tui-textfield class="input">
|
||||
<label tuiLabel>Username *</label>
|
||||
<label tuiLabel>{{ 'Username' | i18n }}*</label>
|
||||
<input
|
||||
tuiTextfield
|
||||
formControlName="username"
|
||||
@@ -71,7 +70,7 @@ export interface CifsResponse {
|
||||
/>
|
||||
|
||||
<tui-textfield class="input">
|
||||
<label tuiLabel>Password</label>
|
||||
<label tuiLabel>{{ 'Password' | i18n }}</label>
|
||||
<input tuiTextfield type="password" formControlName="password" />
|
||||
<tui-icon tuiPassword />
|
||||
</tui-textfield>
|
||||
@@ -81,11 +80,14 @@ export interface CifsResponse {
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
type="button"
|
||||
[disabled]="connecting"
|
||||
(click)="cancel()"
|
||||
>
|
||||
Cancel
|
||||
{{ 'Cancel' | i18n }}
|
||||
</button>
|
||||
<button tuiButton [disabled]="form.invalid" [loading]="connecting">
|
||||
{{ 'Connect' | i18n }}
|
||||
</button>
|
||||
<button tuiButton [disabled]="form.invalid">Verify</button>
|
||||
</footer>
|
||||
</form>
|
||||
`,
|
||||
@@ -97,18 +99,20 @@ export interface CifsResponse {
|
||||
footer {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
TuiButton,
|
||||
TuiButtonLoading,
|
||||
TuiTextfield,
|
||||
TuiPassword,
|
||||
TuiError,
|
||||
TuiFieldErrorPipe,
|
||||
TuiIcon,
|
||||
i18nPipe,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
@@ -120,10 +124,11 @@ export interface CifsResponse {
|
||||
],
|
||||
})
|
||||
export class CifsComponent {
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly dialogs = inject(DialogService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly context = injectContext<TuiDialogContext<CifsResponse>>()
|
||||
private readonly context = injectContext<TuiDialogContext<CifsResult>>()
|
||||
|
||||
connecting = false
|
||||
|
||||
readonly form = new FormGroup({
|
||||
hostname: new FormControl('', {
|
||||
@@ -149,9 +154,7 @@ export class CifsComponent {
|
||||
}
|
||||
|
||||
async submit(): Promise<void> {
|
||||
const loader = this.loader
|
||||
.open('Connecting to shared folder' as i18nKey)
|
||||
.subscribe()
|
||||
this.connecting = true
|
||||
|
||||
try {
|
||||
const diskInfo = await this.api.verifyCifs({
|
||||
@@ -161,36 +164,25 @@ export class CifsComponent {
|
||||
: null,
|
||||
})
|
||||
|
||||
loader.unsubscribe()
|
||||
const servers = Object.keys(diskInfo).map(id => ({
|
||||
id,
|
||||
...diskInfo[id]!,
|
||||
}))
|
||||
|
||||
this.selectServer(diskInfo)
|
||||
this.context.completeWith({
|
||||
cifs: { ...this.form.getRawValue() },
|
||||
servers,
|
||||
})
|
||||
} catch (e) {
|
||||
loader.unsubscribe()
|
||||
this.connecting = false
|
||||
this.onFail()
|
||||
}
|
||||
}
|
||||
|
||||
private selectServer(servers: Record<string, StartOSDiskInfo>) {
|
||||
this.dialogs
|
||||
.open<ServersResponse>(SERVERS, {
|
||||
label: 'Select Server to Restore',
|
||||
data: {
|
||||
servers: Object.keys(servers).map(id => ({ id, ...servers[id] })),
|
||||
},
|
||||
})
|
||||
.subscribe(({ password, serverId }) => {
|
||||
this.context.completeWith({
|
||||
cifs: { ...this.form.getRawValue() },
|
||||
serverId,
|
||||
password,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private onFail() {
|
||||
this.dialogs
|
||||
.open(
|
||||
'Unable to connect to shared folder. Ensure (1) target computer is connected to LAN, (2) target folder is being shared, and (3) hostname, path, and credentials are accurate.',
|
||||
.openAlert(
|
||||
'Unable to connect to network folder. Ensure (1) target computer is connected to LAN, (2) target folder is being shared, and (3) hostname, path, and credentials are accurate.',
|
||||
{
|
||||
label: 'Connection Failed',
|
||||
size: 's',
|
||||
|
||||
@@ -1,156 +0,0 @@
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import {
|
||||
AbstractControl,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms'
|
||||
import { verify } from '@start9labs/argon2'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { TuiAutoFocus, TuiMapperPipe, TuiValidator } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDialogContext,
|
||||
TuiError,
|
||||
TuiIcon,
|
||||
TuiTextfield,
|
||||
} from '@taiga-ui/core'
|
||||
import {
|
||||
TuiFieldErrorPipe,
|
||||
TuiPassword,
|
||||
tuiValidationErrorsProvider,
|
||||
} from '@taiga-ui/kit'
|
||||
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
|
||||
interface DialogData {
|
||||
passwordHash?: string
|
||||
storageDrive?: boolean
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
@if (storageDrive) {
|
||||
Choose a password for your server.
|
||||
<em>Make it good. Write it down.</em>
|
||||
} @else {
|
||||
Enter the password that was used to encrypt this drive.
|
||||
}
|
||||
|
||||
<form [formGroup]="form" [style.margin-top.rem]="1" (ngSubmit)="submit()">
|
||||
<tui-textfield>
|
||||
<label tuiLabel>Enter Password</label>
|
||||
<input
|
||||
tuiTextfield
|
||||
type="password"
|
||||
tuiAutoFocus
|
||||
maxlength="64"
|
||||
formControlName="password"
|
||||
/>
|
||||
<tui-icon tuiPassword />
|
||||
</tui-textfield>
|
||||
<tui-error
|
||||
formControlName="password"
|
||||
[error]="[] | tuiFieldError | async"
|
||||
/>
|
||||
@if (storageDrive) {
|
||||
<tui-textfield [style.margin-top.rem]="1">
|
||||
<label tuiLabel>Retype Password</label>
|
||||
<input
|
||||
tuiTextfield
|
||||
type="password"
|
||||
maxlength="64"
|
||||
formControlName="confirm"
|
||||
[tuiValidator]="form.controls.password.value | tuiMapper: validator"
|
||||
/>
|
||||
<tui-icon tuiPassword />
|
||||
</tui-textfield>
|
||||
<tui-error
|
||||
formControlName="confirm"
|
||||
[error]="[] | tuiFieldError | async"
|
||||
/>
|
||||
}
|
||||
<footer>
|
||||
<button
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
type="button"
|
||||
(click)="cancel()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button tuiButton [disabled]="form.invalid">
|
||||
{{ storageDrive ? 'Finish' : 'Unlock' }}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
`,
|
||||
styles: `
|
||||
footer {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
ReactiveFormsModule,
|
||||
TuiButton,
|
||||
TuiError,
|
||||
TuiAutoFocus,
|
||||
TuiFieldErrorPipe,
|
||||
TuiTextfield,
|
||||
TuiPassword,
|
||||
TuiValidator,
|
||||
TuiIcon,
|
||||
TuiMapperPipe,
|
||||
],
|
||||
providers: [
|
||||
tuiValidationErrorsProvider({
|
||||
required: 'Required',
|
||||
minlength: 'Must be 12 characters or greater',
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class PasswordComponent {
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly context =
|
||||
injectContext<TuiDialogContext<string, DialogData>>()
|
||||
|
||||
readonly storageDrive = this.context.data.storageDrive
|
||||
readonly form = new FormGroup({
|
||||
password: new FormControl('', [
|
||||
Validators.required,
|
||||
Validators.minLength(12),
|
||||
]),
|
||||
confirm: new FormControl('', this.storageDrive ? Validators.required : []),
|
||||
})
|
||||
|
||||
readonly validator = (value: any) => (control: AbstractControl) =>
|
||||
value === control.value ? null : { match: 'Passwords do not match' }
|
||||
|
||||
submit() {
|
||||
const password = this.form.controls.password.value || ''
|
||||
|
||||
if (this.storageDrive) {
|
||||
this.context.completeWith(password)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
verify(this.context.data.passwordHash || '', password)
|
||||
this.context.completeWith(password)
|
||||
} catch (e) {
|
||||
this.errorService.handleError('Incorrect password provided')
|
||||
}
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.context.$implicit.complete()
|
||||
}
|
||||
}
|
||||
|
||||
export const PASSWORD = new PolymorpheusComponent(PasswordComponent)
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Directive, ElementRef, inject, input, Output } from '@angular/core'
|
||||
import { StartOSDiskInfo } from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { filter, fromEvent, switchMap } from 'rxjs'
|
||||
import { PASSWORD } from 'src/app/components/password.component'
|
||||
|
||||
@Directive({
|
||||
selector: 'button[server][password]',
|
||||
})
|
||||
export class PasswordDirective {
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
|
||||
readonly server = input.required<StartOSDiskInfo>()
|
||||
|
||||
@Output()
|
||||
readonly password = fromEvent(inject(ElementRef).nativeElement, 'click').pipe(
|
||||
switchMap(() =>
|
||||
this.dialogs.open<string>(PASSWORD, {
|
||||
label: 'Unlock Drive',
|
||||
size: 's',
|
||||
data: { passwordHash: this.server().passwordHash },
|
||||
}),
|
||||
),
|
||||
filter(Boolean),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import { TuiButton } from '@taiga-ui/core'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
import { injectContext } from '@taiga-ui/polymorpheus'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [TuiButton, i18nPipe],
|
||||
template: `
|
||||
<p>{{ 'This drive contains existing StartOS data.' | i18n }}</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong class="g-positive">{{ 'Preserve' | i18n }}</strong>
|
||||
{{ 'to keep your data.' | i18n }}
|
||||
</li>
|
||||
<li>
|
||||
<strong class="g-negative">{{ 'Overwrite' | i18n }}</strong>
|
||||
{{ 'to discard' | i18n }}
|
||||
</li>
|
||||
</ul>
|
||||
<footer>
|
||||
<button
|
||||
tuiButton
|
||||
appearance="flat-destructive"
|
||||
(click)="context.completeWith(false)"
|
||||
>
|
||||
{{ 'Overwrite' | i18n }}
|
||||
</button>
|
||||
<button
|
||||
tuiButton
|
||||
class="preserve-btn"
|
||||
(click)="context.completeWith(true)"
|
||||
>
|
||||
{{ 'Preserve' | i18n }}
|
||||
</button>
|
||||
</footer>
|
||||
`,
|
||||
styles: `
|
||||
p {
|
||||
margin: 0 0 0.75rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
margin-top: 2rem;
|
||||
gap: 0.5rem;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.preserve-btn {
|
||||
background: var(--tui-status-positive) !important;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class PreserveOverwriteDialog {
|
||||
protected readonly context = injectContext<TuiDialogContext<boolean>>()
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { TuiCell } from '@taiga-ui/layout'
|
||||
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { RouterModule } from '@angular/router'
|
||||
|
||||
@Component({
|
||||
selector: 'app-recover',
|
||||
template: `
|
||||
<a tuiCell [routerLink]="disabled ? null : '/attach'">
|
||||
<tui-icon icon="@tui.box" />
|
||||
<span tuiTitle>
|
||||
<span class="g-positive">Use Existing Drive</span>
|
||||
<span tuiSubtitle>
|
||||
Attach an existing StartOS data drive (not a backup)
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<a tuiCell [routerLink]="disabled ? null : '/transfer'">
|
||||
<tui-icon icon="@tui.share" />
|
||||
<span tuiTitle>
|
||||
<span class="g-info">Transfer</span>
|
||||
<span tuiSubtitle>
|
||||
Transfer data from an existing StartOS data drive (not a backup) to a
|
||||
new, preferred drive
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<a tuiCell [routerLink]="disabled ? null : '/recover'">
|
||||
<tui-icon icon="@tui.save" />
|
||||
<span tuiTitle>
|
||||
<span class="g-warning">Restore From Backup (Disaster Recovery)</span>
|
||||
<span tuiSubtitle>Restore StartOS data from an encrypted backup</span>
|
||||
</span>
|
||||
</a>
|
||||
`,
|
||||
imports: [RouterModule, TuiIcon, TuiCell, TuiTitle],
|
||||
})
|
||||
export class RecoverComponent {
|
||||
@Input() disabled = false
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import { TuiDialogContext, TuiTextfield } from '@taiga-ui/core'
|
||||
import { TuiDataListWrapper, TuiSelect } from '@taiga-ui/kit'
|
||||
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import { StartOSDiskInfoWithId } from '../types'
|
||||
|
||||
interface Data {
|
||||
servers: StartOSDiskInfoWithId[]
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [FormsModule, TuiTextfield, TuiSelect, TuiDataListWrapper, i18nPipe],
|
||||
template: `
|
||||
<p>{{ 'Multiple backups found. Select which one to restore.' | i18n }}</p>
|
||||
<tui-textfield [stringify]="stringify">
|
||||
<label tuiLabel>{{ 'Backups' | i18n }}</label>
|
||||
<input tuiSelect [(ngModel)]="selectedServer" />
|
||||
<tui-data-list-wrapper
|
||||
new
|
||||
*tuiTextfieldDropdown
|
||||
[items]="context.data.servers"
|
||||
[itemContent]="serverContent"
|
||||
/>
|
||||
</tui-textfield>
|
||||
|
||||
<ng-template #serverContent let-server>
|
||||
<div class="server-item">
|
||||
<span>{{ server.id }}</span>
|
||||
<!-- @TODO eos-version? -->
|
||||
<small>{{ server['eos-version'] }}</small>
|
||||
</div>
|
||||
</ng-template>
|
||||
`,
|
||||
styles: `
|
||||
.server-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
small {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class SelectNetworkBackupDialog {
|
||||
protected readonly context =
|
||||
injectContext<TuiDialogContext<StartOSDiskInfoWithId | null, Data>>()
|
||||
|
||||
private _selectedServer: StartOSDiskInfoWithId | null = null
|
||||
|
||||
get selectedServer(): StartOSDiskInfoWithId | null {
|
||||
return this._selectedServer
|
||||
}
|
||||
|
||||
set selectedServer(value: StartOSDiskInfoWithId | null) {
|
||||
this._selectedServer = value
|
||||
|
||||
if (value) {
|
||||
this.context.completeWith(value)
|
||||
}
|
||||
}
|
||||
|
||||
readonly stringify = (server: StartOSDiskInfoWithId | null) =>
|
||||
server ? server.id : ''
|
||||
}
|
||||
|
||||
export const SELECT_NETWORK_BACKUP = new PolymorpheusComponent(
|
||||
SelectNetworkBackupDialog,
|
||||
)
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { ServerComponent } from '@start9labs/shared'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import { PasswordDirective } from 'src/app/components/password.directive'
|
||||
import { StartOSDiskInfoWithId } from 'src/app/services/api.service'
|
||||
|
||||
interface Data {
|
||||
servers: StartOSDiskInfoWithId[]
|
||||
}
|
||||
|
||||
export interface ServersResponse {
|
||||
password: string
|
||||
serverId: string
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
@for (server of context.data.servers; track $index) {
|
||||
<button [server]="server" (password)="select($event, server.id)"></button>
|
||||
}
|
||||
`,
|
||||
imports: [ServerComponent, PasswordDirective],
|
||||
})
|
||||
export class ServersComponent {
|
||||
readonly context = injectContext<TuiDialogContext<ServersResponse, Data>>()
|
||||
|
||||
select(password: string, serverId: string) {
|
||||
this.context.completeWith({ serverId, password })
|
||||
}
|
||||
}
|
||||
|
||||
export const SERVERS = new PolymorpheusComponent(ServersComponent)
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDialogContext,
|
||||
TuiIcon,
|
||||
TuiTextfield,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiPassword } from '@taiga-ui/kit'
|
||||
import { injectContext } from '@taiga-ui/polymorpheus'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [
|
||||
FormsModule,
|
||||
TuiButton,
|
||||
TuiTextfield,
|
||||
TuiPassword,
|
||||
TuiIcon,
|
||||
i18nPipe,
|
||||
],
|
||||
template: `
|
||||
<p>
|
||||
{{ 'Enter the password that was used to encrypt this backup.' | i18n }}
|
||||
</p>
|
||||
<tui-textfield>
|
||||
<label tuiLabel>{{ 'Password' | i18n }}</label>
|
||||
<input
|
||||
tuiTextfield
|
||||
type="password"
|
||||
[(ngModel)]="password"
|
||||
(keyup.enter)="unlock()"
|
||||
/>
|
||||
<tui-icon tuiPassword />
|
||||
</tui-textfield>
|
||||
<footer>
|
||||
<button tuiButton appearance="flat" (click)="context.completeWith(null)">
|
||||
{{ 'Cancel' | i18n }}
|
||||
</button>
|
||||
<button tuiButton [disabled]="!password" (click)="unlock()">
|
||||
{{ 'Unlock' | i18n }}
|
||||
</button>
|
||||
</footer>
|
||||
`,
|
||||
styles: `
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
`,
|
||||
})
|
||||
export class UnlockPasswordDialog {
|
||||
protected readonly context = injectContext<TuiDialogContext<string | null>>()
|
||||
|
||||
password = ''
|
||||
|
||||
unlock() {
|
||||
if (this.password) {
|
||||
this.context.completeWith(this.password)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import {
|
||||
DiskInfo,
|
||||
DriveComponent,
|
||||
ErrorService,
|
||||
i18nKey,
|
||||
LoadingService,
|
||||
toGuid,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiButton, TuiDialogService, TuiLoader } from '@taiga-ui/core'
|
||||
import { TuiCardLarge, TuiCell } from '@taiga-ui/layout'
|
||||
import { PASSWORD } from 'src/app/components/password.component'
|
||||
import { ApiService } from 'src/app/services/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<section tuiCardLarge="compact">
|
||||
<header>Use existing drive</header>
|
||||
<div>Select the physical drive containing your StartOS data</div>
|
||||
|
||||
@if (loading) {
|
||||
<tui-loader />
|
||||
} @else {
|
||||
@for (drive of drives; track drive) {
|
||||
<button tuiCell [drive]="drive" (click)="select(drive)"></button>
|
||||
} @empty {
|
||||
No valid StartOS data drives found. Please make sure the drive is a
|
||||
valid StartOS data drive (not a backup) and is firmly connected, then
|
||||
refresh the page.
|
||||
}
|
||||
<footer>
|
||||
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
|
||||
Refresh
|
||||
</button>
|
||||
</footer>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
imports: [TuiButton, TuiCardLarge, TuiCell, TuiLoader, DriveComponent],
|
||||
})
|
||||
export default class AttachPage {
|
||||
private readonly apiService = inject(ApiService)
|
||||
private readonly router = inject(Router)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly stateService = inject(StateService)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
|
||||
loading = true
|
||||
drives: DiskInfo[] = []
|
||||
|
||||
async ngOnInit() {
|
||||
this.stateService.setupType = 'attach'
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.loading = true
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
async getDrives() {
|
||||
try {
|
||||
this.drives = await this.apiService
|
||||
.getDrives()
|
||||
.then(drives => drives.filter(toGuid))
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
select(disk: DiskInfo) {
|
||||
this.dialogs
|
||||
.open<string>(PASSWORD, {
|
||||
label: 'Set Password',
|
||||
size: 's',
|
||||
data: { storageDrive: true },
|
||||
})
|
||||
.subscribe(password => {
|
||||
this.attachDrive(toGuid(disk) || '', password)
|
||||
})
|
||||
}
|
||||
|
||||
private async attachDrive(guid: string, password: string) {
|
||||
const loader = this.loader
|
||||
.open('Connecting to drive' as i18nKey)
|
||||
.subscribe()
|
||||
|
||||
try {
|
||||
await this.stateService.importDrive(guid, password)
|
||||
await this.router.navigate([`loading`])
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
395
web/projects/setup-wizard/src/app/pages/drives.page.ts
Normal file
395
web/projects/setup-wizard/src/app/pages/drives.page.ts
Normal file
@@ -0,0 +1,395 @@
|
||||
import { ChangeDetectorRef, Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import {
|
||||
DialogService,
|
||||
DiskInfo,
|
||||
ErrorService,
|
||||
i18nKey,
|
||||
i18nPipe,
|
||||
LoadingService,
|
||||
toGuid,
|
||||
} from '@start9labs/shared'
|
||||
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiIcon,
|
||||
TuiLoader,
|
||||
TuiTextfield,
|
||||
TuiTitle,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiDataListWrapper, TuiSelect, TuiTooltip } from '@taiga-ui/kit'
|
||||
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
|
||||
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import { filter } from 'rxjs'
|
||||
import { ApiService } from '../services/api.service'
|
||||
import { StateService } from '../services/state.service'
|
||||
import { PreserveOverwriteDialog } from '../components/preserve-overwrite.dialog'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<section tuiCardLarge="compact">
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>{{ 'Select Drives' | i18n }}</h2>
|
||||
</header>
|
||||
|
||||
@if (loading) {
|
||||
<tui-loader />
|
||||
} @else if (drives.length === 0) {
|
||||
<p class="no-drives">
|
||||
{{
|
||||
'No drives found. Please connect a drive and click Refresh.' | i18n
|
||||
}}
|
||||
</p>
|
||||
} @else {
|
||||
<tui-textfield [stringify]="stringify">
|
||||
<label tuiLabel>{{ 'OS Drive' | i18n }}</label>
|
||||
@if (mobile) {
|
||||
<select
|
||||
tuiSelect
|
||||
[(ngModel)]="selectedOsDrive"
|
||||
[items]="drives"
|
||||
></select>
|
||||
} @else {
|
||||
<input tuiSelect [(ngModel)]="selectedOsDrive" />
|
||||
}
|
||||
@if (!mobile) {
|
||||
<tui-data-list-wrapper
|
||||
new
|
||||
*tuiTextfieldDropdown
|
||||
[items]="drives"
|
||||
[itemContent]="driveContent"
|
||||
/>
|
||||
}
|
||||
<tui-icon [tuiTooltip]="osDriveTooltip" />
|
||||
</tui-textfield>
|
||||
|
||||
<tui-textfield [stringify]="stringify">
|
||||
<label tuiLabel>{{ 'Data Drive' | i18n }}</label>
|
||||
@if (mobile) {
|
||||
<select
|
||||
tuiSelect
|
||||
[(ngModel)]="selectedDataDrive"
|
||||
(ngModelChange)="onDataDriveChange($event)"
|
||||
[items]="drives"
|
||||
></select>
|
||||
} @else {
|
||||
<input
|
||||
tuiSelect
|
||||
[(ngModel)]="selectedDataDrive"
|
||||
(ngModelChange)="onDataDriveChange($event)"
|
||||
/>
|
||||
}
|
||||
@if (!mobile) {
|
||||
<tui-data-list-wrapper
|
||||
new
|
||||
*tuiTextfieldDropdown
|
||||
[items]="drives"
|
||||
[itemContent]="driveContent"
|
||||
/>
|
||||
}
|
||||
@if (preserveData === true) {
|
||||
<tui-icon
|
||||
icon="@tui.database"
|
||||
style="color: var(--tui-status-positive); pointer-events: none"
|
||||
/>
|
||||
}
|
||||
@if (preserveData === false) {
|
||||
<tui-icon
|
||||
icon="@tui.database-zap"
|
||||
style="color: var(--tui-status-negative); pointer-events: none"
|
||||
/>
|
||||
}
|
||||
<tui-icon [tuiTooltip]="dataDriveTooltip" />
|
||||
</tui-textfield>
|
||||
|
||||
<ng-template #driveContent let-drive>
|
||||
<div class="drive-item">
|
||||
<span class="drive-name">
|
||||
{{ drive.vendor || ('Unknown' | i18n) }}
|
||||
{{ drive.model || ('Drive' | i18n) }}
|
||||
</span>
|
||||
<small>
|
||||
{{ formatCapacity(drive.capacity) }} · {{ drive.logicalname }}
|
||||
</small>
|
||||
</div>
|
||||
</ng-template>
|
||||
}
|
||||
|
||||
<footer>
|
||||
@if (drives.length === 0) {
|
||||
<button tuiButton appearance="secondary" (click)="refresh()">
|
||||
{{ 'Refresh' | i18n }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
tuiButton
|
||||
[disabled]="!selectedOsDrive || !selectedDataDrive"
|
||||
(click)="continue()"
|
||||
>
|
||||
{{ 'Continue' | i18n }}
|
||||
</button>
|
||||
}
|
||||
</footer>
|
||||
</section>
|
||||
`,
|
||||
styles: `
|
||||
.no-drives {
|
||||
text-align: center;
|
||||
color: var(--tui-text-secondary);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.drive-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
small {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
FormsModule,
|
||||
TuiCardLarge,
|
||||
TuiButton,
|
||||
TuiIcon,
|
||||
TuiLoader,
|
||||
TuiTextfield,
|
||||
TuiSelect,
|
||||
TuiDataListWrapper,
|
||||
TuiTooltip,
|
||||
TuiHeader,
|
||||
TuiTitle,
|
||||
i18nPipe,
|
||||
],
|
||||
})
|
||||
export default class DrivesPage {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly router = inject(Router)
|
||||
private readonly dialogs = inject(DialogService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly stateService = inject(StateService)
|
||||
private readonly cdr = inject(ChangeDetectorRef)
|
||||
private readonly i18n = inject(i18nPipe)
|
||||
|
||||
protected readonly mobile = inject(TUI_IS_MOBILE)
|
||||
|
||||
readonly osDriveTooltip = this.i18n.transform(
|
||||
'The drive where the StartOS operating system will be installed.',
|
||||
)
|
||||
readonly dataDriveTooltip = this.i18n.transform(
|
||||
'The drive where your StartOS data (services, settings, etc.) will be stored. This can be the same as the OS drive or a separate drive.',
|
||||
)
|
||||
|
||||
drives: DiskInfo[] = []
|
||||
loading = true
|
||||
selectedOsDrive: DiskInfo | null = null
|
||||
selectedDataDrive: DiskInfo | null = null
|
||||
preserveData: boolean | null = null
|
||||
|
||||
readonly stringify = (drive: DiskInfo | null) =>
|
||||
drive
|
||||
? `${drive.vendor || this.i18n.transform('Unknown')} ${drive.model || this.i18n.transform('Drive')}`
|
||||
: ''
|
||||
|
||||
formatCapacity(bytes: number): string {
|
||||
const gb = bytes / 1e9
|
||||
if (gb >= 1000) {
|
||||
return `${(gb / 1000).toFixed(1)} TB`
|
||||
}
|
||||
return `${gb.toFixed(0)} GB`
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.loadDrives()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.loading = true
|
||||
this.selectedOsDrive = null
|
||||
this.selectedDataDrive = null
|
||||
this.preserveData = null
|
||||
await this.loadDrives()
|
||||
}
|
||||
|
||||
onDataDriveChange(drive: DiskInfo | null) {
|
||||
this.preserveData = null
|
||||
|
||||
if (!drive) {
|
||||
return
|
||||
}
|
||||
|
||||
const hasStartOSData = !!toGuid(drive)
|
||||
if (hasStartOSData) {
|
||||
this.showPreserveOverwriteDialog()
|
||||
}
|
||||
}
|
||||
|
||||
continue() {
|
||||
if (!this.selectedOsDrive || !this.selectedDataDrive) return
|
||||
|
||||
const sameDevice =
|
||||
this.selectedOsDrive.logicalname === this.selectedDataDrive.logicalname
|
||||
const dataHasStartOS = !!toGuid(this.selectedDataDrive)
|
||||
|
||||
// Scenario 1: Same drive, has StartOS data, preserving → no warning
|
||||
if (sameDevice && dataHasStartOS && this.preserveData) {
|
||||
this.installOs(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Scenario 2: Different drives, preserving data → warn OS only
|
||||
if (!sameDevice && this.preserveData) {
|
||||
this.showOsDriveWarning()
|
||||
return
|
||||
}
|
||||
|
||||
// Scenario 3: All other cases → warn about overwriting
|
||||
this.showFullWarning(sameDevice)
|
||||
}
|
||||
|
||||
private showPreserveOverwriteDialog() {
|
||||
let selectionMade = false
|
||||
|
||||
this.dialogs
|
||||
.openComponent<boolean>(
|
||||
new PolymorpheusComponent(PreserveOverwriteDialog),
|
||||
{
|
||||
label: 'StartOS Data Detected',
|
||||
size: 's',
|
||||
dismissible: true,
|
||||
closeable: true,
|
||||
},
|
||||
)
|
||||
.subscribe({
|
||||
next: preserve => {
|
||||
selectionMade = true
|
||||
this.preserveData = preserve
|
||||
this.cdr.markForCheck()
|
||||
},
|
||||
complete: () => {
|
||||
if (!selectionMade) {
|
||||
// Dialog was dismissed without selection - clear the data drive
|
||||
this.selectedDataDrive = null
|
||||
this.preserveData = null
|
||||
this.cdr.markForCheck()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private showOsDriveWarning() {
|
||||
this.dialogs
|
||||
.openConfirm({
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content: `<ul>
|
||||
<li class="g-negative">${this.i18n.transform('Data on the OS drive may be overwritten.')}</li>
|
||||
<li class="g-positive">${this.i18n.transform('your StartOS data on the data drive will be preserved.')}</li>
|
||||
</ul>` as i18nKey,
|
||||
yes: 'Continue',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => {
|
||||
this.installOs(false)
|
||||
})
|
||||
}
|
||||
|
||||
private showFullWarning(sameDevice: boolean) {
|
||||
const message = sameDevice
|
||||
? `<p class="g-negative">${this.i18n.transform('Data on this drive will be overwritten.')}</p>`
|
||||
: `<p class="g-negative">${this.i18n.transform('Data on both drives will be overwritten.')}</p>`
|
||||
|
||||
this.dialogs
|
||||
.openConfirm({
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content: message as i18nKey,
|
||||
yes: 'Continue',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => {
|
||||
this.installOs(true)
|
||||
})
|
||||
}
|
||||
|
||||
private async installOs(wipe: boolean) {
|
||||
const loader = this.loader.open('Installing StartOS').subscribe()
|
||||
|
||||
try {
|
||||
const result = await this.api.installOs({
|
||||
osDrive: this.selectedOsDrive!.logicalname,
|
||||
dataDrive: {
|
||||
logicalname: this.selectedDataDrive!.logicalname,
|
||||
wipe,
|
||||
},
|
||||
})
|
||||
|
||||
this.stateService.dataDriveGuid = result.guid
|
||||
this.stateService.attach = result.attach
|
||||
|
||||
loader.unsubscribe()
|
||||
|
||||
// Show success dialog
|
||||
this.dialogs
|
||||
.openConfirm({
|
||||
label: 'Installation Complete!',
|
||||
size: 's',
|
||||
data: {
|
||||
content: 'StartOS has been installed successfully.',
|
||||
yes: 'Continue to Setup',
|
||||
no: 'Shutdown',
|
||||
},
|
||||
})
|
||||
.subscribe(continueSetup => {
|
||||
if (continueSetup) {
|
||||
this.navigateToNextStep(result.attach)
|
||||
} else {
|
||||
this.shutdownServer()
|
||||
}
|
||||
})
|
||||
} catch (e: any) {
|
||||
loader.unsubscribe()
|
||||
this.errorService.handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
private async navigateToNextStep(attach: boolean) {
|
||||
if (attach) {
|
||||
this.stateService.setupType = 'attach'
|
||||
await this.router.navigate(['/password'])
|
||||
} else {
|
||||
await this.router.navigate(['/home'])
|
||||
}
|
||||
}
|
||||
|
||||
private async shutdownServer() {
|
||||
const loader = this.loader.open('Beginning shutdown').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.shutdown()
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async loadDrives() {
|
||||
try {
|
||||
this.drives = await this.api.getDisks()
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,134 +1,74 @@
|
||||
import { Component, inject, OnInit } from '@angular/core'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { TuiButton, TuiIcon, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiCardLarge, TuiCell } from '@taiga-ui/layout'
|
||||
import { RecoverComponent } from 'src/app/components/recover.component'
|
||||
import { ApiService } from 'src/app/services/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { i18nPipe } from '@start9labs/shared'
|
||||
import { TuiAppearance, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiAvatar } from '@taiga-ui/kit'
|
||||
import { TuiCardLarge, TuiCell, TuiHeader } from '@taiga-ui/layout'
|
||||
import { StateService } from '../services/state.service'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<img class="logo" src="assets/img/icon.png" alt="Start9" />
|
||||
@if (!loading) {
|
||||
<section tuiCardLarge="compact">
|
||||
<header [style.padding-top.rem]="1.25">
|
||||
@if (recover) {
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="flat-grayscale"
|
||||
class="back"
|
||||
iconStart="@tui.chevron-left"
|
||||
(click)="recover = false"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
}
|
||||
{{ recover ? 'Recover Options' : 'StartOS Setup' }}
|
||||
</header>
|
||||
<div class="pages">
|
||||
<div class="options" [class.options_recover]="recover">
|
||||
<button tuiCell [routerLink]="error || recover ? null : '/storage'">
|
||||
<tui-icon icon="@tui.plus" />
|
||||
<span tuiTitle>
|
||||
<span class="g-positive">Start Fresh</span>
|
||||
<span tuiSubtitle>
|
||||
Get started with a brand new Start9 server
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
tuiCell
|
||||
[disabled]="error || recover"
|
||||
(click)="recover = true"
|
||||
>
|
||||
<tui-icon icon="@tui.rotate-cw" />
|
||||
<span tuiTitle>
|
||||
<span class="g-warning">Recover</span>
|
||||
<span tuiSubtitle>
|
||||
Recover, restore, or transfer StartOS data
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<app-recover class="options" [disabled]="!recover" />
|
||||
<div tuiCardLarge="compact">
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>{{ 'Select Setup Flow' | i18n }}</h2>
|
||||
</header>
|
||||
|
||||
<button tuiCell="l" (click)="startFresh()">
|
||||
<tui-avatar appearance="positive" src="@tui.plus" />
|
||||
<div tuiTitle>
|
||||
{{ 'Start Fresh' | i18n }}
|
||||
<div tuiSubtitle>{{ 'Set up a brand new server' | i18n }}</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
@use '@taiga-ui/core/styles/taiga-ui-local' as taiga;
|
||||
</button>
|
||||
|
||||
.logo {
|
||||
width: 6rem;
|
||||
margin: auto auto -2rem;
|
||||
z-index: 1;
|
||||
<button tuiCell="l" (click)="restore()">
|
||||
<tui-avatar appearance="warning" src="@tui.archive-restore" />
|
||||
<div tuiTitle>
|
||||
{{ 'Restore from Backup' | i18n }}
|
||||
<div tuiSubtitle>
|
||||
{{ 'Restore StartOS data from an encrypted backup' | i18n }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
&:only-child {
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
+ * {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.back {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
border-radius: 10rem;
|
||||
}
|
||||
|
||||
.pages {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.options {
|
||||
@include taiga.transition(margin);
|
||||
|
||||
min-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
|
||||
&_recover {
|
||||
margin-left: -100%;
|
||||
}
|
||||
}
|
||||
<button tuiCell="l" (click)="transfer()">
|
||||
<tui-avatar appearance="info" src="@tui.hard-drive-download" />
|
||||
<div tuiTitle>
|
||||
{{ 'Transfer' | i18n }}
|
||||
<div tuiSubtitle>
|
||||
{{ 'Transfer data from an existing StartOS data drive' | i18n }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
`,
|
||||
imports: [
|
||||
RouterModule,
|
||||
TuiAppearance,
|
||||
TuiCardLarge,
|
||||
TuiButton,
|
||||
TuiHeader,
|
||||
TuiCell,
|
||||
TuiIcon,
|
||||
TuiTitle,
|
||||
RecoverComponent,
|
||||
TuiAvatar,
|
||||
i18nPipe,
|
||||
],
|
||||
})
|
||||
export default class HomePage implements OnInit {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
export default class HomePage {
|
||||
private readonly router = inject(Router)
|
||||
private readonly stateService = inject(StateService)
|
||||
|
||||
error = false
|
||||
loading = true
|
||||
recover = false
|
||||
|
||||
async ngOnInit() {
|
||||
async startFresh() {
|
||||
this.stateService.setupType = 'fresh'
|
||||
this.stateService.recoverySource = undefined
|
||||
await this.router.navigate(['/password'])
|
||||
}
|
||||
|
||||
try {
|
||||
await this.api.getPubKey()
|
||||
} catch (e: any) {
|
||||
this.error = true
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
async restore() {
|
||||
this.stateService.setupType = 'restore'
|
||||
await this.router.navigate(['/restore'])
|
||||
}
|
||||
|
||||
async transfer() {
|
||||
this.stateService.setupType = 'transfer'
|
||||
await this.router.navigate(['/transfer'])
|
||||
}
|
||||
}
|
||||
|
||||
124
web/projects/setup-wizard/src/app/pages/keyboard.page.ts
Normal file
124
web/projects/setup-wizard/src/app/pages/keyboard.page.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { Component, inject, signal } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import {
|
||||
getAllKeyboardsSorted,
|
||||
i18nPipe,
|
||||
Keyboard,
|
||||
LanguageCode,
|
||||
} from '@start9labs/shared'
|
||||
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
|
||||
import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core'
|
||||
import {
|
||||
TuiButtonLoading,
|
||||
TuiChevron,
|
||||
TuiDataListWrapper,
|
||||
TuiSelect,
|
||||
} from '@taiga-ui/kit'
|
||||
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
|
||||
import { ApiService } from '../services/api.service'
|
||||
import { StateService } from '../services/state.service'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<section tuiCardLarge="compact">
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>{{ 'Select Keyboard Layout' | i18n }}</h2>
|
||||
</header>
|
||||
<tui-textfield
|
||||
tuiChevron
|
||||
[stringify]="stringify"
|
||||
[tuiTextfieldCleaner]="false"
|
||||
>
|
||||
<label tuiLabel>{{ 'Keyboard' | i18n }}</label>
|
||||
@if (mobile) {
|
||||
<select tuiSelect [(ngModel)]="selected" [items]="keyboards"></select>
|
||||
} @else {
|
||||
<input tuiSelect [(ngModel)]="selected" />
|
||||
}
|
||||
@if (!mobile) {
|
||||
<tui-data-list-wrapper
|
||||
new
|
||||
*tuiTextfieldDropdown
|
||||
[items]="keyboards"
|
||||
/>
|
||||
}
|
||||
</tui-textfield>
|
||||
|
||||
<footer>
|
||||
<button
|
||||
tuiButton
|
||||
[disabled]="!selected"
|
||||
[loading]="saving()"
|
||||
(click)="continue()"
|
||||
>
|
||||
{{ 'Continue' | i18n }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
`,
|
||||
imports: [
|
||||
FormsModule,
|
||||
TuiCardLarge,
|
||||
TuiButton,
|
||||
TuiButtonLoading,
|
||||
TuiTextfield,
|
||||
TuiChevron,
|
||||
TuiSelect,
|
||||
TuiDataListWrapper,
|
||||
TuiHeader,
|
||||
TuiTitle,
|
||||
i18nPipe,
|
||||
],
|
||||
})
|
||||
export default class KeyboardPage {
|
||||
private readonly router = inject(Router)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly stateService = inject(StateService)
|
||||
|
||||
protected readonly mobile = inject(TUI_IS_MOBILE)
|
||||
// All keyboards, with language-specific keyboards at the top
|
||||
readonly keyboards = getAllKeyboardsSorted(
|
||||
this.stateService.language as LanguageCode,
|
||||
)
|
||||
selected =
|
||||
this.keyboards.find(k => k.layout === this.stateService.keyboard) ||
|
||||
this.keyboards[0]!
|
||||
|
||||
readonly saving = signal(false)
|
||||
|
||||
readonly stringify = (kb: Keyboard) => kb.name
|
||||
|
||||
async continue() {
|
||||
this.saving.set(true)
|
||||
|
||||
try {
|
||||
// Send keyboard to backend
|
||||
await this.api.setKeyboard({
|
||||
layout: this.selected.layout,
|
||||
keymap: this.selected.keymap,
|
||||
model: null,
|
||||
variant: null,
|
||||
options: [],
|
||||
})
|
||||
|
||||
this.stateService.keyboard = this.selected.layout
|
||||
await this.navigateToNextStep()
|
||||
} finally {
|
||||
this.saving.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
private async navigateToNextStep() {
|
||||
if (this.stateService.dataDriveGuid) {
|
||||
if (this.stateService.attach) {
|
||||
this.stateService.setupType = 'attach'
|
||||
await this.router.navigate(['/password'])
|
||||
} else {
|
||||
await this.router.navigate(['/home'])
|
||||
}
|
||||
} else {
|
||||
await this.router.navigate(['/drives'])
|
||||
}
|
||||
}
|
||||
}
|
||||
151
web/projects/setup-wizard/src/app/pages/language.page.ts
Normal file
151
web/projects/setup-wizard/src/app/pages/language.page.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import { Component, computed, inject, signal } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { i18nPipe, i18nService, Language, LANGUAGES } from '@start9labs/shared'
|
||||
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
|
||||
import { TuiButton, TuiTextfield, TuiTitle } from '@taiga-ui/core'
|
||||
import {
|
||||
TuiButtonLoading,
|
||||
TuiChevron,
|
||||
TuiDataListWrapper,
|
||||
TuiSelect,
|
||||
} from '@taiga-ui/kit'
|
||||
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
|
||||
import { ApiService } from '../services/api.service'
|
||||
import { StateService } from '../services/state.service'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<section tuiCardLarge="compact">
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>
|
||||
<span class="inline-title">
|
||||
<img src="assets/img/icon.png" alt="Start9" />
|
||||
{{ 'Welcome to' | i18n }} StartOS
|
||||
</span>
|
||||
<span tuiSubtitle>{{ 'Select your language' | i18n }}</span>
|
||||
</h2>
|
||||
</header>
|
||||
<tui-textfield
|
||||
tuiChevron
|
||||
[stringify]="stringify"
|
||||
[tuiTextfieldCleaner]="false"
|
||||
>
|
||||
<label tuiLabel>{{ 'Language' | i18n }}</label>
|
||||
@if (mobile) {
|
||||
<select
|
||||
tuiSelect
|
||||
[(ngModel)]="selected"
|
||||
[items]="languages"
|
||||
(ngModelChange)="onLanguageChange($event)"
|
||||
></select>
|
||||
} @else {
|
||||
<input
|
||||
tuiSelect
|
||||
[(ngModel)]="selected"
|
||||
(ngModelChange)="onLanguageChange($event)"
|
||||
/>
|
||||
}
|
||||
@if (!mobile) {
|
||||
<tui-data-list-wrapper
|
||||
*tuiTextfieldDropdown
|
||||
new
|
||||
[items]="languages"
|
||||
[itemContent]="itemContent"
|
||||
/>
|
||||
}
|
||||
</tui-textfield>
|
||||
|
||||
<ng-template #itemContent let-item>
|
||||
@let lang = asLanguage(item);
|
||||
<div class="language-item">
|
||||
<span>{{ lang.nativeName }}</span>
|
||||
<small>{{ lang.name | i18n }}</small>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<footer>
|
||||
<button
|
||||
tuiButton
|
||||
[disabled]="!selected"
|
||||
[loading]="loading()"
|
||||
(click)="continue()"
|
||||
>
|
||||
{{ 'Continue' | i18n }}
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
`,
|
||||
styles: `
|
||||
.language-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
small {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
FormsModule,
|
||||
TuiCardLarge,
|
||||
TuiButton,
|
||||
TuiButtonLoading,
|
||||
TuiTextfield,
|
||||
TuiChevron,
|
||||
TuiSelect,
|
||||
TuiDataListWrapper,
|
||||
TuiHeader,
|
||||
TuiTitle,
|
||||
i18nPipe,
|
||||
],
|
||||
})
|
||||
export default class LanguagePage {
|
||||
private readonly router = inject(Router)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly stateService = inject(StateService)
|
||||
private readonly i18nService = inject(i18nService)
|
||||
|
||||
protected readonly mobile = inject(TUI_IS_MOBILE)
|
||||
readonly languages = LANGUAGES
|
||||
|
||||
selected =
|
||||
LANGUAGES.find(l => l.code === this.stateService.language) || LANGUAGES[0]
|
||||
|
||||
private readonly saving = signal(false)
|
||||
|
||||
// Show loading when either language is loading or saving is in progress
|
||||
readonly loading = computed(() => this.i18nService.loading() || this.saving())
|
||||
|
||||
readonly stringify = (lang: Language) => lang.nativeName
|
||||
readonly asLanguage = (item: unknown): Language => item as Language
|
||||
|
||||
constructor() {
|
||||
if (this.selected) {
|
||||
this.i18nService.setLang(this.selected.name)
|
||||
}
|
||||
}
|
||||
|
||||
onLanguageChange(language: Language) {
|
||||
if (language) {
|
||||
this.i18nService.setLang(language.name)
|
||||
}
|
||||
}
|
||||
|
||||
async continue() {
|
||||
if (this.selected) {
|
||||
this.stateService.language = this.selected.code
|
||||
|
||||
// Save language to backend
|
||||
this.saving.set(true)
|
||||
|
||||
try {
|
||||
await this.api.setLanguage({ language: this.selected.name })
|
||||
// Always go to keyboard selection
|
||||
await this.router.navigate(['/keyboard'])
|
||||
} finally {
|
||||
this.saving.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
DialogService,
|
||||
formatProgress,
|
||||
getErrorMessage,
|
||||
i18nKey,
|
||||
i18nPipe,
|
||||
InitializingComponent,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
@@ -26,17 +26,17 @@ import {
|
||||
tap,
|
||||
timer,
|
||||
} from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { ApiService } from '../services/api.service'
|
||||
import { StateService } from '../services/state.service'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
@if (error(); as err) {
|
||||
<section>
|
||||
<h1>{{ 'Error initializing server' }}</h1>
|
||||
<h1>{{ 'Error initializing server' | i18n }}</h1>
|
||||
<p>{{ err }}</p>
|
||||
<button tuiButton (click)="restart()">
|
||||
{{ 'Restart server' }}
|
||||
{{ 'Restart server' | i18n }}
|
||||
</button>
|
||||
</section>
|
||||
} @else {
|
||||
@@ -54,22 +54,21 @@ import { StateService } from 'src/app/services/state.service'
|
||||
padding: 1rem;
|
||||
margin: 1.5rem;
|
||||
text-align: center;
|
||||
// @TODO Theme
|
||||
background: #e0e0e0;
|
||||
color: #333;
|
||||
--tui-background-neutral-1: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
`,
|
||||
imports: [InitializingComponent, TuiButton],
|
||||
imports: [InitializingComponent, TuiButton, i18nPipe],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export default class LoadingPage {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly dialog = inject(DialogService)
|
||||
private readonly router = inject(Router)
|
||||
|
||||
readonly type = inject(StateService).setupType
|
||||
readonly router = inject(Router)
|
||||
readonly progress = toSignal(
|
||||
from(this.getStatus()).pipe(
|
||||
filter(Boolean),
|
||||
@@ -99,12 +98,13 @@ export default class LoadingPage {
|
||||
try {
|
||||
const res = await this.api.getStatus()
|
||||
|
||||
if (!res) {
|
||||
this.router.navigate(['home'])
|
||||
} else if (res.status === 'complete') {
|
||||
this.router.navigate(['success'])
|
||||
} else {
|
||||
if (res.status === 'running') {
|
||||
return res
|
||||
} else if (res.status === 'complete') {
|
||||
this.router.navigate(['/success'])
|
||||
} else {
|
||||
// incomplete or needs-install - shouldn't happen on loading page
|
||||
this.router.navigate(['/language'])
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.error.set(getErrorMessage(e))
|
||||
@@ -119,7 +119,7 @@ export default class LoadingPage {
|
||||
try {
|
||||
await this.api.restart()
|
||||
this.dialog
|
||||
.openAlert('Wait 1-2 minutes and refresh the page' as i18nKey, {
|
||||
.openAlert('Wait 1-2 minutes and refresh the page', {
|
||||
label: 'Server is restarting',
|
||||
})
|
||||
.subscribe()
|
||||
|
||||
196
web/projects/setup-wizard/src/app/pages/password.page.ts
Normal file
196
web/projects/setup-wizard/src/app/pages/password.page.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import { AsyncPipe } from '@angular/common'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import {
|
||||
AbstractControl,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms'
|
||||
import { ErrorService, i18nPipe, LoadingService } from '@start9labs/shared'
|
||||
import { TuiAutoFocus, TuiMapperPipe, TuiValidator } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiError,
|
||||
TuiIcon,
|
||||
TuiTextfield,
|
||||
TuiTitle,
|
||||
} from '@taiga-ui/core'
|
||||
import {
|
||||
TuiFieldErrorPipe,
|
||||
TuiPassword,
|
||||
tuiValidationErrorsProvider,
|
||||
} from '@taiga-ui/kit'
|
||||
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
|
||||
import { StateService } from '../services/state.service'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<section tuiCardLarge="compact">
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>
|
||||
{{
|
||||
isRequired
|
||||
? ('Set Master Password' | i18n)
|
||||
: ('Set New Password (Optional)' | i18n)
|
||||
}}
|
||||
<span tuiSubtitle>
|
||||
{{
|
||||
isRequired
|
||||
? ('Make it good. Write it down.' | i18n)
|
||||
: ('Skip to keep your existing password.' | i18n)
|
||||
}}
|
||||
</span>
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
<tui-textfield>
|
||||
<label tuiLabel>
|
||||
{{
|
||||
isRequired ? ('Enter Password' | i18n) : ('New Password' | i18n)
|
||||
}}
|
||||
</label>
|
||||
<input
|
||||
tuiTextfield
|
||||
type="password"
|
||||
tuiAutoFocus
|
||||
maxlength="64"
|
||||
formControlName="password"
|
||||
/>
|
||||
<tui-icon tuiPassword />
|
||||
</tui-textfield>
|
||||
<tui-error
|
||||
formControlName="password"
|
||||
[error]="[] | tuiFieldError | async"
|
||||
/>
|
||||
|
||||
<tui-textfield [style.margin-top.rem]="1">
|
||||
<label tuiLabel>{{ 'Confirm Password' | i18n }}</label>
|
||||
<input
|
||||
tuiTextfield
|
||||
type="password"
|
||||
formControlName="confirm"
|
||||
[tuiValidator]="
|
||||
form.controls.password.value || '' | tuiMapper: validator
|
||||
"
|
||||
/>
|
||||
<tui-icon tuiPassword />
|
||||
</tui-textfield>
|
||||
<tui-error
|
||||
formControlName="confirm"
|
||||
[error]="[] | tuiFieldError | async"
|
||||
/>
|
||||
|
||||
<footer>
|
||||
<button
|
||||
tuiButton
|
||||
[disabled]="
|
||||
isRequired
|
||||
? form.invalid
|
||||
: form.controls.password.value && form.invalid
|
||||
"
|
||||
>
|
||||
{{ 'Finish' | i18n }}
|
||||
</button>
|
||||
@if (!isRequired) {
|
||||
<button
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
type="button"
|
||||
(click)="skip()"
|
||||
>
|
||||
{{ 'Skip' | i18n }}
|
||||
</button>
|
||||
}
|
||||
</footer>
|
||||
</form>
|
||||
</section>
|
||||
`,
|
||||
styles: `
|
||||
footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
ReactiveFormsModule,
|
||||
TuiCardLarge,
|
||||
TuiButton,
|
||||
TuiError,
|
||||
TuiAutoFocus,
|
||||
TuiFieldErrorPipe,
|
||||
TuiTextfield,
|
||||
TuiPassword,
|
||||
TuiValidator,
|
||||
TuiIcon,
|
||||
TuiMapperPipe,
|
||||
TuiHeader,
|
||||
TuiTitle,
|
||||
i18nPipe,
|
||||
],
|
||||
providers: [
|
||||
tuiValidationErrorsProvider({
|
||||
required: 'Required',
|
||||
minlength: 'Must be 12 characters or greater',
|
||||
maxlength: 'Must be 64 character or less',
|
||||
match: 'Passwords do not match',
|
||||
}),
|
||||
],
|
||||
})
|
||||
export default class PasswordPage {
|
||||
private readonly router = inject(Router)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly stateService = inject(StateService)
|
||||
private readonly i18n = inject(i18nPipe)
|
||||
|
||||
// Password is required only for fresh install
|
||||
readonly isRequired = this.stateService.setupType === 'fresh'
|
||||
|
||||
readonly form = new FormGroup({
|
||||
password: new FormControl('', [
|
||||
...(this.isRequired ? [Validators.required] : []),
|
||||
Validators.minLength(12),
|
||||
Validators.maxLength(64),
|
||||
]),
|
||||
confirm: new FormControl(''),
|
||||
})
|
||||
|
||||
readonly validator = (value: string) => (control: AbstractControl) =>
|
||||
value === control.value
|
||||
? null
|
||||
: { match: this.i18n.transform('Passwords do not match') }
|
||||
|
||||
async skip() {
|
||||
// Skip means no new password - pass null
|
||||
await this.executeSetup(null)
|
||||
}
|
||||
|
||||
async submit() {
|
||||
await this.executeSetup(this.form.controls.password.value)
|
||||
}
|
||||
|
||||
private async executeSetup(password: string | null) {
|
||||
const loader = this.loader.open('Starting setup').subscribe()
|
||||
|
||||
try {
|
||||
if (this.stateService.setupType === 'attach') {
|
||||
await this.stateService.attachDrive(password)
|
||||
} else {
|
||||
// fresh, restore, or transfer - all use execute
|
||||
await this.stateService.executeSetup(password)
|
||||
}
|
||||
|
||||
await this.router.navigate(['/loading'])
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,144 +0,0 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { ErrorService, ServerComponent } from '@start9labs/shared'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDialogService,
|
||||
TuiIcon,
|
||||
TuiLoader,
|
||||
TuiTitle,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiCardLarge, TuiCell } from '@taiga-ui/layout'
|
||||
import { CIFS, CifsResponse } from 'src/app/components/cifs.component'
|
||||
import { PasswordDirective } from 'src/app/components/password.directive'
|
||||
import { ApiService, StartOSDiskInfoFull } from 'src/app/services/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<section tuiCardLarge="compact">
|
||||
<header>Restore from Backup</header>
|
||||
@if (loading) {
|
||||
<tui-loader />
|
||||
} @else {
|
||||
<h2>Network Folder</h2>
|
||||
Restore StartOS data from a folder on another computer that is connected
|
||||
to the same network as your server.
|
||||
|
||||
<button tuiCell [style.box-shadow]="'none'" (click)="onCifs()">
|
||||
<tui-icon icon="@tui.folder" />
|
||||
<span tuiTitle>Open</span>
|
||||
</button>
|
||||
|
||||
<h2>Physical Drive</h2>
|
||||
<div>
|
||||
Restore StartOS data from a physical drive that is plugged directly
|
||||
into your server.
|
||||
</div>
|
||||
<strong>
|
||||
Warning. Do not use this option if you are using a Raspberry Pi with
|
||||
an external SSD as your main data drive. The Raspberry Pi cannot not
|
||||
support more than one external drive without additional power and can
|
||||
cause data corruption.
|
||||
</strong>
|
||||
|
||||
@for (server of servers; track $index) {
|
||||
<button
|
||||
[server]="server"
|
||||
(password)="select($event, server)"
|
||||
></button>
|
||||
}
|
||||
<footer>
|
||||
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
|
||||
Refresh
|
||||
</button>
|
||||
</footer>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
imports: [
|
||||
TuiCardLarge,
|
||||
TuiLoader,
|
||||
TuiButton,
|
||||
TuiCell,
|
||||
TuiIcon,
|
||||
TuiTitle,
|
||||
ServerComponent,
|
||||
PasswordDirective,
|
||||
],
|
||||
})
|
||||
export default class RecoverPage {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly router = inject(Router)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly stateService = inject(StateService)
|
||||
|
||||
loading = true
|
||||
servers: StartOSDiskInfoFull[] = []
|
||||
|
||||
async ngOnInit() {
|
||||
this.stateService.setupType = 'restore'
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.loading = true
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
async getDrives() {
|
||||
this.servers = []
|
||||
|
||||
try {
|
||||
const drives = await this.api.getDrives()
|
||||
|
||||
this.servers = drives.flatMap(drive =>
|
||||
drive.partitions.flatMap(partition =>
|
||||
Object.entries(partition.startOs).map(([id, val]) => ({
|
||||
id,
|
||||
...val,
|
||||
partition,
|
||||
drive,
|
||||
})),
|
||||
),
|
||||
)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
select(password: string, server: StartOSDiskInfoFull) {
|
||||
this.stateService.recoverySource = {
|
||||
type: 'backup',
|
||||
target: {
|
||||
type: 'disk',
|
||||
logicalname: server.partition.logicalname,
|
||||
},
|
||||
serverId: server.id,
|
||||
password,
|
||||
}
|
||||
this.router.navigate(['storage'])
|
||||
}
|
||||
|
||||
onCifs() {
|
||||
this.dialogs
|
||||
.open<CifsResponse>(CIFS, {
|
||||
label: 'Connect Network Folder',
|
||||
})
|
||||
.subscribe(({ cifs, serverId, password }) => {
|
||||
this.stateService.recoverySource = {
|
||||
type: 'backup',
|
||||
target: {
|
||||
type: 'cifs',
|
||||
...cifs,
|
||||
},
|
||||
serverId,
|
||||
password,
|
||||
}
|
||||
this.router.navigate(['storage'])
|
||||
})
|
||||
}
|
||||
}
|
||||
239
web/projects/setup-wizard/src/app/pages/restore.page.ts
Normal file
239
web/projects/setup-wizard/src/app/pages/restore.page.ts
Normal file
@@ -0,0 +1,239 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { DialogService, ErrorService, i18nPipe } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDataList,
|
||||
TuiDropdown,
|
||||
TuiIcon,
|
||||
TuiLoader,
|
||||
TuiOptGroup,
|
||||
TuiTitle,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
|
||||
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||
import { ApiService } from '../services/api.service'
|
||||
import { StateService } from '../services/state.service'
|
||||
import { StartOSDiskInfoFull, StartOSDiskInfoWithId } from '../types'
|
||||
import { CIFS, CifsResult } from '../components/cifs.component'
|
||||
import { SELECT_NETWORK_BACKUP } from '../components/select-network-backup.dialog'
|
||||
import { UnlockPasswordDialog } from '../components/unlock-password.dialog'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<section tuiCardLarge="compact">
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>
|
||||
{{ 'Select Backup' | i18n }}
|
||||
<span tuiSubtitle>
|
||||
{{ 'Select the StartOS backup you want to restore' | i18n }}
|
||||
<a class="refresh" (click)="refresh()">
|
||||
<tui-icon icon="@tui.rotate-cw" />
|
||||
{{ 'Refresh' | i18n }}
|
||||
</a>
|
||||
</span>
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
@if (loading) {
|
||||
<tui-loader />
|
||||
} @else {
|
||||
<button
|
||||
tuiButton
|
||||
iconEnd="@tui.chevron-down"
|
||||
[tuiDropdown]="dropdown"
|
||||
[tuiDropdownLimitWidth]="'fixed'"
|
||||
[(tuiDropdownOpen)]="open"
|
||||
style="width: 100%"
|
||||
>
|
||||
{{ 'Select Backup' | i18n }}
|
||||
</button>
|
||||
|
||||
<ng-template #dropdown>
|
||||
<tui-data-list>
|
||||
<tui-opt-group>
|
||||
<button tuiOption new (click)="openCifs()">
|
||||
<tui-icon icon="@tui.folder-plus" />
|
||||
{{ 'Open Network Backup' | i18n }}
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
<tui-opt-group [label]="'Physical Backups' | i18n">
|
||||
@for (server of physicalServers; track server.id) {
|
||||
<button tuiOption new (click)="selectPhysicalBackup(server)">
|
||||
<div class="server-item">
|
||||
<span>{{ server.id }}</span>
|
||||
<small>
|
||||
{{ server.drive.vendor }} {{ server.drive.model }} ·
|
||||
{{ server.partition.logicalname }}
|
||||
</small>
|
||||
</div>
|
||||
</button>
|
||||
} @empty {
|
||||
<div class="no-items">{{ 'No physical backups' | i18n }}</div>
|
||||
}
|
||||
</tui-opt-group>
|
||||
</tui-data-list>
|
||||
</ng-template>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: `
|
||||
.refresh {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
cursor: pointer;
|
||||
color: var(--tui-text-action);
|
||||
|
||||
tui-icon {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.server-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
small {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.no-items {
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: var(--tui-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
TuiButton,
|
||||
TuiCardLarge,
|
||||
TuiDataList,
|
||||
TuiDropdown,
|
||||
TuiLoader,
|
||||
TuiIcon,
|
||||
TuiOptGroup,
|
||||
TuiTitle,
|
||||
TuiHeader,
|
||||
i18nPipe,
|
||||
],
|
||||
})
|
||||
export default class RestorePage {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly router = inject(Router)
|
||||
private readonly dialogs = inject(DialogService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly stateService = inject(StateService)
|
||||
|
||||
loading = true
|
||||
open = false
|
||||
physicalServers: StartOSDiskInfoFull[] = []
|
||||
|
||||
async ngOnInit() {
|
||||
await this.loadDrives()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.loading = true
|
||||
await this.loadDrives()
|
||||
}
|
||||
|
||||
openCifs() {
|
||||
this.open = false
|
||||
this.dialogs
|
||||
.openComponent<CifsResult>(CIFS, {
|
||||
label: 'Connect Network Folder',
|
||||
size: 's',
|
||||
})
|
||||
.subscribe(result => {
|
||||
if (result) {
|
||||
this.handleCifsResult(result)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
selectPhysicalBackup(server: StartOSDiskInfoFull) {
|
||||
this.open = false
|
||||
this.showUnlockDialog(server.id, {
|
||||
type: 'disk',
|
||||
logicalname: server.partition.logicalname,
|
||||
})
|
||||
}
|
||||
|
||||
private handleCifsResult(result: CifsResult) {
|
||||
if (result.servers.length === 1) {
|
||||
this.showUnlockDialog(result.servers[0]!.id, {
|
||||
type: 'cifs',
|
||||
...result.cifs,
|
||||
})
|
||||
} else if (result.servers.length > 1) {
|
||||
this.showSelectNetworkBackupDialog(result.cifs, result.servers)
|
||||
}
|
||||
}
|
||||
|
||||
private showSelectNetworkBackupDialog(
|
||||
cifs: T.Cifs,
|
||||
servers: StartOSDiskInfoWithId[],
|
||||
) {
|
||||
this.dialogs
|
||||
.openComponent<StartOSDiskInfoWithId | null>(SELECT_NETWORK_BACKUP, {
|
||||
label: 'Select Network Backup',
|
||||
size: 's',
|
||||
data: { servers },
|
||||
})
|
||||
.subscribe(server => {
|
||||
if (server) {
|
||||
this.showUnlockDialog(server.id, { type: 'cifs', ...cifs })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private showUnlockDialog(
|
||||
serverId: string,
|
||||
target: { type: 'disk'; logicalname: string } | ({ type: 'cifs' } & T.Cifs),
|
||||
) {
|
||||
this.dialogs
|
||||
.openComponent<string | null>(
|
||||
new PolymorpheusComponent(UnlockPasswordDialog),
|
||||
{
|
||||
label: 'Unlock Backup',
|
||||
size: 's',
|
||||
},
|
||||
)
|
||||
.subscribe(password => {
|
||||
if (password) {
|
||||
this.stateService.recoverySource = {
|
||||
type: 'backup',
|
||||
target,
|
||||
serverId,
|
||||
password,
|
||||
}
|
||||
this.router.navigate(['/password'])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async loadDrives() {
|
||||
this.physicalServers = []
|
||||
|
||||
try {
|
||||
const drives = await this.api.getDisks()
|
||||
|
||||
this.physicalServers = drives.flatMap(drive =>
|
||||
drive.partitions.flatMap(partition =>
|
||||
Object.entries(partition.startOs).map(([id, val]) => ({
|
||||
id,
|
||||
...val,
|
||||
partition,
|
||||
drive,
|
||||
})),
|
||||
),
|
||||
)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,173 +0,0 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import {
|
||||
DiskInfo,
|
||||
DriveComponent,
|
||||
ErrorService,
|
||||
i18nKey,
|
||||
LoadingService,
|
||||
toGuid,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiButton, TuiDialogService, TuiLoader } from '@taiga-ui/core'
|
||||
import { TUI_CONFIRM } from '@taiga-ui/kit'
|
||||
import { TuiCardLarge, TuiCell } from '@taiga-ui/layout'
|
||||
import { filter, of, switchMap } from 'rxjs'
|
||||
import { PASSWORD } from 'src/app/components/password.component'
|
||||
import { ApiService } from 'src/app/services/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<section tuiCardLarge="compact">
|
||||
@if (loading || drives.length) {
|
||||
<header>Select storage drive</header>
|
||||
This is the drive where your StartOS data will be stored.
|
||||
} @else {
|
||||
<header>No drives found</header>
|
||||
Please connect a storage drive to your server. Then click "Refresh".
|
||||
}
|
||||
|
||||
@if (loading) {
|
||||
<tui-loader />
|
||||
}
|
||||
|
||||
@for (d of drives; track d) {
|
||||
<button tuiCell [drive]="d" [disabled]="isSmall(d)" (click)="select(d)">
|
||||
@if (isSmall(d)) {
|
||||
<span tuiSubtitle class="g-negative">Drive capacity too small</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
<footer>
|
||||
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
|
||||
Refresh
|
||||
</button>
|
||||
</footer>
|
||||
</section>
|
||||
`,
|
||||
imports: [TuiCardLarge, TuiLoader, TuiCell, TuiButton, DriveComponent],
|
||||
})
|
||||
export default class StoragePage {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly router = inject(Router)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly stateService = inject(StateService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
|
||||
drives: DiskInfo[] = []
|
||||
loading = true
|
||||
|
||||
async ngOnInit() {
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
isSmall({ capacity }: DiskInfo) {
|
||||
return capacity < 34359738368
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.loading = true
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
async getDrives() {
|
||||
this.loading = true
|
||||
try {
|
||||
const disks = await this.api.getDrives()
|
||||
if (this.stateService.setupType === 'fresh') {
|
||||
this.drives = disks
|
||||
} else if (
|
||||
this.stateService.setupType === 'restore' &&
|
||||
this.stateService.recoverySource?.type === 'backup'
|
||||
) {
|
||||
if (this.stateService.recoverySource.target.type === 'disk') {
|
||||
const logicalname =
|
||||
this.stateService.recoverySource.target.logicalname
|
||||
this.drives = disks.filter(
|
||||
d => !d.partitions.map(p => p.logicalname).includes(logicalname),
|
||||
)
|
||||
} else {
|
||||
this.drives = disks
|
||||
}
|
||||
} else if (
|
||||
this.stateService.setupType === 'transfer' &&
|
||||
this.stateService.recoverySource?.type === 'migrate'
|
||||
) {
|
||||
const guid = this.stateService.recoverySource.guid
|
||||
this.drives = disks.filter(d => {
|
||||
return (
|
||||
d.guid !== guid && !d.partitions.map(p => p.guid).includes(guid)
|
||||
)
|
||||
})
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
select(drive: DiskInfo) {
|
||||
of(!toGuid(drive) && !drive.partitions.some(p => p.used))
|
||||
.pipe(
|
||||
switchMap(unused =>
|
||||
unused
|
||||
? of(true)
|
||||
: this.dialogs.open(TUI_CONFIRM, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content:
|
||||
'<strong>Drive contains data!</strong><p>All data stored on this drive will be permanently deleted.</p>',
|
||||
yes: 'Continue',
|
||||
no: 'Cancel',
|
||||
},
|
||||
}),
|
||||
),
|
||||
)
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => {
|
||||
// for backup recoveries
|
||||
if (this.stateService.recoverySource?.type === 'backup') {
|
||||
this.setupEmbassy(
|
||||
drive.logicalname,
|
||||
this.stateService.recoverySource.password,
|
||||
)
|
||||
} else {
|
||||
// for migrations and fresh setups
|
||||
this.promptPassword(drive.logicalname)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private promptPassword(logicalname: string) {
|
||||
this.dialogs
|
||||
.open<string>(PASSWORD, {
|
||||
label: 'Set Password',
|
||||
size: 's',
|
||||
data: { storageDrive: true },
|
||||
})
|
||||
.subscribe(password => {
|
||||
this.setupEmbassy(logicalname, password)
|
||||
})
|
||||
}
|
||||
|
||||
private async setupEmbassy(
|
||||
logicalname: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
const loader = this.loader
|
||||
.open('Connecting to drive' as i18nKey)
|
||||
.subscribe()
|
||||
|
||||
try {
|
||||
await this.stateService.setupEmbassy(logicalname, password)
|
||||
await this.router.navigate(['loading'])
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,155 +6,224 @@ import {
|
||||
ViewChild,
|
||||
DOCUMENT,
|
||||
} from '@angular/core'
|
||||
import { DownloadHTMLService, ErrorService } from '@start9labs/shared'
|
||||
import { TuiButton, TuiIcon, TuiLoader, TuiSurface } from '@taiga-ui/core'
|
||||
import { TuiCardLarge } from '@taiga-ui/layout'
|
||||
import { DocumentationComponent } from 'src/app/components/documentation.component'
|
||||
import { MatrixComponent } from 'src/app/components/matrix.component'
|
||||
import { ApiService } from 'src/app/services/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { DownloadHTMLService, ErrorService, i18nPipe } from '@start9labs/shared'
|
||||
import { TuiIcon, TuiLoader, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiAvatar } from '@taiga-ui/kit'
|
||||
import { TuiCardLarge, TuiCell, TuiHeader } from '@taiga-ui/layout'
|
||||
import { ApiService } from '../services/api.service'
|
||||
import { StateService } from '../services/state.service'
|
||||
import { DocumentationComponent } from '../components/documentation.component'
|
||||
import { MatrixComponent } from '../components/matrix.component'
|
||||
import { SetupCompleteRes } from '../types'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<canvas matrix></canvas>
|
||||
<section tuiCardLarge>
|
||||
<h1 class="heading">
|
||||
<tui-icon icon="@tui.circle-check-big" class="g-positive" />
|
||||
Setup Complete!
|
||||
</h1>
|
||||
@if (stateService.kiosk) {
|
||||
<button tuiButton (click)="exitKiosk()">Continue to Login</button>
|
||||
} @else if (lanAddress) {
|
||||
@if (stateService.setupType === 'restore') {
|
||||
<h3>You can now safely unplug your backup drive</h3>
|
||||
} @else if (stateService.setupType === 'transfer') {
|
||||
<h3>You can now safely unplug your old StartOS data drive</h3>
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>
|
||||
<span class="inline-title">
|
||||
<tui-icon icon="@tui.circle-check-big" class="g-positive" />
|
||||
{{ 'Setup Complete!' | i18n }}
|
||||
</span>
|
||||
@if (!stateService.kiosk) {
|
||||
<span tuiSubtitle>
|
||||
{{
|
||||
stateService.setupType === 'restore'
|
||||
? ('You can unplug your backup drive' | i18n)
|
||||
: stateService.setupType === 'transfer'
|
||||
? ('You can unplug your transfer drive' | i18n)
|
||||
: ('http://start.local was for setup only. It will no longer work.'
|
||||
| i18n)
|
||||
}}
|
||||
</span>
|
||||
}
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
@if (!result) {
|
||||
<tui-loader />
|
||||
} @else {
|
||||
<!-- Step: Download Address Info (non-kiosk only) -->
|
||||
@if (!stateService.kiosk) {
|
||||
<button tuiCell="l" [disabled]="downloaded" (click)="download()">
|
||||
<tui-avatar appearance="secondary" src="@tui.download" />
|
||||
<div tuiTitle>
|
||||
{{ 'Download Address Info' | i18n }}
|
||||
<div tuiSubtitle>
|
||||
{{
|
||||
"Contains your server's permanent local address and Root CA"
|
||||
| i18n
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
@if (downloaded) {
|
||||
<tui-icon icon="@tui.circle-check" class="g-positive" />
|
||||
}
|
||||
</button>
|
||||
}
|
||||
|
||||
<h3>
|
||||
http://start.local was for setup purposes only. It will no longer
|
||||
work.
|
||||
</h3>
|
||||
<!-- Step: Remove USB Media (when restart needed) -->
|
||||
@if (result.needsRestart) {
|
||||
<button
|
||||
tuiCell="l"
|
||||
[class.disabled]="!stateService.kiosk && !downloaded"
|
||||
[disabled]="(!stateService.kiosk && !downloaded) || usbRemoved"
|
||||
(click)="usbRemoved = true"
|
||||
>
|
||||
<tui-avatar appearance="secondary" src="@tui.usb" />
|
||||
<div tuiTitle>
|
||||
{{ 'USB Removed' | i18n }}
|
||||
<div tuiSubtitle>
|
||||
{{
|
||||
'Remove the USB installation media from your server' | i18n
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
@if (usbRemoved) {
|
||||
<tui-icon icon="@tui.circle-check" class="g-positive" />
|
||||
}
|
||||
</button>
|
||||
|
||||
<button tuiCardLarge tuiSurface="floating" (click)="download()">
|
||||
<strong class="caps">Download address info</strong>
|
||||
<span>
|
||||
For future reference, this file contains your server's permanent
|
||||
local address, as well as its Root Certificate Authority (Root CA).
|
||||
</span>
|
||||
<strong class="caps">
|
||||
Download
|
||||
<tui-icon icon="@tui.download" />
|
||||
</strong>
|
||||
</button>
|
||||
<!-- Step: Restart Server -->
|
||||
<button
|
||||
tuiCell="l"
|
||||
[class.disabled]="!usbRemoved"
|
||||
[disabled]="!usbRemoved || rebooted || rebooting"
|
||||
(click)="reboot()"
|
||||
>
|
||||
<tui-avatar appearance="secondary" src="@tui.rotate-cw" />
|
||||
<div tuiTitle>
|
||||
{{ 'Restart Server' | i18n }}
|
||||
<div tuiSubtitle>
|
||||
@if (rebooting) {
|
||||
{{ 'Waiting for server to come back online' | i18n }}
|
||||
} @else if (rebooted) {
|
||||
{{ 'Server is back online' | i18n }}
|
||||
} @else {
|
||||
{{ 'Restart your server to complete setup' | i18n }}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (rebooting) {
|
||||
<tui-loader />
|
||||
} @else if (rebooted) {
|
||||
<tui-icon icon="@tui.circle-check" class="g-positive" />
|
||||
}
|
||||
</button>
|
||||
}
|
||||
|
||||
<a
|
||||
tuiCardLarge
|
||||
tuiSurface="floating"
|
||||
target="_blank"
|
||||
[attr.href]="disableLogin ? null : lanAddress"
|
||||
>
|
||||
<span>
|
||||
In the new tab, follow instructions to trust your server's Root CA
|
||||
and log in.
|
||||
</span>
|
||||
<strong class="caps">
|
||||
Open Local Address
|
||||
<tui-icon icon="@tui.external-link" />
|
||||
</strong>
|
||||
</a>
|
||||
<app-documentation hidden [lanAddress]="lanAddress" />
|
||||
} @else {
|
||||
<tui-loader />
|
||||
<!-- Step: Open Local Address (non-kiosk only) -->
|
||||
@if (!stateService.kiosk) {
|
||||
<button
|
||||
tuiCell="l"
|
||||
[class.disabled]="!canOpenAddress"
|
||||
[disabled]="!canOpenAddress"
|
||||
(click)="openLocalAddress()"
|
||||
>
|
||||
<tui-avatar appearance="secondary" src="@tui.external-link" />
|
||||
<div tuiTitle>
|
||||
{{ 'Open Local Address' | i18n }}
|
||||
<div tuiSubtitle>{{ lanAddress }}</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<app-documentation hidden [lanAddress]="lanAddress" />
|
||||
}
|
||||
|
||||
<!-- Step: Continue to Login (kiosk only) -->
|
||||
@if (stateService.kiosk) {
|
||||
<button
|
||||
tuiCell="l"
|
||||
[class.disabled]="result.needsRestart && !rebooted"
|
||||
[disabled]="result.needsRestart && !rebooted"
|
||||
(click)="exitKiosk()"
|
||||
>
|
||||
<tui-avatar appearance="secondary" src="@tui.log-in" />
|
||||
<div tuiTitle>
|
||||
{{ 'Continue to Login' | i18n }}
|
||||
<div tuiSubtitle>
|
||||
{{ 'Proceed to the StartOS login screen' | i18n }}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
styles: `
|
||||
.heading {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
.inline-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
margin: 0;
|
||||
font: var(--tui-font-heading-4);
|
||||
}
|
||||
|
||||
.caps {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
[tuiCardLarge] {
|
||||
color: var(--tui-text-primary);
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
|
||||
&[data-appearance='floating'] {
|
||||
background: var(--tui-background-neutral-1);
|
||||
|
||||
&:hover {
|
||||
background: var(--tui-background-neutral-1-hover) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
a[tuiCardLarge]:not([href]) {
|
||||
[tuiCell].disabled {
|
||||
opacity: var(--tui-disabled-opacity);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
h3 {
|
||||
text-align: left;
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
TuiCardLarge,
|
||||
TuiCell,
|
||||
TuiIcon,
|
||||
TuiButton,
|
||||
TuiSurface,
|
||||
TuiLoader,
|
||||
TuiAvatar,
|
||||
MatrixComponent,
|
||||
DocumentationComponent,
|
||||
TuiLoader,
|
||||
TuiHeader,
|
||||
TuiTitle,
|
||||
i18nPipe,
|
||||
],
|
||||
})
|
||||
export default class SuccessPage implements AfterViewInit {
|
||||
@ViewChild(DocumentationComponent, { read: ElementRef })
|
||||
private readonly documentation?: ElementRef<HTMLElement>
|
||||
|
||||
private readonly document = inject(DOCUMENT)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly downloadHtml = inject(DownloadHTMLService)
|
||||
private readonly i18n = inject(i18nPipe)
|
||||
|
||||
readonly stateService = inject(StateService)
|
||||
|
||||
lanAddress?: string
|
||||
cert?: string
|
||||
disableLogin = this.stateService.setupType === 'fresh'
|
||||
result?: SetupCompleteRes
|
||||
lanAddress = ''
|
||||
downloaded = false
|
||||
usbRemoved = false
|
||||
rebooting = false
|
||||
rebooted = false
|
||||
|
||||
get canOpenAddress(): boolean {
|
||||
if (!this.downloaded) return false
|
||||
if (this.result?.needsRestart && !this.rebooted) return false
|
||||
return true
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
setTimeout(() => this.complete(), 1000)
|
||||
setTimeout(() => this.complete(), 500)
|
||||
}
|
||||
|
||||
download() {
|
||||
const lanElem = this.document.getElementById('lan-addr')
|
||||
if (this.downloaded) return
|
||||
|
||||
if (lanElem) lanElem.innerHTML = this.lanAddress || ''
|
||||
const lanElem = this.document.getElementById('lan-addr')
|
||||
if (lanElem) lanElem.innerHTML = this.lanAddress
|
||||
|
||||
this.document
|
||||
.getElementById('cert')
|
||||
?.setAttribute(
|
||||
'href',
|
||||
URL.createObjectURL(
|
||||
new Blob([this.cert!], { type: 'application/octet-stream' }),
|
||||
new Blob([this.result!.rootCa], { type: 'application/octet-stream' }),
|
||||
),
|
||||
)
|
||||
|
||||
const html = this.documentation?.nativeElement.innerHTML || ''
|
||||
|
||||
this.downloadHtml.download('StartOS-info.html', html).then(_ => {
|
||||
this.disableLogin = false
|
||||
this.downloadHtml.download('StartOS-info.html', html).then(() => {
|
||||
this.downloaded = true
|
||||
})
|
||||
}
|
||||
|
||||
@@ -162,17 +231,58 @@ export default class SuccessPage implements AfterViewInit {
|
||||
this.api.exit()
|
||||
}
|
||||
|
||||
openLocalAddress() {
|
||||
window.open(this.lanAddress, '_blank')
|
||||
}
|
||||
|
||||
async reboot() {
|
||||
this.rebooting = true
|
||||
|
||||
try {
|
||||
await this.api.exit()
|
||||
await this.pollForServer()
|
||||
this.rebooted = true
|
||||
this.rebooting = false
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
this.rebooting = false
|
||||
}
|
||||
}
|
||||
|
||||
private async complete() {
|
||||
try {
|
||||
const ret = await this.api.complete()
|
||||
if (!this.stateService.kiosk) {
|
||||
this.lanAddress = ret.lanAddress.replace(/^https:/, 'http:')
|
||||
this.cert = ret.rootCa
|
||||
this.result = await this.api.complete()
|
||||
|
||||
await this.api.exit()
|
||||
if (!this.stateService.kiosk) {
|
||||
this.lanAddress = `http://${this.result.hostname}.local`
|
||||
|
||||
if (!this.result.needsRestart) {
|
||||
await this.api.exit()
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
private async pollForServer(): Promise<void> {
|
||||
const maxAttempts = 60
|
||||
let attempts = 0
|
||||
|
||||
while (attempts < maxAttempts) {
|
||||
try {
|
||||
await this.api.echo({ message: 'ping' }, this.lanAddress)
|
||||
return
|
||||
} catch {
|
||||
await new Promise(resolve => setTimeout(resolve, 5000))
|
||||
attempts++
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
this.i18n.transform(
|
||||
'Server did not come back online. Please check your server and try accessing it manually.',
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,97 +1,172 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import {
|
||||
DialogService,
|
||||
DiskInfo,
|
||||
DriveComponent,
|
||||
ErrorService,
|
||||
i18nPipe,
|
||||
toGuid,
|
||||
} from '@start9labs/shared'
|
||||
import {
|
||||
TuiButton,
|
||||
TuiDialogOptions,
|
||||
TuiDialogService,
|
||||
TuiDataList,
|
||||
TuiDropdown,
|
||||
TuiIcon,
|
||||
TuiLoader,
|
||||
TuiTitle,
|
||||
} from '@taiga-ui/core'
|
||||
import { TUI_CONFIRM, TuiConfirmData } from '@taiga-ui/kit'
|
||||
import { TuiCardLarge, TuiCell } from '@taiga-ui/layout'
|
||||
import { TuiCardLarge, TuiHeader } from '@taiga-ui/layout'
|
||||
import { filter } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { ApiService } from '../services/api.service'
|
||||
import { StateService } from '../services/state.service'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<section tuiCardLarge="compact">
|
||||
<header>Transfer</header>
|
||||
Select the physical drive containing your StartOS data
|
||||
<header tuiHeader>
|
||||
<h2 tuiTitle>
|
||||
{{ 'Transfer Data' | i18n }}
|
||||
<span tuiSubtitle>
|
||||
{{
|
||||
'Select the drive containing your existing StartOS data' | i18n
|
||||
}}
|
||||
<a class="refresh" (click)="refresh()">
|
||||
<tui-icon icon="@tui.rotate-cw" />
|
||||
{{ 'Refresh' | i18n }}
|
||||
</a>
|
||||
</span>
|
||||
</h2>
|
||||
</header>
|
||||
|
||||
@if (loading) {
|
||||
<tui-loader />
|
||||
}
|
||||
@for (drive of drives; track drive) {
|
||||
<button tuiCell [drive]="drive" (click)="select(drive)"></button>
|
||||
}
|
||||
<footer>
|
||||
<button tuiButton iconStart="@tui.rotate-cw" (click)="refresh()">
|
||||
Refresh
|
||||
} @else {
|
||||
<button
|
||||
tuiButton
|
||||
iconEnd="@tui.chevron-down"
|
||||
[tuiDropdown]="dropdown"
|
||||
[tuiDropdownLimitWidth]="'fixed'"
|
||||
[(tuiDropdownOpen)]="open"
|
||||
style="width: 100%"
|
||||
>
|
||||
{{ 'Select Drive' | i18n }}
|
||||
</button>
|
||||
</footer>
|
||||
|
||||
<ng-template #dropdown>
|
||||
<tui-data-list>
|
||||
@for (drive of drives; track drive.logicalname) {
|
||||
<button tuiOption new (click)="select(drive)">
|
||||
<div class="drive-item">
|
||||
<span>{{ drive.vendor }} {{ drive.model }}</span>
|
||||
<small>{{ drive.logicalname }}</small>
|
||||
</div>
|
||||
</button>
|
||||
} @empty {
|
||||
<div class="no-items">
|
||||
{{ 'No StartOS data drives found' | i18n }}
|
||||
</div>
|
||||
}
|
||||
</tui-data-list>
|
||||
</ng-template>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
imports: [TuiCardLarge, TuiCell, TuiButton, TuiLoader, DriveComponent],
|
||||
styles: `
|
||||
.refresh {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
cursor: pointer;
|
||||
color: var(--tui-text-action);
|
||||
|
||||
tui-icon {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.drive-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
small {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.no-items {
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: var(--tui-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
TuiButton,
|
||||
TuiCardLarge,
|
||||
TuiDataList,
|
||||
TuiDropdown,
|
||||
TuiIcon,
|
||||
TuiLoader,
|
||||
TuiTitle,
|
||||
TuiHeader,
|
||||
i18nPipe,
|
||||
],
|
||||
})
|
||||
export default class TransferPage {
|
||||
private readonly apiService = inject(ApiService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly router = inject(Router)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly dialogs = inject(DialogService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly stateService = inject(StateService)
|
||||
|
||||
loading = true
|
||||
open = false
|
||||
drives: DiskInfo[] = []
|
||||
|
||||
async ngOnInit() {
|
||||
this.stateService.setupType = 'transfer'
|
||||
await this.getDrives()
|
||||
await this.loadDrives()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
await this.getDrives()
|
||||
this.loading = true
|
||||
await this.loadDrives()
|
||||
}
|
||||
|
||||
async getDrives() {
|
||||
this.loading = true
|
||||
select(drive: DiskInfo) {
|
||||
this.open = false
|
||||
|
||||
this.dialogs
|
||||
.openConfirm({
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content:
|
||||
'After transferring data from this drive, do not attempt to boot into it again as a Start9 Server. This may result in services malfunctioning, data corruption, or loss of funds.',
|
||||
yes: 'Continue',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => {
|
||||
const guid = toGuid(drive)
|
||||
if (guid) {
|
||||
this.stateService.recoverySource = {
|
||||
type: 'migrate',
|
||||
guid,
|
||||
}
|
||||
this.router.navigate(['/password'])
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private async loadDrives() {
|
||||
try {
|
||||
this.drives = await this.apiService
|
||||
.getDrives()
|
||||
.then(drives => drives.filter(toGuid))
|
||||
const allDrives = await this.api.getDisks()
|
||||
// Filter to only drives with StartOS data (guid)
|
||||
this.drives = allDrives.filter(toGuid)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
select(drive: DiskInfo) {
|
||||
this.dialogs
|
||||
.open(TUI_CONFIRM, OPTIONS)
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => {
|
||||
this.stateService.recoverySource = {
|
||||
type: 'migrate',
|
||||
guid: toGuid(drive) || '',
|
||||
}
|
||||
this.router.navigate([`storage`])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const OPTIONS: Partial<TuiDialogOptions<TuiConfirmData>> = {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content:
|
||||
'After transferring data from this drive, <b>do not</b> attempt to boot into it again as a Start9 Server. This may result in services malfunctioning, data corruption, or loss of funds.',
|
||||
yes: 'Continue',
|
||||
no: 'Cancel',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,50 +1,65 @@
|
||||
import * as jose from 'node-jose'
|
||||
import {
|
||||
DiskInfo,
|
||||
DiskListResponse,
|
||||
FollowLogsRes,
|
||||
PartitionInfo,
|
||||
FullKeyboard,
|
||||
SetLanguageParams,
|
||||
StartOSDiskInfo,
|
||||
} from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||
import { Observable } from 'rxjs'
|
||||
import {
|
||||
SetupStatusRes,
|
||||
InstallOsParams,
|
||||
InstallOsRes,
|
||||
AttachParams,
|
||||
SetupExecuteParams,
|
||||
SetupCompleteRes,
|
||||
EchoReq,
|
||||
} from '../types'
|
||||
|
||||
export abstract class ApiService {
|
||||
pubkey?: jose.JWK.Key
|
||||
|
||||
abstract getStatus(): Promise<T.SetupStatusRes | null> // setup.status
|
||||
// echo
|
||||
abstract echo(params: EchoReq, url: string): Promise<string>
|
||||
|
||||
// Status & Setup
|
||||
abstract getStatus(): Promise<SetupStatusRes> // setup.status
|
||||
abstract getPubKey(): Promise<void> // setup.get-pubkey
|
||||
abstract getDrives(): Promise<DiskListResponse> // setup.disk.list
|
||||
abstract setKeyboard(params: FullKeyboard): Promise<null> // setup.set-keyboard
|
||||
abstract setLanguage(params: SetLanguageParams): Promise<null> // setup.set-language
|
||||
|
||||
// Install
|
||||
abstract getDisks(): Promise<DiskInfo[]> // setup.disk.list
|
||||
abstract installOs(params: InstallOsParams): Promise<InstallOsRes> // setup.install-os
|
||||
|
||||
// Setup execution
|
||||
abstract attach(params: AttachParams): Promise<T.SetupProgress> // setup.attach
|
||||
abstract execute(params: SetupExecuteParams): Promise<T.SetupProgress> // setup.execute
|
||||
|
||||
// Recovery helpers
|
||||
abstract verifyCifs(
|
||||
cifs: T.VerifyCifsParams,
|
||||
): Promise<Record<string, StartOSDiskInfo>> // setup.cifs.verify
|
||||
abstract attach(importInfo: T.AttachParams): Promise<T.SetupProgress> // setup.attach
|
||||
abstract execute(setupInfo: T.SetupExecuteParams): Promise<T.SetupProgress> // setup.execute
|
||||
abstract complete(): Promise<T.SetupResult> // setup.complete
|
||||
|
||||
// Completion
|
||||
abstract complete(): Promise<SetupCompleteRes> // setup.complete
|
||||
abstract exit(): Promise<void> // setup.exit
|
||||
abstract shutdown(): Promise<void> // setup.shutdown
|
||||
|
||||
// Logs & Progress
|
||||
abstract initFollowLogs(): Promise<FollowLogsRes> // setup.logs.follow
|
||||
abstract restart(): Promise<void> // setup.restart
|
||||
abstract openWebsocket$<T>(guid: string): Observable<T>
|
||||
|
||||
// Restart (for error recovery)
|
||||
abstract restart(): Promise<void> // setup.restart
|
||||
|
||||
async encrypt(toEncrypt: string): Promise<T.EncryptedWire> {
|
||||
if (!this.pubkey) throw new Error('No pubkey found!')
|
||||
const encrypted = await jose.JWE.createEncrypt(this.pubkey!)
|
||||
.update(toEncrypt)
|
||||
.final()
|
||||
return {
|
||||
encrypted,
|
||||
}
|
||||
return { encrypted }
|
||||
}
|
||||
}
|
||||
|
||||
export type WebsocketConfig<T> = Omit<WebSocketSubjectConfig<T>, 'url'>
|
||||
|
||||
export type StartOSDiskInfoWithId = StartOSDiskInfo & {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type StartOSDiskInfoFull = StartOSDiskInfoWithId & {
|
||||
partition: PartitionInfo
|
||||
drive: DiskInfo
|
||||
}
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Inject, Injectable, DOCUMENT } from '@angular/core'
|
||||
import {
|
||||
DiskListResponse,
|
||||
DiskInfo,
|
||||
encodeBase64,
|
||||
FollowLogsRes,
|
||||
FullKeyboard,
|
||||
HttpService,
|
||||
isRpcError,
|
||||
RpcError,
|
||||
RPCOptions,
|
||||
SetLanguageParams,
|
||||
StartOSDiskInfo,
|
||||
} from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
@@ -14,6 +16,15 @@ import * as jose from 'node-jose'
|
||||
import { Observable } from 'rxjs'
|
||||
import { webSocket } from 'rxjs/webSocket'
|
||||
import { ApiService } from './api.service'
|
||||
import {
|
||||
SetupStatusRes,
|
||||
InstallOsParams,
|
||||
InstallOsRes,
|
||||
AttachParams,
|
||||
SetupExecuteParams,
|
||||
SetupCompleteRes,
|
||||
EchoReq,
|
||||
} from '../types'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -36,39 +47,54 @@ export class LiveApiService extends ApiService {
|
||||
})
|
||||
}
|
||||
|
||||
async getStatus(): Promise<T.SetupStatusRes | null> {
|
||||
return this.rpcRequest<T.SetupStatusRes | null>({
|
||||
async echo(params: EchoReq, url: string): Promise<string> {
|
||||
return this.rpcRequest({ method: 'echo', params }, url)
|
||||
}
|
||||
|
||||
async getStatus() {
|
||||
return this.rpcRequest<SetupStatusRes>({
|
||||
method: 'setup.status',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* We want to update the pubkey, which means that we will call in clearnet the
|
||||
* getPubKey, and all the information is never in the clear, and only public
|
||||
* information is sent across the network. We don't want to expose that we do
|
||||
* this wil all public/private key, which means that there is no information loss
|
||||
* through the network.
|
||||
*/
|
||||
async getPubKey(): Promise<void> {
|
||||
async getPubKey() {
|
||||
const response: jose.JWK.Key = await this.rpcRequest({
|
||||
method: 'setup.get-pubkey',
|
||||
params: {},
|
||||
})
|
||||
|
||||
this.pubkey = response
|
||||
}
|
||||
|
||||
async getDrives(): Promise<DiskListResponse> {
|
||||
return this.rpcRequest<DiskListResponse>({
|
||||
async setKeyboard(params: FullKeyboard): Promise<null> {
|
||||
return this.rpcRequest({
|
||||
method: 'setup.set-keyboard',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
async setLanguage(params: SetLanguageParams): Promise<null> {
|
||||
return this.rpcRequest({
|
||||
method: 'setup.set-language',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
async getDisks() {
|
||||
return this.rpcRequest<DiskInfo[]>({
|
||||
method: 'setup.disk.list',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async verifyCifs(
|
||||
source: T.VerifyCifsParams,
|
||||
): Promise<Record<string, StartOSDiskInfo>> {
|
||||
async installOs(params: InstallOsParams) {
|
||||
return this.rpcRequest<InstallOsRes>({
|
||||
method: 'setup.install-os',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
async verifyCifs(source: T.VerifyCifsParams) {
|
||||
source.path = source.path.replace('/\\/g', '/')
|
||||
return this.rpcRequest<Record<string, StartOSDiskInfo>>({
|
||||
method: 'setup.cifs.verify',
|
||||
@@ -76,33 +102,36 @@ export class LiveApiService extends ApiService {
|
||||
})
|
||||
}
|
||||
|
||||
async attach(params: T.AttachParams): Promise<T.SetupProgress> {
|
||||
async attach(params: AttachParams) {
|
||||
return this.rpcRequest<T.SetupProgress>({
|
||||
method: 'setup.attach',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
async execute(setupInfo: T.SetupExecuteParams): Promise<T.SetupProgress> {
|
||||
if (setupInfo.recoverySource?.type === 'backup') {
|
||||
if (isCifsSource(setupInfo.recoverySource.target)) {
|
||||
setupInfo.recoverySource.target.path =
|
||||
setupInfo.recoverySource.target.path.replace('/\\/g', '/')
|
||||
async execute(params: SetupExecuteParams) {
|
||||
if (params.recoverySource?.type === 'backup') {
|
||||
const target = params.recoverySource.target
|
||||
if (target.type === 'cifs') {
|
||||
target.path = target.path.replace('/\\/g', '/')
|
||||
}
|
||||
}
|
||||
|
||||
return this.rpcRequest<T.SetupProgress>({
|
||||
method: 'setup.execute',
|
||||
params: setupInfo,
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
async initFollowLogs(): Promise<FollowLogsRes> {
|
||||
return this.rpcRequest({ method: 'setup.logs.follow', params: {} })
|
||||
async initFollowLogs() {
|
||||
return this.rpcRequest<FollowLogsRes>({
|
||||
method: 'setup.logs.follow',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async complete(): Promise<T.SetupResult> {
|
||||
const res = await this.rpcRequest<T.SetupResult>({
|
||||
async complete() {
|
||||
const res = await this.rpcRequest<SetupCompleteRes>({
|
||||
method: 'setup.complete',
|
||||
params: {},
|
||||
})
|
||||
@@ -113,23 +142,29 @@ export class LiveApiService extends ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
async exit(): Promise<void> {
|
||||
async exit() {
|
||||
await this.rpcRequest<void>({
|
||||
method: 'setup.exit',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async restart(): Promise<void> {
|
||||
async shutdown() {
|
||||
await this.rpcRequest<void>({
|
||||
method: 'setup.shutdown',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async restart() {
|
||||
await this.rpcRequest<void>({
|
||||
method: 'setup.restart',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
private async rpcRequest<T>(opts: RPCOptions): Promise<T> {
|
||||
const res = await this.http.rpcRequest<T>(opts)
|
||||
|
||||
private async rpcRequest<T>(opts: RPCOptions, url?: string): Promise<T> {
|
||||
const res = await this.http.rpcRequest<T>(opts, url)
|
||||
const rpcRes = res.body
|
||||
|
||||
if (isRpcError(rpcRes)) {
|
||||
@@ -139,9 +174,3 @@ export class LiveApiService extends ApiService {
|
||||
return rpcRes.result
|
||||
}
|
||||
}
|
||||
|
||||
function isCifsSource(
|
||||
source: T.BackupTargetFS | null,
|
||||
): source is T.Cifs & { type: 'cifs' } {
|
||||
return !!(source as T.Cifs)?.hostname
|
||||
}
|
||||
|
||||
@@ -1,111 +1,33 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import {
|
||||
DiskListResponse,
|
||||
DiskInfo,
|
||||
encodeBase64,
|
||||
FollowLogsRes,
|
||||
FullKeyboard,
|
||||
pauseFor,
|
||||
SetLanguageParams,
|
||||
StartOSDiskInfo,
|
||||
} from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import * as jose from 'node-jose'
|
||||
import { first, interval, map, Observable } from 'rxjs'
|
||||
import { interval, map, Observable } from 'rxjs'
|
||||
import { ApiService } from './api.service'
|
||||
import {
|
||||
SetupStatusRes,
|
||||
InstallOsParams,
|
||||
InstallOsRes,
|
||||
AttachParams,
|
||||
SetupExecuteParams,
|
||||
SetupCompleteRes,
|
||||
EchoReq,
|
||||
} from '../types'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class MockApiService extends ApiService {
|
||||
// fullProgress$(): Observable<T.FullProgress> {
|
||||
// const phases = [
|
||||
// {
|
||||
// name: 'Preparing Data',
|
||||
// progress: null,
|
||||
// },
|
||||
// {
|
||||
// name: 'Transferring Data',
|
||||
// progress: null,
|
||||
// },
|
||||
// {
|
||||
// name: 'Finalizing Setup',
|
||||
// progress: null,
|
||||
// },
|
||||
// ]
|
||||
|
||||
// return from(phases).pipe(
|
||||
// switchScan((acc, val, i) => {}, { overall: null, phases }),
|
||||
// )
|
||||
// }
|
||||
|
||||
// namedProgress$(namedProgress: T.NamedProgress): Observable<T.NamedProgress> {
|
||||
// return of(namedProgress).pipe(startWith(namedProgress))
|
||||
// }
|
||||
|
||||
// progress$(progress: T.Progress): Observable<T.Progress> {}
|
||||
|
||||
// websocket
|
||||
|
||||
// oldMockProgress$(): Promise<T.FullProgress> {
|
||||
// const numPhases = PROGRESS.phases.length
|
||||
|
||||
// return of(PROGRESS).pipe(
|
||||
// switchMap(full =>
|
||||
// from(PROGRESS.phases).pipe(
|
||||
// mergeScan((full, phase, i) => {
|
||||
// if (
|
||||
// !phase.progress ||
|
||||
// typeof phase.progress !== 'object' ||
|
||||
// !phase.progress.total
|
||||
// ) {
|
||||
// full.phases[i].progress = true
|
||||
|
||||
// if (
|
||||
// full.overall &&
|
||||
// typeof full.overall === 'object' &&
|
||||
// full.overall.total
|
||||
// ) {
|
||||
// const step = full.overall.total / numPhases
|
||||
// full.overall.done += step
|
||||
// }
|
||||
|
||||
// return of(full).pipe(delay(2000))
|
||||
// } else {
|
||||
// const total = phase.progress.total
|
||||
// const step = total / 4
|
||||
// let done = phase.progress.done
|
||||
|
||||
// return interval(1000).pipe(
|
||||
// takeWhile(() => done < total),
|
||||
// map(() => {
|
||||
// done += step
|
||||
|
||||
// console.error(done)
|
||||
|
||||
// if (
|
||||
// full.overall &&
|
||||
// typeof full.overall === 'object' &&
|
||||
// full.overall.total
|
||||
// ) {
|
||||
// const step = full.overall.total / numPhases / 4
|
||||
|
||||
// full.overall.done += step
|
||||
// }
|
||||
|
||||
// if (done === total) {
|
||||
// full.phases[i].progress = true
|
||||
|
||||
// if (i === numPhases - 1) {
|
||||
// full.overall = true
|
||||
// }
|
||||
// }
|
||||
// return full
|
||||
// }),
|
||||
// )
|
||||
// }
|
||||
// }, full),
|
||||
// ),
|
||||
// ),
|
||||
// )
|
||||
// }
|
||||
private statusIndex = 0
|
||||
private installCompleted = false
|
||||
|
||||
openWebsocket$<T>(guid: string): Observable<T> {
|
||||
if (guid === 'logs-guid') {
|
||||
@@ -117,24 +39,13 @@ export class MockApiService extends ApiService {
|
||||
})),
|
||||
) as Observable<T>
|
||||
} else if (guid === 'progress-guid') {
|
||||
// @TODO Matt mock progress
|
||||
return interval(1000).pipe(
|
||||
first(),
|
||||
map(() => ({
|
||||
overall: true,
|
||||
phases: [
|
||||
{
|
||||
name: 'Preparing Data',
|
||||
progress: true,
|
||||
},
|
||||
{
|
||||
name: 'Transferring Data',
|
||||
progress: true,
|
||||
},
|
||||
{
|
||||
name: 'Finalizing Setup',
|
||||
progress: true,
|
||||
},
|
||||
{ name: 'Preparing Data', progress: true },
|
||||
{ name: 'Transferring Data', progress: true },
|
||||
{ name: 'Finalizing Setup', progress: true },
|
||||
],
|
||||
})),
|
||||
) as Observable<T>
|
||||
@@ -143,40 +54,44 @@ export class MockApiService extends ApiService {
|
||||
}
|
||||
}
|
||||
|
||||
private statusIndex = 0
|
||||
async getStatus(): Promise<T.SetupStatusRes | null> {
|
||||
await pauseFor(1000)
|
||||
async echo(params: EchoReq, url: string): Promise<string> {
|
||||
if (url) {
|
||||
const num = Math.floor(Math.random() * 10) + 1
|
||||
if (num > 8) return params.message
|
||||
throw new Error()
|
||||
}
|
||||
await pauseFor(500)
|
||||
return params.message
|
||||
}
|
||||
|
||||
async getStatus(): Promise<SetupStatusRes> {
|
||||
await pauseFor(500)
|
||||
|
||||
this.statusIndex++
|
||||
|
||||
switch (this.statusIndex) {
|
||||
case 2:
|
||||
return {
|
||||
status: 'running',
|
||||
progress: PROGRESS,
|
||||
guid: 'progress-guid',
|
||||
}
|
||||
case 3:
|
||||
return {
|
||||
status: 'complete',
|
||||
torAddresses: ['https://asdafsadasdasasdasdfasdfasdf.onion'],
|
||||
hostname: 'adjective-noun',
|
||||
lanAddress: 'https://adjective-noun.local',
|
||||
rootCa: encodeBase64(rootCA),
|
||||
}
|
||||
default:
|
||||
return null
|
||||
if (this.statusIndex === 1) {
|
||||
return { status: 'needs-install', keyboard: null }
|
||||
// return {
|
||||
// status: 'incomplete',
|
||||
// attach: false,
|
||||
// guid: 'mock-data-guid',
|
||||
// keyboard: null,
|
||||
// }
|
||||
}
|
||||
|
||||
if (this.statusIndex > 3) {
|
||||
return { status: 'complete' }
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'running',
|
||||
progress: PROGRESS,
|
||||
guid: 'progress-guid',
|
||||
}
|
||||
}
|
||||
|
||||
async getPubKey(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
|
||||
// randomly generated
|
||||
// const keystore = jose.JWK.createKeyStore()
|
||||
// this.pubkey = await keystore.generate('EC', 'P-256')
|
||||
|
||||
// generated from backend
|
||||
await pauseFor(300)
|
||||
this.pubkey = await jose.JWK.asKey({
|
||||
kty: 'EC',
|
||||
crv: 'P-256',
|
||||
@@ -185,88 +100,28 @@ export class MockApiService extends ApiService {
|
||||
})
|
||||
}
|
||||
|
||||
async getDrives(): Promise<DiskListResponse> {
|
||||
await pauseFor(1000)
|
||||
return [
|
||||
{
|
||||
logicalname: '/dev/nvme0n1p3',
|
||||
vendor: 'Unknown Vendor',
|
||||
model: 'Samsung SSD - 970 EVO Plus 2TB',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: 'pabcd',
|
||||
label: null,
|
||||
capacity: 1979120929996,
|
||||
used: null,
|
||||
startOs: {
|
||||
'1234-5678-9876-5432': {
|
||||
hostname: 'adjective-noun',
|
||||
version: '0.2.17',
|
||||
timestamp: new Date().toISOString(),
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
},
|
||||
},
|
||||
guid: null,
|
||||
},
|
||||
],
|
||||
capacity: 1979120929996,
|
||||
guid: 'uuid-uuid-uuid-uuid',
|
||||
},
|
||||
{
|
||||
logicalname: 'dcba',
|
||||
vendor: 'CT1000MX',
|
||||
model: '500SSD1',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: 'pbcba',
|
||||
label: null,
|
||||
capacity: 73264762332,
|
||||
used: null,
|
||||
startOs: {
|
||||
'1234-5678-9876-5432': {
|
||||
hostname: 'adjective-noun',
|
||||
version: '0.2.17',
|
||||
timestamp: new Date().toISOString(),
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
},
|
||||
},
|
||||
guid: null,
|
||||
},
|
||||
],
|
||||
capacity: 1000190509056,
|
||||
guid: null,
|
||||
},
|
||||
{
|
||||
logicalname: '/dev/sda',
|
||||
vendor: 'ASMT',
|
||||
model: '2115',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: 'pbcba',
|
||||
label: null,
|
||||
capacity: 73264762332,
|
||||
used: null,
|
||||
startOs: {
|
||||
'1234-5678-9876-5432': {
|
||||
hostname: 'adjective-noun',
|
||||
version: '0.2.17',
|
||||
timestamp: new Date().toISOString(),
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
},
|
||||
},
|
||||
guid: 'guid-guid-guid-guid',
|
||||
},
|
||||
],
|
||||
capacity: 1000190509,
|
||||
guid: null,
|
||||
},
|
||||
]
|
||||
async setKeyboard(_params: FullKeyboard): Promise<null> {
|
||||
await pauseFor(300)
|
||||
return null
|
||||
}
|
||||
|
||||
async setLanguage(params: SetLanguageParams): Promise<null> {
|
||||
await pauseFor(300)
|
||||
return null
|
||||
}
|
||||
|
||||
async getDisks(): Promise<DiskInfo[]> {
|
||||
await pauseFor(500)
|
||||
return MOCK_DISKS
|
||||
}
|
||||
|
||||
async installOs(params: InstallOsParams): Promise<InstallOsRes> {
|
||||
await pauseFor(2000)
|
||||
this.installCompleted = true
|
||||
return {
|
||||
guid: 'mock-data-guid',
|
||||
attach: !params.dataDrive.wipe,
|
||||
}
|
||||
}
|
||||
|
||||
async verifyCifs(
|
||||
@@ -282,21 +137,29 @@ export class MockApiService extends ApiService {
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: '',
|
||||
},
|
||||
'9876-5432-1234-5671': {
|
||||
hostname: 'adjective-noun',
|
||||
version: '0.3.6',
|
||||
timestamp: new Date().toISOString(),
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: '',
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async attach(params: T.AttachParams): Promise<T.SetupProgress> {
|
||||
async attach(params: AttachParams): Promise<T.SetupProgress> {
|
||||
await pauseFor(1000)
|
||||
|
||||
this.statusIndex = 1 // Jump to running state
|
||||
return {
|
||||
progress: PROGRESS,
|
||||
guid: 'progress-guid',
|
||||
}
|
||||
}
|
||||
|
||||
async execute(setupInfo: T.SetupExecuteParams): Promise<T.SetupProgress> {
|
||||
async execute(params: SetupExecuteParams): Promise<T.SetupProgress> {
|
||||
await pauseFor(1000)
|
||||
|
||||
this.statusIndex = 1 // Jump to running state
|
||||
return {
|
||||
progress: PROGRESS,
|
||||
guid: 'progress-guid',
|
||||
@@ -304,33 +167,113 @@ export class MockApiService extends ApiService {
|
||||
}
|
||||
|
||||
async initFollowLogs(): Promise<FollowLogsRes> {
|
||||
await pauseFor(1000)
|
||||
await pauseFor(500)
|
||||
return {
|
||||
startCursor: 'fakestartcursor',
|
||||
guid: 'logs-guid',
|
||||
}
|
||||
}
|
||||
|
||||
async complete(): Promise<T.SetupResult> {
|
||||
await pauseFor(1000)
|
||||
async complete(): Promise<SetupCompleteRes> {
|
||||
await pauseFor(500)
|
||||
return {
|
||||
torAddresses: ['https://asdafsadasdasasdasdfasdfasdf.onion'],
|
||||
hostname: 'adjective-noun',
|
||||
lanAddress: 'https://adjective-noun.local',
|
||||
rootCa: encodeBase64(rootCA),
|
||||
rootCa: encodeBase64(ROOT_CA),
|
||||
needsRestart: this.installCompleted,
|
||||
}
|
||||
}
|
||||
|
||||
async exit(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
await pauseFor(500)
|
||||
}
|
||||
|
||||
async shutdown(): Promise<void> {
|
||||
await pauseFor(500)
|
||||
}
|
||||
|
||||
async restart(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
await pauseFor(500)
|
||||
}
|
||||
}
|
||||
|
||||
const rootCA = `-----BEGIN CERTIFICATE-----
|
||||
const MOCK_DISKS: DiskInfo[] = [
|
||||
{
|
||||
logicalname: '/dev/sda',
|
||||
vendor: 'Samsung',
|
||||
model: 'SSD 970 EVO Plus',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: '/dev/sda1',
|
||||
label: null,
|
||||
capacity: 500000000000,
|
||||
used: null,
|
||||
startOs: {},
|
||||
guid: null,
|
||||
},
|
||||
],
|
||||
capacity: 500000000000,
|
||||
guid: null,
|
||||
},
|
||||
{
|
||||
logicalname: '/dev/sdb',
|
||||
vendor: 'Crucial',
|
||||
model: 'MX500',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: '/dev/sdb1',
|
||||
label: null,
|
||||
capacity: 1000000000000,
|
||||
used: null,
|
||||
startOs: {
|
||||
'1234-5678-9876-5432': {
|
||||
hostname: 'existing-server',
|
||||
version: '0.3.6',
|
||||
timestamp: new Date().toISOString(),
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: null,
|
||||
},
|
||||
},
|
||||
guid: 'existing-guid',
|
||||
},
|
||||
],
|
||||
capacity: 1000000000000,
|
||||
guid: 'existing-guid',
|
||||
},
|
||||
{
|
||||
logicalname: '/dev/sdc',
|
||||
vendor: 'WD',
|
||||
model: 'Blue SN570',
|
||||
partitions: [
|
||||
{
|
||||
logicalname: '/dev/sdc1',
|
||||
label: 'Backup',
|
||||
capacity: 2000000000000,
|
||||
used: 500000000000,
|
||||
startOs: {
|
||||
'backup-server-id': {
|
||||
hostname: 'backup-server',
|
||||
version: '0.3.5',
|
||||
timestamp: new Date(Date.now() - 86400000).toISOString(),
|
||||
passwordHash:
|
||||
'$argon2d$v=19$m=1024,t=1,p=1$YXNkZmFzZGZhc2RmYXNkZg$Ceev1I901G6UwU+hY0sHrFZ56D+o+LNJ',
|
||||
wrappedKey: '',
|
||||
},
|
||||
},
|
||||
guid: null,
|
||||
},
|
||||
],
|
||||
capacity: 2000000000000,
|
||||
guid: null,
|
||||
},
|
||||
]
|
||||
|
||||
const PROGRESS: T.FullProgress = {
|
||||
overall: null,
|
||||
phases: [],
|
||||
}
|
||||
|
||||
const ROOT_CA = `-----BEGIN CERTIFICATE-----
|
||||
MIIDpzCCAo+gAwIBAgIRAIIuOarlQETlUQEOZJGZYdIwDQYJKoZIhvcNAQELBQAw
|
||||
bTELMAkGA1UEBhMCVVMxFTATBgNVBAoMDEV4YW1wbGUgQ29ycDEOMAwGA1UECwwF
|
||||
U2FsZXMxCzAJBgNVBAgMAldBMRgwFgYDVQQDDA93d3cuZXhhbXBsZS5jb20xEDAO
|
||||
@@ -352,8 +295,3 @@ Rf3ZOPm9QP92YpWyYDkfAU04xdDo1vR0MYjKPkl4LjRqSU/tcCJnPMbJiwq+bWpX
|
||||
2WJoEBXB/p15Kn6JxjI0ze2SnSI48JZ8it4fvxrhOo0VoLNIuCuNXJOwU17Rdl1W
|
||||
YJidaq7je6k18AdgPA0Kh8y1XtfUH3fTaVw4
|
||||
-----END CERTIFICATE-----`
|
||||
|
||||
const PROGRESS = {
|
||||
overall: null,
|
||||
phases: [],
|
||||
}
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { ApiService } from './api.service'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { ApiService } from './api.service'
|
||||
|
||||
export type SetupType = 'fresh' | 'restore' | 'attach' | 'transfer'
|
||||
|
||||
export type RecoverySource =
|
||||
| {
|
||||
type: 'migrate'
|
||||
guid: string
|
||||
}
|
||||
| {
|
||||
type: 'backup'
|
||||
target:
|
||||
| { type: 'disk'; logicalname: string }
|
||||
| {
|
||||
type: 'cifs'
|
||||
hostname: string
|
||||
path: string
|
||||
username: string
|
||||
password: string | null
|
||||
}
|
||||
serverId: string
|
||||
password: string // plaintext, will be encrypted before sending
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -8,34 +30,68 @@ import { T } from '@start9labs/start-sdk'
|
||||
export class StateService {
|
||||
private readonly api = inject(ApiService)
|
||||
|
||||
kiosk?: boolean
|
||||
setupType?: 'fresh' | 'restore' | 'attach' | 'transfer'
|
||||
recoverySource?: T.RecoverySource<string>
|
||||
// Determined at app init
|
||||
kiosk = false
|
||||
|
||||
async importDrive(guid: string, password: string): Promise<void> {
|
||||
// Set during install flow, or loaded from status response
|
||||
language = ''
|
||||
keyboard = ''
|
||||
|
||||
// From install response or status response (incomplete)
|
||||
dataDriveGuid = ''
|
||||
attach = false
|
||||
|
||||
// Set during setup flow
|
||||
setupType?: SetupType
|
||||
recoverySource?: RecoverySource
|
||||
|
||||
/**
|
||||
* Called for attach flow (existing data drive)
|
||||
*/
|
||||
async attachDrive(password: string | null): Promise<void> {
|
||||
await this.api.attach({
|
||||
guid,
|
||||
startOsPassword: await this.api.encrypt(password),
|
||||
kiosk: this.kiosk,
|
||||
guid: this.dataDriveGuid,
|
||||
password: password ? await this.api.encrypt(password) : null,
|
||||
})
|
||||
}
|
||||
|
||||
async setupEmbassy(
|
||||
storageLogicalname: string,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
/**
|
||||
* Called for fresh, restore, and transfer flows
|
||||
* password is required for fresh, optional for restore/transfer
|
||||
*/
|
||||
async executeSetup(password: string | null): Promise<void> {
|
||||
let recoverySource: T.RecoverySource<T.EncryptedWire> | null = null
|
||||
|
||||
if (this.recoverySource) {
|
||||
if (this.recoverySource.type === 'migrate') {
|
||||
recoverySource = this.recoverySource
|
||||
} else {
|
||||
// backup type - need to encrypt the backup password
|
||||
recoverySource = {
|
||||
type: 'backup',
|
||||
target: this.recoverySource.target,
|
||||
serverId: this.recoverySource.serverId,
|
||||
password: await this.api.encrypt(this.recoverySource.password),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await this.api.execute({
|
||||
startOsLogicalname: storageLogicalname,
|
||||
startOsPassword: await this.api.encrypt(password),
|
||||
recoverySource: this.recoverySource
|
||||
? this.recoverySource.type === 'migrate'
|
||||
? this.recoverySource
|
||||
: {
|
||||
...this.recoverySource,
|
||||
password: await this.api.encrypt(this.recoverySource.password),
|
||||
}
|
||||
: null,
|
||||
kiosk: this.kiosk,
|
||||
guid: this.dataDriveGuid,
|
||||
password: password ? await this.api.encrypt(password) : null,
|
||||
recoverySource,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset state for a fresh start
|
||||
*/
|
||||
reset(): void {
|
||||
this.language = ''
|
||||
this.keyboard = ''
|
||||
this.dataDriveGuid = ''
|
||||
this.attach = false
|
||||
this.setupType = undefined
|
||||
this.recoverySource = undefined
|
||||
}
|
||||
}
|
||||
|
||||
94
web/projects/setup-wizard/src/app/types.ts
Normal file
94
web/projects/setup-wizard/src/app/types.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import {
|
||||
DiskInfo,
|
||||
FullKeyboard,
|
||||
PartitionInfo,
|
||||
StartOSDiskInfo,
|
||||
} from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
|
||||
// === Echo ===
|
||||
|
||||
export type EchoReq = {
|
||||
message: string
|
||||
}
|
||||
|
||||
// === Setup Status ===
|
||||
|
||||
export type SetupStatusRes =
|
||||
| { status: 'needs-install'; keyboard: FullKeyboard | null }
|
||||
| {
|
||||
status: 'incomplete'
|
||||
guid: string
|
||||
attach: boolean
|
||||
keyboard: FullKeyboard | null
|
||||
}
|
||||
| { status: 'running'; progress: T.FullProgress; guid: string }
|
||||
| { status: 'complete' }
|
||||
|
||||
// === Install OS ===
|
||||
|
||||
export interface InstallOsParams {
|
||||
osDrive: string // e.g. /dev/sda
|
||||
dataDrive: {
|
||||
logicalname: string // e.g. /dev/sda, /dev/sdb3
|
||||
wipe: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface InstallOsRes {
|
||||
guid: string // data drive guid
|
||||
attach: boolean
|
||||
}
|
||||
|
||||
// === Attach ===
|
||||
|
||||
export interface AttachParams {
|
||||
password: T.EncryptedWire | null
|
||||
guid: string // data drive
|
||||
}
|
||||
|
||||
// === Execute ===
|
||||
|
||||
export interface SetupExecuteParams {
|
||||
guid: string
|
||||
password: T.EncryptedWire | null // null = keep existing password (for restore/transfer)
|
||||
recoverySource:
|
||||
| {
|
||||
type: 'migrate'
|
||||
guid: string
|
||||
}
|
||||
| {
|
||||
type: 'backup'
|
||||
target:
|
||||
| { type: 'disk'; logicalname: string }
|
||||
| {
|
||||
type: 'cifs'
|
||||
hostname: string
|
||||
path: string
|
||||
username: string
|
||||
password: string | null
|
||||
}
|
||||
password: T.EncryptedWire
|
||||
serverId: string
|
||||
}
|
||||
| null
|
||||
}
|
||||
|
||||
// === Complete ===
|
||||
|
||||
export interface SetupCompleteRes {
|
||||
hostname: string // unique.local
|
||||
rootCa: string
|
||||
needsRestart: boolean
|
||||
}
|
||||
|
||||
// === Disk Info Helpers ===
|
||||
|
||||
export type StartOSDiskInfoWithId = StartOSDiskInfo & {
|
||||
id: string
|
||||
}
|
||||
|
||||
export type StartOSDiskInfoFull = StartOSDiskInfoWithId & {
|
||||
partition: PartitionInfo
|
||||
drive: DiskInfo
|
||||
}
|
||||
@@ -19,16 +19,29 @@ router-outlet + * {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
margin: 0 auto;
|
||||
|
||||
[tuiCardLarge] {
|
||||
width: 100%;
|
||||
background: var(--tui-background-elevation-2);
|
||||
margin: auto;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
.inline-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
|
||||
:first-child {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: var(--tui-disabled-opacity);
|
||||
pointer-events: none;
|
||||
@@ -74,4 +87,4 @@ h2 {
|
||||
|
||||
[tuiCell]:not(:last-of-type) {
|
||||
box-shadow: 0 calc(0.5rem + 1px) 0 -0.5rem var(--tui-border-normal);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -99,6 +99,7 @@ export default {
|
||||
101: 'Sie haben nicht gespeicherte Änderungen. Möchten Sie die Seite wirklich verlassen?',
|
||||
102: 'Verlassen',
|
||||
103: 'Sind Sie sicher?',
|
||||
104: 'Neues Netzwerk-Gateway',
|
||||
108: 'Öffentlich',
|
||||
109: 'privat',
|
||||
111: 'Keine Onion-Domains',
|
||||
@@ -493,6 +494,7 @@ export default {
|
||||
516: 'Empfohlen',
|
||||
517: 'Möchten Sie diese Aufgabe wirklich verwerfen?',
|
||||
518: 'Verwerfen',
|
||||
519: 'Muss eine gültige IPv4- oder IPv6-Adresse mit optionalem Port sein',
|
||||
520: 'Update verfügbar',
|
||||
521: 'Um das Problem zu beheben, siehe',
|
||||
522: 'SDK version',
|
||||
@@ -582,10 +584,10 @@ export default {
|
||||
612: 'Grund',
|
||||
613: 'Private Gateways für die StartOS-Benutzeroberfläche können nicht deaktiviert werden',
|
||||
614: 'Root-CA',
|
||||
615: 'DHCP-Server',
|
||||
616: 'DHCP-Server können nicht bearbeitet werden',
|
||||
615: 'Die von Ihrem Router bereitgestellten DNS-Server verwenden',
|
||||
616: 'Manuell festgelegte DNS-Server verwenden',
|
||||
617: 'Statisch',
|
||||
618: 'Statische Server',
|
||||
618: 'Server',
|
||||
619: 'Warnung. StartOS verwendet derzeit das folgende Gateway für DNS',
|
||||
620: 'Wenn Sie dieses Gateway für die Auflösung privater Domains verwenden möchten, legen Sie alternative statische DNS-Server mit dem obigen Formular fest.',
|
||||
621: 'Einen Dienst paketieren',
|
||||
@@ -596,5 +598,86 @@ export default {
|
||||
626: 'Hochladen',
|
||||
627: 'UI öffnen',
|
||||
628: 'In Zwischenablage kopiert',
|
||||
629: 'Die Liste ist leer',
|
||||
629: 'StartOS wird installiert',
|
||||
630: 'Wähle deine Sprache',
|
||||
631: 'Tastaturlayout auswählen',
|
||||
632: 'Tastatur',
|
||||
633: 'Einrichtungsablauf auswählen',
|
||||
634: 'Neu beginnen',
|
||||
635: 'Einen brandneuen Server einrichten',
|
||||
636: 'Aus Backup wiederherstellen',
|
||||
637: 'StartOS-Daten aus einem verschlüsselten Backup wiederherstellen',
|
||||
638: 'Daten von einem bestehenden StartOS-Datenträger übertragen',
|
||||
639: 'Laufwerke auswählen',
|
||||
640: 'Keine Laufwerke gefunden. Bitte schließen Sie ein Laufwerk an und klicken Sie auf Aktualisieren.',
|
||||
641: 'OS-Laufwerk',
|
||||
642: 'Datenlaufwerk',
|
||||
643: 'StartOS-Daten erkannt',
|
||||
644: 'Daten auf dem OS-Laufwerk könnten überschrieben werden.',
|
||||
645: 'Ihre StartOS-Daten auf dem Datenlaufwerk bleiben erhalten.',
|
||||
646: 'Daten auf diesem Laufwerk werden überschrieben.',
|
||||
647: 'Daten auf beiden Laufwerken werden überschrieben.',
|
||||
648: 'Backup auswählen',
|
||||
649: 'Wählen Sie das StartOS-Backup aus, das Sie wiederherstellen möchten',
|
||||
650: 'Netzwerk-Backup öffnen',
|
||||
651: 'Physische Backups',
|
||||
652: 'Keine physischen Backups',
|
||||
653: 'Netzwerkordner verbinden',
|
||||
654: 'Netzwerk-Backup auswählen',
|
||||
655: 'Backup entsperren',
|
||||
656: 'Daten übertragen',
|
||||
657: 'Wählen Sie das Laufwerk mit Ihren bestehenden StartOS-Daten aus',
|
||||
658: 'Laufwerk auswählen',
|
||||
659: 'Keine StartOS-Datenlaufwerke gefunden',
|
||||
660: 'Master-Passwort festlegen',
|
||||
661: 'Neues Passwort festlegen (optional)',
|
||||
662: 'Machen Sie es gut. Schreiben Sie es auf.',
|
||||
663: 'Überspringen, um Ihr bestehendes Passwort beizubehalten.',
|
||||
664: 'Passwort eingeben',
|
||||
665: 'Passwort bestätigen',
|
||||
666: 'Fertigstellen',
|
||||
667: 'Einrichtung wird gestartet',
|
||||
670: 'Warten Sie 1–2 Minuten und aktualisieren Sie die Seite',
|
||||
672: 'Einrichtung abgeschlossen!',
|
||||
673: 'Sie können Ihr Backup-Laufwerk entfernen',
|
||||
674: 'Sie können Ihr Übertragungs-Laufwerk entfernen',
|
||||
675: 'http://start.local war nur für die Einrichtung gedacht. Es funktioniert nicht mehr.',
|
||||
676: 'Adressinformationen herunterladen',
|
||||
677: 'Enthält die permanente lokale Adresse Ihres Servers und die Root-CA',
|
||||
678: 'USB entfernt',
|
||||
679: 'Entfernen Sie das USB-Installationsmedium aus Ihrem Server',
|
||||
680: 'Server neu starten',
|
||||
681: 'Warten, bis der Server wieder online ist',
|
||||
682: 'Server ist wieder online',
|
||||
683: 'Starten Sie Ihren Server neu, um die Einrichtung abzuschließen',
|
||||
684: 'Lokale Adresse öffnen',
|
||||
685: 'Weiter zur Anmeldung',
|
||||
686: 'Zur StartOS-Anmeldeseite wechseln',
|
||||
687: 'Der Server ist nicht wieder online gegangen. Bitte überprüfen Sie Ihren Server und versuchen Sie, manuell darauf zuzugreifen.',
|
||||
691: 'Dieses Feld ist erforderlich',
|
||||
692: 'Verbindung fehlgeschlagen',
|
||||
693: 'Verbindung zum Netzwerkordner nicht möglich. Stellen Sie sicher, dass (1) der Zielcomputer mit dem LAN verbunden ist, (2) der Zielordner freigegeben ist und (3) Hostname, Pfad und Anmeldedaten korrekt sind.',
|
||||
694: 'Dieses Laufwerk enthält bestehende StartOS-Daten.',
|
||||
695: 'um Ihre Daten zu behalten.',
|
||||
696: 'um zu verwerfen',
|
||||
697: 'Geben Sie das Passwort ein, das zum Verschlüsseln dieses Backups verwendet wurde.',
|
||||
698: 'Mehrere Backups gefunden. Wählen Sie aus, welches wiederhergestellt werden soll.',
|
||||
699: 'Backups',
|
||||
700: 'Das Laufwerk, auf dem das StartOS-Betriebssystem installiert wird.',
|
||||
701: 'Das Laufwerk, auf dem Ihre StartOS-Daten (Dienste, Einstellungen usw.) gespeichert werden. Dies kann dasselbe wie das OS-Laufwerk oder ein separates Laufwerk sein.',
|
||||
702: 'Versuchen Sie nach der Datenübertragung von diesem Laufwerk nicht, erneut als Start9-Server davon zu booten. Dies kann zu Fehlfunktionen von Diensten, Datenbeschädigung oder Geldverlust führen.',
|
||||
703: 'Muss mindestens 12 Zeichen lang sein',
|
||||
704: 'Darf höchstens 64 Zeichen lang sein',
|
||||
705: 'Passwörter stimmen nicht überein',
|
||||
706: 'Beibehalten',
|
||||
707: 'Überschreiben',
|
||||
708: 'Entsperren',
|
||||
709: 'Laufwerk',
|
||||
710: 'Übertragen',
|
||||
711: 'Die Liste ist leer',
|
||||
712: 'Jetzt neu starten',
|
||||
713: 'Später',
|
||||
714: 'Installation abgeschlossen!',
|
||||
715: 'StartOS wurde erfolgreich installiert.',
|
||||
716: 'Weiter zur Einrichtung',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -56,7 +56,7 @@ export const ENGLISH = {
|
||||
'Beginning shutdown': 57,
|
||||
'Add': 58,
|
||||
'Ok': 59,
|
||||
'french': 60,
|
||||
'fr_FR': 60,
|
||||
'This value cannot be changed once set': 61,
|
||||
'Continue': 62,
|
||||
'Click or drop file here': 63,
|
||||
@@ -98,6 +98,7 @@ export const ENGLISH = {
|
||||
'You have unsaved changes. Are you sure you want to leave?': 101,
|
||||
'Leave': 102,
|
||||
'Are you sure?': 103,
|
||||
'New gateway': 104, // as in, a network gateway
|
||||
'public': 108,
|
||||
'private': 109,
|
||||
'No Tor domains': 111,
|
||||
@@ -462,10 +463,10 @@ export const ENGLISH = {
|
||||
'StartOS UI': 485,
|
||||
'WiFi': 486,
|
||||
'Documentation': 487, // as in, a website to view documentation
|
||||
'spanish': 488,
|
||||
'polish': 489,
|
||||
'german': 490,
|
||||
'english': 491,
|
||||
'es_ES': 488,
|
||||
'pl_PL': 489,
|
||||
'de_DE': 490,
|
||||
'en_US': 491,
|
||||
'Start Menu': 492,
|
||||
'Install Progress': 493,
|
||||
'Downloading': 494,
|
||||
@@ -492,6 +493,7 @@ export const ENGLISH = {
|
||||
'Recommended': 516, // as in, we recommend this
|
||||
'Are you sure you want to dismiss this task?': 517,
|
||||
'Dismiss': 518, // as in, dismiss or delete a task
|
||||
'Must be a valid IPv4 or Ipv6 address with optional port': 519,
|
||||
'Update available': 520,
|
||||
'To resolve the issue, refer to': 521,
|
||||
'SDK version': 522,
|
||||
@@ -581,10 +583,10 @@ export const ENGLISH = {
|
||||
'Reason': 612, // as in, an explanation for something
|
||||
'Cannot disable private gateways for StartOS UI': 613,
|
||||
'Root CA': 614, // as in, the unique, fixed-length digital identifier generated from a certificate's data using a cryptographic hash function
|
||||
'DHCP Servers': 615,
|
||||
'Cannot edit DHCP servers': 616,
|
||||
'Use the DNS servers provided by your router': 615,
|
||||
'Use DNS servers you specify manually': 616,
|
||||
'Static': 617, // as in, unchanging
|
||||
'Static Servers': 618, // as in, servers that do not change
|
||||
'Servers': 618, // as in, computer servers
|
||||
'Warning. StartOS is currently using the following gateway for DNS': 619,
|
||||
'If you intend to use this gateway for private domain resolution, set alternative static DNS servers using the form above.': 620,
|
||||
'Package a service': 621, // as in, package a software application for an operating system
|
||||
@@ -595,5 +597,87 @@ export const ENGLISH = {
|
||||
'Upload': 626, // as in, upload a file
|
||||
'Open UI': 627, // as in, upload a file
|
||||
'Copied to clipboard': 628,
|
||||
'The list is empty': 629,
|
||||
} as Record<any, any>
|
||||
// Setup wizard keys
|
||||
'Installing StartOS': 629,
|
||||
'Select your language': 630,
|
||||
'Select Keyboard Layout': 631,
|
||||
'Keyboard': 632,
|
||||
'Select Setup Flow': 633,
|
||||
'Start Fresh': 634,
|
||||
'Set up a brand new server': 635,
|
||||
'Restore from Backup': 636,
|
||||
'Restore StartOS data from an encrypted backup': 637,
|
||||
'Transfer data from an existing StartOS data drive': 638,
|
||||
'Select Drives': 639, // as in storage devices
|
||||
'No drives found. Please connect a drive and click Refresh.': 640,
|
||||
'OS Drive': 641,
|
||||
'Data Drive': 642,
|
||||
'StartOS Data Detected': 643,
|
||||
'Data on the OS drive may be overwritten.': 644,
|
||||
'your StartOS data on the data drive will be preserved.': 645,
|
||||
'Data on this drive will be overwritten.': 646,
|
||||
'Data on both drives will be overwritten.': 647,
|
||||
'Select Backup': 648,
|
||||
'Select the StartOS backup you want to restore': 649,
|
||||
'Open Network Backup': 650, // as in, a backup stored on a networked device
|
||||
'Physical Backups': 651, // as in, a backup stored on a physical drive
|
||||
'No physical backups': 652,
|
||||
'Connect Network Folder': 653,
|
||||
'Select Network Backup': 654,
|
||||
'Unlock Backup': 655,
|
||||
'Transfer Data': 656,
|
||||
'Select the drive containing your existing StartOS data': 657,
|
||||
'Select Drive': 658,
|
||||
'No StartOS data drives found': 659,
|
||||
'Set Master Password': 660,
|
||||
'Set New Password (Optional)': 661,
|
||||
'Make it good. Write it down.': 662,
|
||||
'Skip to keep your existing password.': 663,
|
||||
'Enter Password': 664,
|
||||
'Confirm Password': 665,
|
||||
'Finish': 666,
|
||||
'Starting setup': 667,
|
||||
'Wait 1-2 minutes and refresh the page': 670,
|
||||
'Setup Complete!': 672,
|
||||
'You can unplug your backup drive': 673,
|
||||
'You can unplug your transfer drive': 674,
|
||||
'http://start.local was for setup only. It will no longer work.': 675,
|
||||
'Download Address Info': 676,
|
||||
"Contains your server's permanent local address and Root CA": 677,
|
||||
'USB Removed': 678,
|
||||
'Remove the USB installation media from your server': 679,
|
||||
'Restart Server': 680,
|
||||
'Waiting for server to come back online': 681,
|
||||
'Server is back online': 682,
|
||||
'Restart your server to complete setup': 683,
|
||||
'Open Local Address': 684,
|
||||
'Continue to Login': 685,
|
||||
'Proceed to the StartOS login screen': 686,
|
||||
'Server did not come back online. Please check your server and try accessing it manually.': 687,
|
||||
'This field is required': 691,
|
||||
'Connection Failed': 692,
|
||||
'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.': 693,
|
||||
'This drive contains existing StartOS data.': 694,
|
||||
'to keep your data.': 695,
|
||||
'to discard': 696,
|
||||
'Enter the password that was used to encrypt this backup.': 697,
|
||||
'Multiple backups found. Select which one to restore.': 698,
|
||||
'Backups': 699,
|
||||
'The drive where the StartOS operating system will be installed.': 700,
|
||||
'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.': 701,
|
||||
'After transferring data from this drive, do not attempt to boot into it again as a Start9 Server. This may result in services malfunctioning, data corruption, or loss of funds.': 702,
|
||||
'Must be 12 characters or greater': 703,
|
||||
'Must be 64 character or less': 704,
|
||||
'Passwords do not match': 705,
|
||||
'Preserve': 706,
|
||||
'Overwrite': 707,
|
||||
'Unlock': 708,
|
||||
'Drive': 709, // the noun, a storage device
|
||||
'Transfer': 710, // the verb
|
||||
'The list is empty': 711,
|
||||
'Restart now': 712,
|
||||
'Later': 713, // as in, (do it) later
|
||||
'Installation Complete!': 714,
|
||||
'StartOS has been installed successfully.': 715,
|
||||
'Continue to Setup': 716,
|
||||
} as const
|
||||
|
||||
@@ -99,6 +99,7 @@ export default {
|
||||
101: 'Tienes cambios no guardados. ¿Estás seguro de que deseas salir?',
|
||||
102: 'Salir',
|
||||
103: '¿Estás seguro?',
|
||||
104: 'Nueva puerta de enlace de red',
|
||||
108: 'público',
|
||||
109: 'privado',
|
||||
111: 'Sin dominios onion',
|
||||
@@ -493,6 +494,7 @@ export default {
|
||||
516: 'Recomendado',
|
||||
517: '¿Estás seguro de que deseas descartar esta tarea?',
|
||||
518: 'Descartar',
|
||||
519: 'Debe ser una dirección IPv4 o IPv6 válida con puerto opcional',
|
||||
520: 'Actualización disponible',
|
||||
521: 'Para resolver el problema, consulta',
|
||||
522: 'Versión de SDK',
|
||||
@@ -582,10 +584,10 @@ export default {
|
||||
612: 'Razón',
|
||||
613: 'No se pueden deshabilitar las puertas de enlace privadas para la interfaz de usuario de StartOS',
|
||||
614: 'CA raíz',
|
||||
615: 'Servidores DHCP',
|
||||
616: 'No se pueden editar los servidores DHCP',
|
||||
615: 'Usar los servidores DNS proporcionados por su router',
|
||||
616: 'Usar servidores DNS que especifique manualmente',
|
||||
617: 'Estático',
|
||||
618: 'Servidores estáticos',
|
||||
618: 'Servidores',
|
||||
619: 'Advertencia. StartOS está utilizando actualmente la siguiente puerta de enlace para DNS',
|
||||
620: 'Si deseas usar esta puerta de enlace para la resolución de dominios privados, configura servidores DNS estáticos alternativos usando el formulario anterior.',
|
||||
621: 'Empaquetar un servicio',
|
||||
@@ -596,5 +598,86 @@ export default {
|
||||
626: 'Subir',
|
||||
627: 'Abrir UI',
|
||||
628: 'Copiado al portapapeles',
|
||||
629: 'La lista está vacía',
|
||||
629: 'Instalando StartOS',
|
||||
630: 'Selecciona tu idioma',
|
||||
631: 'Seleccionar distribución del teclado',
|
||||
632: 'Teclado',
|
||||
633: 'Seleccionar flujo de configuración',
|
||||
634: 'Empezar desde cero',
|
||||
635: 'Configurar un servidor completamente nuevo',
|
||||
636: 'Restaurar desde copia de seguridad',
|
||||
637: 'Restaurar datos de StartOS desde una copia de seguridad cifrada',
|
||||
638: 'Transferir datos desde una unidad de datos StartOS existente',
|
||||
639: 'Seleccionar unidades',
|
||||
640: 'No se encontraron unidades. Conecte una unidad y haga clic en Actualizar.',
|
||||
641: 'Unidad del sistema operativo',
|
||||
642: 'Unidad de datos',
|
||||
643: 'Datos de StartOS detectados',
|
||||
644: 'Los datos de la unidad del sistema operativo pueden sobrescribirse.',
|
||||
645: 'Sus datos de StartOS en la unidad de datos se conservarán.',
|
||||
646: 'Los datos de esta unidad se sobrescribirán.',
|
||||
647: 'Los datos de ambas unidades se sobrescribirán.',
|
||||
648: 'Seleccionar copia de seguridad',
|
||||
649: 'Seleccione la copia de seguridad de StartOS que desea restaurar',
|
||||
650: 'Abrir copia de seguridad de red',
|
||||
651: 'Copias de seguridad físicas',
|
||||
652: 'No hay copias de seguridad físicas',
|
||||
653: 'Conectar carpeta de red',
|
||||
654: 'Seleccionar copia de seguridad de red',
|
||||
655: 'Desbloquear copia de seguridad',
|
||||
656: 'Transferir datos',
|
||||
657: 'Seleccione la unidad que contiene sus datos StartOS existentes',
|
||||
658: 'Seleccionar unidad',
|
||||
659: 'No se encontraron unidades de datos StartOS',
|
||||
660: 'Establecer contraseña maestra',
|
||||
661: 'Establecer nueva contraseña (opcional)',
|
||||
662: 'Que sea buena. Escríbala.',
|
||||
663: 'Omitir para mantener su contraseña existente.',
|
||||
664: 'Introducir contraseña',
|
||||
665: 'Confirmar contraseña',
|
||||
666: 'Finalizar',
|
||||
667: 'Iniciando configuración',
|
||||
670: 'Espere 1–2 minutos y actualice la página',
|
||||
672: '¡Configuración completa!',
|
||||
673: 'Puede desconectar su unidad de copia de seguridad',
|
||||
674: 'Puede desconectar su unidad de transferencia',
|
||||
675: 'http://start.local era solo para la configuración. Ya no funcionará.',
|
||||
676: 'Descargar información de direcciones',
|
||||
677: 'Contiene la dirección local permanente de su servidor y la CA raíz',
|
||||
678: 'USB retirado',
|
||||
679: 'Retire el medio de instalación USB de su servidor',
|
||||
680: 'Reiniciar servidor',
|
||||
681: 'Esperando a que el servidor vuelva a estar en línea',
|
||||
682: 'El servidor ha vuelto a estar en línea',
|
||||
683: 'Reinicie su servidor para completar la configuración',
|
||||
684: 'Abrir dirección local',
|
||||
685: 'Continuar al inicio de sesión',
|
||||
686: 'Ir a la pantalla de inicio de sesión de StartOS',
|
||||
687: 'El servidor no volvió a estar en línea. Verifique su servidor e intente acceder manualmente.',
|
||||
691: 'Este campo es obligatorio',
|
||||
692: 'Conexión fallida',
|
||||
693: 'No se pudo conectar a la carpeta de red. Asegúrese de que (1) el equipo de destino esté conectado a la LAN, (2) la carpeta de destino esté compartida y (3) el nombre de host, la ruta y las credenciales sean correctos.',
|
||||
694: 'Esta unidad contiene datos existentes de StartOS.',
|
||||
695: 'para conservar sus datos.',
|
||||
696: 'para descartar',
|
||||
697: 'Introduzca la contraseña que se utilizó para cifrar esta copia de seguridad.',
|
||||
698: 'Se encontraron varias copias de seguridad. Seleccione cuál restaurar.',
|
||||
699: 'Copias de seguridad',
|
||||
700: 'La unidad donde se instalará el sistema operativo StartOS.',
|
||||
701: 'La unidad donde se almacenarán sus datos de StartOS (servicios, ajustes, etc.). Puede ser la misma que la unidad del sistema operativo o una unidad separada.',
|
||||
702: 'Después de transferir datos desde esta unidad, no intente arrancar desde ella nuevamente como un servidor Start9. Esto puede provocar fallos en los servicios, corrupción de datos o pérdida de fondos.',
|
||||
703: 'Debe tener 12 caracteres o más',
|
||||
704: 'Debe tener 64 caracteres o menos',
|
||||
705: 'Las contraseñas no coinciden',
|
||||
706: 'Conservar',
|
||||
707: 'Sobrescribir',
|
||||
708: 'Desbloquear',
|
||||
709: 'Unidad',
|
||||
710: 'Transferir',
|
||||
711: 'La lista está vacía',
|
||||
712: 'Reiniciar ahora',
|
||||
713: 'Más tarde',
|
||||
714: '¡Instalación completada!',
|
||||
715: 'StartOS se ha instalado correctamente.',
|
||||
716: 'Continuar con la configuración',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -99,6 +99,7 @@ export default {
|
||||
101: 'Vous avez des modifications non enregistrées. Voulez-vous vraiment quitter ?',
|
||||
102: 'Quitter',
|
||||
103: 'Êtes-vous sûr ?',
|
||||
104: 'Nouvelle passerelle réseau',
|
||||
108: 'public',
|
||||
109: 'privé',
|
||||
111: 'Aucune domaine onion',
|
||||
@@ -493,6 +494,7 @@ export default {
|
||||
516: 'Recommandé',
|
||||
517: 'Êtes-vous sûr de vouloir ignorer cette tâche ?',
|
||||
518: 'Ignorer',
|
||||
519: 'Doit être une adresse IPv4 ou IPv6 valide avec un port facultatif',
|
||||
520: 'Mise à jour disponible',
|
||||
521: 'Pour résoudre le problème, consultez',
|
||||
522: 'Version de SDK',
|
||||
@@ -582,10 +584,10 @@ export default {
|
||||
612: 'Raison',
|
||||
613: "Impossible de désactiver les passerelles privées pour l'interface utilisateur StartOS",
|
||||
614: 'CA racine',
|
||||
615: 'Serveurs DHCP',
|
||||
616: 'Impossible de modifier les serveurs DHCP',
|
||||
615: 'Utiliser les serveurs DNS fournis par votre routeur',
|
||||
616: 'Utiliser des serveurs DNS que vous spécifiez manuellement',
|
||||
617: 'Statique',
|
||||
618: 'Serveurs statiques',
|
||||
618: 'Serveurs',
|
||||
619: 'Avertissement. StartOS utilise actuellement la passerelle suivante pour le DNS',
|
||||
620: 'Si vous souhaitez utiliser cette passerelle pour la résolution de domaines privés, définissez des serveurs DNS statiques alternatifs à l’aide du formulaire ci-dessus.',
|
||||
621: 'Emballer un service',
|
||||
@@ -596,5 +598,86 @@ export default {
|
||||
626: 'Téléverser',
|
||||
627: 'Ouvrir UI',
|
||||
628: 'Copié dans le presse-papiers',
|
||||
629: 'La liste est vide',
|
||||
629: 'Installation de StartOS',
|
||||
630: 'Sélectionnez votre langue',
|
||||
631: 'Sélectionner la disposition du clavier',
|
||||
632: 'Clavier',
|
||||
633: 'Sélectionner le mode de configuration',
|
||||
634: 'Démarrer à neuf',
|
||||
635: 'Configurer un tout nouveau serveur',
|
||||
636: 'Restaurer à partir d’une sauvegarde',
|
||||
637: 'Restaurer les données StartOS à partir d’une sauvegarde chiffrée',
|
||||
638: 'Transférer les données depuis un disque de données StartOS existant',
|
||||
639: 'Sélectionner les disques',
|
||||
640: 'Aucun disque trouvé. Veuillez connecter un disque et cliquer sur Actualiser.',
|
||||
641: 'Disque du système',
|
||||
642: 'Disque de données',
|
||||
643: 'Données StartOS détectées',
|
||||
644: 'Les données du disque système peuvent être écrasées.',
|
||||
645: 'Vos données StartOS sur le disque de données seront conservées.',
|
||||
646: 'Les données de ce disque seront écrasées.',
|
||||
647: 'Les données des deux disques seront écrasées.',
|
||||
648: 'Sélectionner une sauvegarde',
|
||||
649: 'Sélectionnez la sauvegarde StartOS que vous souhaitez restaurer',
|
||||
650: 'Ouvrir une sauvegarde réseau',
|
||||
651: 'Sauvegardes physiques',
|
||||
652: 'Aucune sauvegarde physique',
|
||||
653: 'Connecter un dossier réseau',
|
||||
654: 'Sélectionner une sauvegarde réseau',
|
||||
655: 'Déverrouiller la sauvegarde',
|
||||
656: 'Transférer les données',
|
||||
657: 'Sélectionnez le disque contenant vos données StartOS existantes',
|
||||
658: 'Sélectionner le disque',
|
||||
659: 'Aucun disque de données StartOS trouvé',
|
||||
660: 'Définir le mot de passe maître',
|
||||
661: 'Définir un nouveau mot de passe (facultatif)',
|
||||
662: 'Choisissez-le bien. Notez-le.',
|
||||
663: 'Ignorer pour conserver votre mot de passe existant.',
|
||||
664: 'Saisir le mot de passe',
|
||||
665: 'Confirmer le mot de passe',
|
||||
666: 'Terminer',
|
||||
667: 'Démarrage de la configuration',
|
||||
670: 'Attendez 1 à 2 minutes puis actualisez la page',
|
||||
672: 'Configuration terminée !',
|
||||
673: 'Vous pouvez débrancher votre disque de sauvegarde',
|
||||
674: 'Vous pouvez débrancher votre disque de transfert',
|
||||
675: 'http://start.local était réservé à la configuration. Il ne fonctionnera plus.',
|
||||
676: 'Télécharger les informations d’adresse',
|
||||
677: 'Contient l’adresse locale permanente de votre serveur et la CA racine',
|
||||
678: 'USB retiré',
|
||||
679: 'Retirez le support d’installation USB de votre serveur',
|
||||
680: 'Redémarrer le serveur',
|
||||
681: 'En attente du retour en ligne du serveur',
|
||||
682: 'Le serveur est de nouveau en ligne',
|
||||
683: 'Redémarrez votre serveur pour terminer la configuration',
|
||||
684: 'Ouvrir l’adresse locale',
|
||||
685: 'Continuer vers la connexion',
|
||||
686: 'Accéder à l’écran de connexion StartOS',
|
||||
687: 'Le serveur n’est pas revenu en ligne. Veuillez vérifier votre serveur et essayer d’y accéder manuellement.',
|
||||
691: 'Ce champ est requis',
|
||||
692: 'Échec de la connexion',
|
||||
693: 'Impossible de se connecter au dossier réseau. Assurez-vous que (1) l’ordinateur cible est connecté au LAN, (2) le dossier cible est partagé et (3) le nom d’hôte, le chemin et les identifiants sont corrects.',
|
||||
694: 'Ce disque contient des données StartOS existantes.',
|
||||
695: 'pour conserver vos données.',
|
||||
696: 'pour supprimer',
|
||||
697: 'Saisissez le mot de passe utilisé pour chiffrer cette sauvegarde.',
|
||||
698: 'Plusieurs sauvegardes trouvées. Sélectionnez celle à restaurer.',
|
||||
699: 'Sauvegardes',
|
||||
700: 'Le disque sur lequel le système d’exploitation StartOS sera installé.',
|
||||
701: 'Le disque sur lequel vos données StartOS (services, paramètres, etc.) seront stockées. Il peut s’agir du même disque que le système ou d’un disque séparé.',
|
||||
702: 'Après le transfert des données depuis ce disque, n’essayez pas de démarrer dessus à nouveau en tant que serveur Start9. Cela peut entraîner des dysfonctionnements des services, une corruption des données ou une perte de fonds.',
|
||||
703: 'Doit comporter au moins 12 caractères',
|
||||
704: 'Doit comporter au maximum 64 caractères',
|
||||
705: 'Les mots de passe ne correspondent pas',
|
||||
706: 'Conserver',
|
||||
707: 'Écraser',
|
||||
708: 'Déverrouiller',
|
||||
709: 'Disque',
|
||||
710: 'Transférer',
|
||||
711: 'La liste est vide',
|
||||
712: 'Redémarrer maintenant',
|
||||
713: 'Plus tard',
|
||||
714: 'Installation terminée !',
|
||||
715: 'StartOS a été installé avec succès.',
|
||||
716: 'Continuer vers la configuration',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -99,6 +99,7 @@ export default {
|
||||
101: 'Masz niezapisane zmiany. Czy na pewno chcesz opuścić tę stronę?',
|
||||
102: 'Opuść',
|
||||
103: 'Czy jesteś pewien?',
|
||||
104: 'Nowa brama sieciowa',
|
||||
108: 'publiczny',
|
||||
109: 'prywatny',
|
||||
111: 'Brak domeny onion',
|
||||
@@ -493,6 +494,7 @@ export default {
|
||||
516: 'Zalecane',
|
||||
517: 'Czy na pewno chcesz odrzucić to zadanie?',
|
||||
518: 'Odrzuć',
|
||||
519: 'Musi być prawidłowym adresem IPv4 lub IPv6 z opcjonalnym portem',
|
||||
520: 'Aktualizacja dostępna',
|
||||
521: 'Aby rozwiązać problem, zapoznaj się z',
|
||||
522: 'Wersja SDK',
|
||||
@@ -582,10 +584,10 @@ export default {
|
||||
612: 'Powód',
|
||||
613: 'Nie można wyłączyć prywatnych bram dla interfejsu użytkownika StartOS',
|
||||
614: 'głównego CA',
|
||||
615: 'Serwery DHCP',
|
||||
616: 'Nie można edytować serwerów DHCP',
|
||||
615: 'Użyj serwerów DNS dostarczonych przez router',
|
||||
616: 'Użyj serwerów DNS określonych ręcznie',
|
||||
617: 'Statyczny',
|
||||
618: 'Serwery statyczne',
|
||||
618: 'Serwery',
|
||||
619: 'Ostrzeżenie. StartOS obecnie używa następującej bramy do DNS',
|
||||
620: 'Jeśli zamierzasz używać tej bramy do rozwiązywania domen prywatnych, ustaw alternatywne statyczne serwery DNS za pomocą powyższego formularza.',
|
||||
621: 'Spakietuj usługę',
|
||||
@@ -596,5 +598,86 @@ export default {
|
||||
626: 'Prześlij',
|
||||
627: 'Otwórz UI',
|
||||
628: 'Skopiowano do schowka',
|
||||
629: 'Lista jest pusta',
|
||||
629: 'Instalowanie StartOS',
|
||||
630: 'Wybierz swój język',
|
||||
631: 'Wybierz układ klawiatury',
|
||||
632: 'Klawiatura',
|
||||
633: 'Wybierz tryb konfiguracji',
|
||||
634: 'Rozpocznij od nowa',
|
||||
635: 'Skonfiguruj zupełnie nowy serwer',
|
||||
636: 'Przywróć z kopii zapasowej',
|
||||
637: 'Przywróć dane StartOS z zaszyfrowanej kopii zapasowej',
|
||||
638: 'Przenieś dane z istniejącego dysku danych StartOS',
|
||||
639: 'Wybierz dyski',
|
||||
640: 'Nie znaleziono dysków. Podłącz dysk i kliknij Odśwież.',
|
||||
641: 'Dysk systemowy',
|
||||
642: 'Dysk danych',
|
||||
643: 'Wykryto dane StartOS',
|
||||
644: 'Dane na dysku systemowym mogą zostać nadpisane.',
|
||||
645: 'Twoje dane StartOS na dysku danych zostaną zachowane.',
|
||||
646: 'Dane na tym dysku zostaną nadpisane.',
|
||||
647: 'Dane na obu dyskach zostaną nadpisane.',
|
||||
648: 'Wybierz kopię zapasową',
|
||||
649: 'Wybierz kopię zapasową StartOS do przywrócenia',
|
||||
650: 'Otwórz kopię zapasową sieciową',
|
||||
651: 'Kopie zapasowe fizyczne',
|
||||
652: 'Brak fizycznych kopii zapasowych',
|
||||
653: 'Połącz folder sieciowy',
|
||||
654: 'Wybierz kopię zapasową sieciową',
|
||||
655: 'Odblokuj kopię zapasową',
|
||||
656: 'Przenieś dane',
|
||||
657: 'Wybierz dysk zawierający istniejące dane StartOS',
|
||||
658: 'Wybierz dysk',
|
||||
659: 'Nie znaleziono dysków danych StartOS',
|
||||
660: 'Ustaw hasło główne',
|
||||
661: 'Ustaw nowe hasło (opcjonalnie)',
|
||||
662: 'Zadbaj o nie. Zapisz je.',
|
||||
663: 'Pomiń, aby zachować istniejące hasło.',
|
||||
664: 'Wprowadź hasło',
|
||||
665: 'Potwierdź hasło',
|
||||
666: 'Zakończ',
|
||||
667: 'Rozpoczynanie konfiguracji',
|
||||
670: 'Poczekaj 1–2 minuty i odśwież stronę',
|
||||
672: 'Konfiguracja zakończona!',
|
||||
673: 'Możesz odłączyć dysk kopii zapasowej',
|
||||
674: 'Możesz odłączyć dysk transferowy',
|
||||
675: 'http://start.local służył tylko do konfiguracji. Nie będzie już działać.',
|
||||
676: 'Pobierz informacje adresowe',
|
||||
677: 'Zawiera stały lokalny adres serwera oraz główny urząd certyfikacji (Root CA)',
|
||||
678: 'USB usunięty',
|
||||
679: 'Usuń instalacyjny nośnik USB z serwera',
|
||||
680: 'Uruchom ponownie serwer',
|
||||
681: 'Oczekiwanie na ponowne połączenie serwera',
|
||||
682: 'Serwer jest ponownie online',
|
||||
683: 'Uruchom ponownie serwer, aby zakończyć konfigurację',
|
||||
684: 'Otwórz adres lokalny',
|
||||
685: 'Przejdź do logowania',
|
||||
686: 'Przejdź do ekranu logowania StartOS',
|
||||
687: 'Serwer nie wrócił do trybu online. Sprawdź serwer i spróbuj uzyskać do niego dostęp ręcznie.',
|
||||
691: 'To pole jest wymagane',
|
||||
692: 'Nie udało się połączyć',
|
||||
693: 'Nie można połączyć się z folderem sieciowym. Upewnij się, że (1) komputer docelowy jest podłączony do sieci LAN, (2) folder docelowy jest udostępniony oraz (3) nazwa hosta, ścieżka i dane logowania są poprawne.',
|
||||
694: 'Ten dysk zawiera istniejące dane StartOS.',
|
||||
695: 'aby zachować dane.',
|
||||
696: 'aby odrzucić',
|
||||
697: 'Wprowadź hasło użyte do zaszyfrowania tej kopii zapasowej.',
|
||||
698: 'Znaleziono wiele kopii zapasowych. Wybierz, którą przywrócić.',
|
||||
699: 'Kopie zapasowe',
|
||||
700: 'Dysk, na którym zostanie zainstalowany system operacyjny StartOS.',
|
||||
701: 'Dysk, na którym będą przechowywane dane StartOS (usługi, ustawienia itp.). Może to być ten sam dysk co systemowy lub oddzielny dysk.',
|
||||
702: 'Po przeniesieniu danych z tego dysku nie próbuj ponownie uruchamiać z niego systemu jako serwer Start9. Może to spowodować nieprawidłowe działanie usług, uszkodzenie danych lub utratę środków.',
|
||||
703: 'Musi mieć co najmniej 12 znaków',
|
||||
704: 'Musi mieć maksymalnie 64 znaki',
|
||||
705: 'Hasła nie są zgodne',
|
||||
706: 'Zachowaj',
|
||||
707: 'Nadpisz',
|
||||
708: 'Odblokuj',
|
||||
709: 'Dysk',
|
||||
710: 'Przenieś',
|
||||
711: 'Lista jest pusta',
|
||||
712: 'Uruchom ponownie teraz',
|
||||
713: 'Później',
|
||||
714: 'Instalacja zakończona!',
|
||||
715: 'StartOS został pomyślnie zainstalowany.',
|
||||
716: 'Przejdź do konfiguracji',
|
||||
} satisfies i18n
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
TuiLanguageSwitcherService,
|
||||
} from '@taiga-ui/i18n'
|
||||
import { ENGLISH } from './dictionaries/en'
|
||||
import { i18nService } from './i18n.service'
|
||||
import { i18nService, Languages } from './i18n.service'
|
||||
|
||||
export type i18nKey = keyof typeof ENGLISH
|
||||
export type i18n = Record<(typeof ENGLISH)[i18nKey], string>
|
||||
@@ -20,7 +20,7 @@ export const I18N_LOADER = new InjectionToken<
|
||||
>('')
|
||||
|
||||
export const I18N_STORAGE = new InjectionToken<
|
||||
(lang: TuiLanguageName) => Promise<void>
|
||||
(lang: Languages) => Promise<void>
|
||||
>('', {
|
||||
factory: () => () => Promise.resolve(),
|
||||
})
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { inject, Injectable, signal } from '@angular/core'
|
||||
import { TuiLanguageName, TuiLanguageSwitcherService } from '@taiga-ui/i18n'
|
||||
import { I18N, I18N_LOADER, I18N_STORAGE } from './i18n.providers'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
|
||||
export const languages = ['en_US', 'es_ES', 'de_DE', 'fr_FR', 'pl_PL'] as const
|
||||
export type Languages = (typeof languages)[number]
|
||||
|
||||
/**
|
||||
* Maps POSIX locale strings to TUI language names
|
||||
*/
|
||||
export const LANGUAGE_TO_TUI: Record<Languages, TuiLanguageName> = {
|
||||
en_US: 'english',
|
||||
es_ES: 'spanish',
|
||||
de_DE: 'german',
|
||||
fr_FR: 'french',
|
||||
pl_PL: 'polish',
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
@@ -12,20 +27,40 @@ export class i18nService extends TuiLanguageSwitcherService {
|
||||
|
||||
readonly loading = signal(false)
|
||||
|
||||
override setLanguage(language: TuiLanguageName = 'english'): void {
|
||||
/**
|
||||
* Current language as POSIX locale string
|
||||
*/
|
||||
get lang(): Languages {
|
||||
return (
|
||||
(Object.entries(LANGUAGE_TO_TUI).find(
|
||||
([, tui]) => tui === this.language,
|
||||
)?.[0] as Languages) || 'en_US'
|
||||
)
|
||||
}
|
||||
|
||||
localize(string: T.LocaleString): string {
|
||||
if (typeof string === 'string') return string
|
||||
|
||||
return (
|
||||
string[this.lang] ?? string['en_US'] ?? Object.values(string)[0] ?? ''
|
||||
)
|
||||
}
|
||||
|
||||
setLang(language: Languages = 'en_US'): void {
|
||||
const tuiLang = LANGUAGE_TO_TUI[language]
|
||||
const current = this.language
|
||||
|
||||
super.setLanguage(language)
|
||||
super.setLanguage(tuiLang)
|
||||
this.loading.set(true)
|
||||
|
||||
if (current === language) {
|
||||
this.i18nLoader(language).then(value => {
|
||||
if (current === tuiLang) {
|
||||
this.i18nLoader(tuiLang).then(value => {
|
||||
this.i18n.set(value)
|
||||
this.loading.set(false)
|
||||
})
|
||||
} else {
|
||||
this.store(language).then(() =>
|
||||
this.i18nLoader(language).then(value => {
|
||||
this.i18nLoader(tuiLang).then(value => {
|
||||
this.i18n.set(value)
|
||||
this.loading.set(false)
|
||||
}),
|
||||
@@ -33,12 +68,3 @@ export class i18nService extends TuiLanguageSwitcherService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const languages = [
|
||||
'english',
|
||||
'spanish',
|
||||
'polish',
|
||||
'german',
|
||||
'french',
|
||||
] as const
|
||||
export type Languages = (typeof languages)[number]
|
||||
|
||||
16
web/projects/shared/src/i18n/localize.pipe.ts
Normal file
16
web/projects/shared/src/i18n/localize.pipe.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { inject, Injectable, Pipe, PipeTransform } from '@angular/core'
|
||||
import { i18nService } from './i18n.service'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
|
||||
@Pipe({
|
||||
name: 'localize',
|
||||
pure: false,
|
||||
})
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class LocalizePipe implements PipeTransform {
|
||||
private readonly i18nService = inject(i18nService)
|
||||
|
||||
transform(string: T.LocaleString): string {
|
||||
return this.i18nService.localize(string)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ export * from './classes/rpc-error'
|
||||
export * from './components/initializing/logs-window.component'
|
||||
export * from './components/initializing/initializing.component'
|
||||
export * from './components/ticker.component'
|
||||
export * from './components/drive.component'
|
||||
export * from './components/markdown.component'
|
||||
export * from './components/prompt.component'
|
||||
export * from './components/server.component'
|
||||
@@ -19,6 +18,7 @@ export * from './directives/safe-links.directive'
|
||||
export * from './i18n/i18n.pipe'
|
||||
export * from './i18n/i18n.providers'
|
||||
export * from './i18n/i18n.service'
|
||||
export * from './i18n/localize.pipe'
|
||||
|
||||
export * from './pipes/exver/exver.module'
|
||||
export * from './pipes/exver/exver.pipe'
|
||||
@@ -59,3 +59,5 @@ export * from './util/rpc.util'
|
||||
export * from './util/to-guid'
|
||||
export * from './util/to-local-iso-string'
|
||||
export * from './util/unused'
|
||||
export * from './util/keyboards'
|
||||
export * from './util/languages'
|
||||
|
||||
@@ -10,7 +10,7 @@ export type AccessType =
|
||||
export type WorkspaceConfig = {
|
||||
gitHash: string
|
||||
useMocks: boolean
|
||||
// each key corresponds to a project and values adjust settings for that project, eg: ui, install-wizard, setup-wizard
|
||||
// each key corresponds to a project and values adjust settings for that project, eg: ui, setup-wizard
|
||||
ui: {
|
||||
api: {
|
||||
url: string
|
||||
|
||||
97
web/projects/shared/src/util/keyboards.ts
Normal file
97
web/projects/shared/src/util/keyboards.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { LanguageCode } from './languages'
|
||||
|
||||
/**
|
||||
* Keyboard layout codes (X11/Wayland)
|
||||
*/
|
||||
export type KeyboardLayout = 'us' | 'gb' | 'es' | 'latam' | 'de' | 'fr' | 'pl'
|
||||
|
||||
/**
|
||||
* Keyboard keymap codes (console/TTY)
|
||||
*/
|
||||
export type KeyboardKeymap = 'us' | 'uk' | 'es' | 'la' | 'de' | 'fr' | 'pl'
|
||||
|
||||
/**
|
||||
* Keyboard layout display names
|
||||
*/
|
||||
export type KeyboardName =
|
||||
| 'US English'
|
||||
| 'UK English'
|
||||
| 'Spanish'
|
||||
| 'Latin American'
|
||||
| 'German'
|
||||
| 'French'
|
||||
| 'Polish'
|
||||
|
||||
/**
|
||||
* Keyboard definition with layout and keymap
|
||||
*/
|
||||
export interface Keyboard {
|
||||
layout: KeyboardLayout
|
||||
keymap: KeyboardKeymap
|
||||
name: KeyboardName
|
||||
}
|
||||
|
||||
/**
|
||||
* Full keyboard configuration for backend API
|
||||
*/
|
||||
export interface FullKeyboard {
|
||||
layout: KeyboardLayout
|
||||
keymap: KeyboardKeymap
|
||||
model: string | null
|
||||
variant: string | null
|
||||
options: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Keyboard layouts grouped by language code
|
||||
*/
|
||||
export const KEYBOARDS_BY_LANGUAGE: Record<LanguageCode, Keyboard[]> = {
|
||||
en: [
|
||||
{ layout: 'us', keymap: 'us', name: 'US English' },
|
||||
{ layout: 'gb', keymap: 'uk', name: 'UK English' },
|
||||
],
|
||||
es: [
|
||||
{ layout: 'es', keymap: 'es', name: 'Spanish' },
|
||||
{ layout: 'latam', keymap: 'la', name: 'Latin American' },
|
||||
],
|
||||
de: [{ layout: 'de', keymap: 'de', name: 'German' }],
|
||||
fr: [{ layout: 'fr', keymap: 'fr', name: 'French' }],
|
||||
pl: [{ layout: 'pl', keymap: 'pl', name: 'Polish' }],
|
||||
}
|
||||
|
||||
/**
|
||||
* All available keyboard layouts
|
||||
*/
|
||||
export const ALL_KEYBOARDS: Keyboard[] = [
|
||||
{ layout: 'us', keymap: 'us', name: 'US English' },
|
||||
{ layout: 'gb', keymap: 'uk', name: 'UK English' },
|
||||
{ layout: 'es', keymap: 'es', name: 'Spanish' },
|
||||
{ layout: 'latam', keymap: 'la', name: 'Latin American' },
|
||||
{ layout: 'de', keymap: 'de', name: 'German' },
|
||||
{ layout: 'fr', keymap: 'fr', name: 'French' },
|
||||
{ layout: 'pl', keymap: 'pl', name: 'Polish' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Get all keyboards sorted with language-specific keyboards first,
|
||||
* then remaining keyboards alphabetically by name.
|
||||
*/
|
||||
export function getAllKeyboardsSorted(languageCode: LanguageCode): Keyboard[] {
|
||||
const languageKeyboards = KEYBOARDS_BY_LANGUAGE[languageCode]
|
||||
const languageLayouts = new Set(languageKeyboards.map(kb => kb.layout))
|
||||
const otherKeyboards = ALL_KEYBOARDS.filter(
|
||||
kb => !languageLayouts.has(kb.layout),
|
||||
).sort((a, b) => a.name.localeCompare(b.name))
|
||||
return [...languageKeyboards, ...otherKeyboards]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name for a keyboard layout.
|
||||
*/
|
||||
export function getKeyboardName(
|
||||
layout: KeyboardLayout | string,
|
||||
): KeyboardName | string {
|
||||
const keyboard = ALL_KEYBOARDS.find(kb => kb.layout === layout)
|
||||
if (keyboard) return keyboard.name
|
||||
return layout // fallback to the layout itself if not found
|
||||
}
|
||||
44
web/projects/shared/src/util/languages.ts
Normal file
44
web/projects/shared/src/util/languages.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Languages } from '../i18n/i18n.service'
|
||||
|
||||
/**
|
||||
* ISO language codes
|
||||
*/
|
||||
export type LanguageCode = 'en' | 'es' | 'de' | 'fr' | 'pl'
|
||||
|
||||
/**
|
||||
* Language definition with metadata
|
||||
*/
|
||||
export interface Language {
|
||||
code: LanguageCode
|
||||
name: Languages
|
||||
nativeName: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Available languages with their metadata
|
||||
*/
|
||||
export const LANGUAGES: Language[] = [
|
||||
{ code: 'en', name: 'en_US', nativeName: 'English' },
|
||||
{ code: 'es', name: 'es_ES', nativeName: 'Español' },
|
||||
{ code: 'de', name: 'de_DE', nativeName: 'Deutsch' },
|
||||
{ code: 'fr', name: 'fr_FR', nativeName: 'Français' },
|
||||
{ code: 'pl', name: 'pl_PL', nativeName: 'Polski' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Maps POSIX locale strings to ISO language codes
|
||||
*/
|
||||
export const LANGUAGE_TO_CODE: Record<Languages, LanguageCode> = {
|
||||
en_US: 'en',
|
||||
es_ES: 'es',
|
||||
de_DE: 'de',
|
||||
fr_FR: 'fr',
|
||||
pl_PL: 'pl',
|
||||
}
|
||||
|
||||
/**
|
||||
* Params for setting language via API
|
||||
*/
|
||||
export interface SetLanguageParams {
|
||||
language: Languages
|
||||
}
|
||||
@@ -39,9 +39,9 @@ export class AppComponent {
|
||||
.subscribe()
|
||||
|
||||
readonly ui = inject<PatchDB<DataModel>>(PatchDB)
|
||||
.watch$('ui', 'language')
|
||||
.watch$('serverInfo', 'language')
|
||||
.pipe(takeUntilDestroyed())
|
||||
.subscribe(language => {
|
||||
this.i18n.setLanguage(language || 'english')
|
||||
this.i18n.setLang(language || 'en_US')
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { inject, provideAppInitializer } from '@angular/core'
|
||||
import { UntypedFormBuilder } from '@angular/forms'
|
||||
import { provideAnimations } from '@angular/platform-browser/animations'
|
||||
import { Router } from '@angular/router'
|
||||
import { ActivationStart, Router } from '@angular/router'
|
||||
import { WA_LOCATION } from '@ng-web-apis/common'
|
||||
import initArgon from '@start9labs/argon2'
|
||||
import {
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
I18N_PROVIDERS,
|
||||
I18N_STORAGE,
|
||||
i18nService,
|
||||
Languages,
|
||||
RELATIVE_URL,
|
||||
VERSION,
|
||||
WorkspaceConfig,
|
||||
@@ -32,7 +33,7 @@ import {
|
||||
TUI_DATE_VALUE_TRANSFORMER,
|
||||
} from '@taiga-ui/kit'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { filter, identity, of, pairwise } from 'rxjs'
|
||||
import { filter, identity, merge, of, pairwise } from 'rxjs'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import {
|
||||
PATCH_CACHE,
|
||||
@@ -115,11 +116,15 @@ export const APP_PROVIDERS = [
|
||||
{
|
||||
provide: TUI_DIALOGS_CLOSE,
|
||||
useFactory: () =>
|
||||
inject(StateService).pipe(
|
||||
pairwise(),
|
||||
filter(
|
||||
([prev, curr]) =>
|
||||
prev === 'running' && (curr === 'error' || curr === 'initializing'),
|
||||
merge(
|
||||
inject(Router).events.pipe(filter(e => e instanceof ActivationStart)),
|
||||
inject(StateService).pipe(
|
||||
pairwise(),
|
||||
filter(
|
||||
([prev, curr]) =>
|
||||
prev === 'running' &&
|
||||
(curr === 'error' || curr === 'initializing'),
|
||||
),
|
||||
),
|
||||
),
|
||||
},
|
||||
@@ -128,7 +133,7 @@ export const APP_PROVIDERS = [
|
||||
useFactory: () => {
|
||||
const api = inject(ApiService)
|
||||
|
||||
return (language: string) => api.setDbValue(['language'], language)
|
||||
return (language: Languages) => api.setLanguage({ language })
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { Router, RouterLink } from '@angular/router'
|
||||
import { invert } from '@start9labs/shared'
|
||||
import { IST } from '@start9labs/start-sdk'
|
||||
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
|
||||
@@ -36,6 +37,7 @@ import { HintPipe } from '../pipes/hint.pipe'
|
||||
[placeholder]="spec.name"
|
||||
[items]="items"
|
||||
[(ngModel)]="selected"
|
||||
(ngModelChange)="onChange($event)"
|
||||
></select>
|
||||
} @else {
|
||||
<input
|
||||
@@ -50,15 +52,27 @@ import { HintPipe } from '../pipes/hint.pipe'
|
||||
@if (!mobile) {
|
||||
<tui-data-list *tuiTextfieldDropdown>
|
||||
@for (item of items; track item) {
|
||||
<button
|
||||
tuiOption
|
||||
new
|
||||
tuiFluidTypography
|
||||
[style.white-space]="'nowrap'"
|
||||
[value]="item"
|
||||
>
|
||||
{{ item }}
|
||||
</button>
|
||||
@if (inverted[item]?.startsWith('~')) {
|
||||
<a
|
||||
tuiOption
|
||||
new
|
||||
iconEnd="@tui.arrow-right"
|
||||
tuiFluidTypography
|
||||
[routerLink]="inverted[item]?.slice(1)"
|
||||
>
|
||||
{{ item }}
|
||||
</a>
|
||||
} @else {
|
||||
<button
|
||||
tuiOption
|
||||
new
|
||||
tuiFluidTypography
|
||||
[style.white-space]="'nowrap'"
|
||||
[value]="item"
|
||||
>
|
||||
{{ item }}
|
||||
</button>
|
||||
}
|
||||
}
|
||||
</tui-data-list>
|
||||
}
|
||||
@@ -70,6 +84,7 @@ import { HintPipe } from '../pipes/hint.pipe'
|
||||
providers: [tuiFluidTypographyOptionsProvider({ max: 1 })],
|
||||
imports: [
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
TuiTextfield,
|
||||
TuiSelect,
|
||||
TuiDataList,
|
||||
@@ -81,8 +96,8 @@ import { HintPipe } from '../pipes/hint.pipe'
|
||||
],
|
||||
})
|
||||
export class FormSelectComponent extends Control<IST.ValueSpecSelect, string> {
|
||||
private readonly inverted = invert(this.spec.values)
|
||||
|
||||
protected readonly router = inject(Router)
|
||||
protected readonly inverted = invert(this.spec.values)
|
||||
protected readonly mobile = inject(TUI_IS_MOBILE)
|
||||
protected readonly items = Object.values(this.spec.values)
|
||||
protected readonly disabledItemHandler = (item: string) =>
|
||||
@@ -101,4 +116,12 @@ export class FormSelectComponent extends Control<IST.ValueSpecSelect, string> {
|
||||
set selected(value: string | null) {
|
||||
this.value = (value && this.inverted[value]) || null
|
||||
}
|
||||
|
||||
protected onChange(value: string) {
|
||||
const mapped = this.inverted[value]
|
||||
|
||||
if (typeof mapped === 'string' && mapped.startsWith('~')) {
|
||||
this.router.navigate([mapped.slice(1)])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,10 +247,10 @@ export class PublicDomainService {
|
||||
),
|
||||
values: gateways.reduce<Record<string, string>>(
|
||||
(obj, gateway) => ({
|
||||
...obj,
|
||||
[gateway.id]: gateway.name || gateway.ipInfo.name,
|
||||
...obj,
|
||||
}),
|
||||
{},
|
||||
{ '~/system/gateways': this.i18n.transform('New gateway') },
|
||||
),
|
||||
default: '',
|
||||
disabled: gateways
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
Exver,
|
||||
ExverPipesModule,
|
||||
i18nPipe,
|
||||
isEmptyObject,
|
||||
i18nService,
|
||||
LoadingService,
|
||||
sameUrl,
|
||||
} from '@start9labs/shared'
|
||||
@@ -123,6 +123,7 @@ export class MarketplaceControlsComponent {
|
||||
private readonly router = inject(Router)
|
||||
private readonly marketplace = inject(MarketplaceService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly i18n = inject(i18nService)
|
||||
|
||||
readonly pkg = input.required<Pick<MarketplacePkg, KEYS>>()
|
||||
|
||||
@@ -149,7 +150,7 @@ export class MarketplaceControlsComponent {
|
||||
const originalUrl = localPkg?.registry || null
|
||||
|
||||
if (!localPkg) {
|
||||
if (await this.alerts.alertInstall(this.pkg().alerts.install || '')) {
|
||||
if (await this.alerts.alertInstall(this.i18n.localize(this.pkg().alerts.install || ''))) {
|
||||
this.installOrUpload(currentUrl)
|
||||
}
|
||||
return
|
||||
@@ -184,10 +185,7 @@ export class MarketplaceControlsComponent {
|
||||
const packages = await getAllPackages(this.patch)
|
||||
const breakages = dryUpdate({ id, version }, packages, this.exver)
|
||||
|
||||
if (
|
||||
isEmptyObject(breakages) ||
|
||||
(await this.alerts.alertBreakages(breakages))
|
||||
) {
|
||||
if (!breakages.length || (await this.alerts.alertBreakages(breakages))) {
|
||||
this.installOrUpload(url)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
i18nPipe,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { ISB } from '@start9labs/start-sdk'
|
||||
import { ISB, utils } from '@start9labs/start-sdk'
|
||||
import { TuiButton, TuiTitle } from '@taiga-ui/core'
|
||||
import { TuiHeader } from '@taiga-ui/layout'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
@@ -21,6 +21,14 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { TitleDirective } from 'src/app/services/title.service'
|
||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||
|
||||
// IPv4
|
||||
const ipv4 =
|
||||
/(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}/
|
||||
|
||||
// IPv6 (your existing pattern)
|
||||
const ipv6 =
|
||||
/(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))/
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<ng-container *title>
|
||||
@@ -111,17 +119,11 @@ export default class SystemDnsComponent {
|
||||
strategy: ISB.Value.union({
|
||||
name: 'strategy',
|
||||
default: 'dhcp',
|
||||
description: `<ul><li><b>DHCP</b>: ${this.i18n.transform('Use the DNS servers provided by your router')}</li><li><b>${this.i18n.transform('Static')}</b>: ${this.i18n.transform('Use DNS servers you specify manually')}</li></ul>`,
|
||||
variants: ISB.Variants.of({
|
||||
dhcp: {
|
||||
name: 'DHCP',
|
||||
spec: ISB.InputSpec.of({
|
||||
servers: ISB.Value.dynamicText(() => ({
|
||||
name: this.i18n.transform('DHCP Servers'),
|
||||
default: null,
|
||||
required: true,
|
||||
disabled: this.i18n.transform('Cannot edit DHCP servers'),
|
||||
})),
|
||||
}),
|
||||
spec: ISB.InputSpec.of({}),
|
||||
},
|
||||
static: {
|
||||
name: this.i18n.transform('Static'),
|
||||
@@ -129,11 +131,21 @@ export default class SystemDnsComponent {
|
||||
servers: ISB.Value.list(
|
||||
ISB.List.text(
|
||||
{
|
||||
name: this.i18n.transform('Static Servers'),
|
||||
name: this.i18n.transform('Servers'),
|
||||
minLength: 1,
|
||||
maxLength: 3,
|
||||
},
|
||||
{ placeholder: '1.1.1.1' },
|
||||
{
|
||||
placeholder: '1.1.1.1',
|
||||
patterns: [
|
||||
{
|
||||
regex: `^(${ipv4.source}(:\\d{1,5})?|${ipv6.source}|\\[${ipv6.source}\\](:\\d{1,5})?)$`,
|
||||
description: this.i18n.transform(
|
||||
'Must be a valid IPv4 or Ipv6 address with optional port',
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
),
|
||||
),
|
||||
}),
|
||||
|
||||
@@ -12,15 +12,20 @@ import { RouterLink } from '@angular/router'
|
||||
import {
|
||||
DialogService,
|
||||
ErrorService,
|
||||
getAllKeyboardsSorted,
|
||||
getKeyboardName,
|
||||
i18nKey,
|
||||
i18nPipe,
|
||||
i18nService,
|
||||
languages,
|
||||
Languages,
|
||||
Keyboard,
|
||||
KeyboardLayout,
|
||||
Language,
|
||||
LANGUAGES,
|
||||
LANGUAGE_TO_CODE,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
|
||||
import { TuiAnimated, TuiContext, TuiStringHandler } from '@taiga-ui/cdk'
|
||||
import { TuiAnimated } from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiAppearance,
|
||||
TuiButton,
|
||||
@@ -49,6 +54,7 @@ import { TitleDirective } from 'src/app/services/title.service'
|
||||
import { SnekDirective } from './snek.directive'
|
||||
import { UPDATE } from './update.component'
|
||||
import { SystemWipeComponent } from './wipe.component'
|
||||
import { KeyboardSelectComponent } from './keyboard-select.component'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
@@ -104,20 +110,16 @@ import { SystemWipeComponent } from './wipe.component'
|
||||
<tui-icon icon="@tui.languages" />
|
||||
<span tuiTitle>
|
||||
<strong>{{ 'Language' | i18n }}</strong>
|
||||
<span tuiSubtitle [style.text-transform]="'capitalize'">
|
||||
@if (language; as lang) {
|
||||
{{ lang | i18n }}
|
||||
} @else {
|
||||
{{ i18nService.language }}
|
||||
}
|
||||
<span tuiSubtitle>
|
||||
{{ currentLanguage?.nativeName }}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
tuiButtonSelect
|
||||
tuiButton
|
||||
[loading]="i18nService.loading()"
|
||||
[ngModel]="i18nService.language"
|
||||
(ngModelChange)="i18nService.setLanguage($event)"
|
||||
[ngModel]="currentLanguage"
|
||||
(ngModelChange)="onLanguageChange($event)"
|
||||
>
|
||||
{{ 'Change' | i18n }}
|
||||
<tui-data-list-wrapper
|
||||
@@ -125,29 +127,50 @@ import { SystemWipeComponent } from './wipe.component'
|
||||
new
|
||||
size="l"
|
||||
[items]="languages"
|
||||
[itemContent]="translation"
|
||||
[itemContent]="languageContent"
|
||||
/>
|
||||
</button>
|
||||
<ng-template #languageContent let-item>
|
||||
{{ item.nativeName }}
|
||||
</ng-template>
|
||||
</div>
|
||||
<div tuiCell tuiAppearance="outline-grayscale">
|
||||
<tui-icon icon="@tui.monitor" />
|
||||
<span tuiTitle>
|
||||
<strong>
|
||||
{{ 'Kiosk Mode' | i18n }}
|
||||
<tui-badge size="m" appearance="primary-grayscale">
|
||||
<tui-badge
|
||||
size="m"
|
||||
[appearance]="server.kiosk ? 'warning' : 'primary-grayscale'"
|
||||
>
|
||||
{{ server.kiosk ? ('Enabled' | i18n) : ('Disabled' | i18n) }}
|
||||
</tui-badge>
|
||||
</strong>
|
||||
<span tuiSubtitle>
|
||||
{{
|
||||
server.kiosk === true
|
||||
? ('Disable Kiosk Mode unless you need to attach a monitor'
|
||||
| i18n)
|
||||
: server.kiosk === false
|
||||
? ('Enable Kiosk Mode if you need to attach a monitor' | i18n)
|
||||
: ('Kiosk Mode is unavailable on this device' | i18n)
|
||||
}}
|
||||
<span tuiSubtitle [class.warning-text]="server.kiosk">
|
||||
@if (server.kiosk === null) {
|
||||
{{ 'Kiosk Mode is unavailable on this device' | i18n }}
|
||||
} @else {
|
||||
{{
|
||||
server.kiosk
|
||||
? ('Disable Kiosk Mode unless you need to attach a monitor'
|
||||
| i18n)
|
||||
: ('Enable Kiosk Mode if you need to attach a monitor' | i18n)
|
||||
}}
|
||||
}
|
||||
</span>
|
||||
@if (server.kiosk !== null && server.keyboard?.layout; as layout) {
|
||||
<span tuiSubtitle class="keyboard-info">
|
||||
<tui-icon icon="@tui.keyboard" />
|
||||
{{ getKeyboardName(layout) }}
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="icon"
|
||||
iconStart="@tui.pencil"
|
||||
size="xs"
|
||||
(click)="onChangeKeyboard()"
|
||||
></button>
|
||||
</span>
|
||||
}
|
||||
</span>
|
||||
@if (server.kiosk !== null) {
|
||||
<button tuiButton appearance="primary" (click)="toggleKiosk()">
|
||||
@@ -214,6 +237,21 @@ import { SystemWipeComponent } from './wipe.component'
|
||||
[tuiAnimated].tui-leave {
|
||||
animation-name: tuiFade, tuiScale;
|
||||
}
|
||||
|
||||
.keyboard-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
|
||||
tui-icon {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
}
|
||||
|
||||
.warning-text,
|
||||
[tuiSubtitle].warning-text {
|
||||
color: var(--tui-status-warning) !important;
|
||||
}
|
||||
`,
|
||||
providers: [tuiCellOptionsProvider({ height: 'spacious' })],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
@@ -258,17 +296,60 @@ export default class SystemGeneralComponent {
|
||||
readonly score = toSignal(this.patch.watch$('ui', 'snakeHighScore'))
|
||||
readonly os = inject(OSService)
|
||||
readonly i18nService = inject(i18nService)
|
||||
readonly languages = languages
|
||||
readonly translation: TuiStringHandler<TuiContext<Languages>> = ({
|
||||
$implicit,
|
||||
}) => {
|
||||
const [head = '', ...result] = this.i18n.transform($implicit) || ''
|
||||
readonly languages = LANGUAGES
|
||||
|
||||
return [head.toUpperCase(), ...result].join('')
|
||||
get currentLanguage(): Language | undefined {
|
||||
return LANGUAGES.find(lang => lang.name === this.i18nService.lang)
|
||||
}
|
||||
|
||||
get language(): Languages | undefined {
|
||||
return this.languages.find(lang => lang === this.i18nService.language)
|
||||
onLanguageChange(language: Language) {
|
||||
this.i18nService.setLang(language.name)
|
||||
}
|
||||
|
||||
// Expose shared utilities for template use
|
||||
readonly getKeyboardName = getKeyboardName
|
||||
|
||||
/**
|
||||
* Open keyboard selection dialog to change keyboard layout
|
||||
*/
|
||||
onChangeKeyboard() {
|
||||
const server = this.server()
|
||||
if (!server) return
|
||||
|
||||
const keyboards = getAllKeyboardsSorted(LANGUAGE_TO_CODE[server.language])
|
||||
const currentLayout = (server.keyboard?.layout as KeyboardLayout) || null
|
||||
|
||||
this.dialog
|
||||
.openComponent<Keyboard | null>(
|
||||
new PolymorpheusComponent(KeyboardSelectComponent, this.injector),
|
||||
{
|
||||
label: 'Select Keyboard Layout',
|
||||
size: 's',
|
||||
data: { keyboards, currentLayout },
|
||||
},
|
||||
)
|
||||
.pipe(filter((keyboard): keyboard is Keyboard => keyboard !== null))
|
||||
.subscribe(keyboard => {
|
||||
this.saveKeyboard(keyboard)
|
||||
})
|
||||
}
|
||||
|
||||
private async saveKeyboard(keyboard: Keyboard) {
|
||||
const loader = this.loader.open('Saving').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.setKeyboard({
|
||||
layout: keyboard.layout,
|
||||
keymap: keyboard.keymap,
|
||||
model: null,
|
||||
variant: null,
|
||||
options: [],
|
||||
})
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
onUpdate() {
|
||||
@@ -352,19 +433,51 @@ export default class SystemGeneralComponent {
|
||||
}
|
||||
|
||||
async toggleKiosk() {
|
||||
const kiosk = this.server()?.kiosk
|
||||
const server = this.server()
|
||||
if (!server) return
|
||||
|
||||
const loader = this.loader
|
||||
.open(kiosk ? 'Disabling' : 'Enabling')
|
||||
.subscribe()
|
||||
const kiosk = server.kiosk
|
||||
|
||||
// If disabling kiosk, just disable it
|
||||
if (kiosk) {
|
||||
await this.disableKiosk()
|
||||
return
|
||||
}
|
||||
|
||||
// Enabling kiosk - check if keyboard is already set
|
||||
if (server.keyboard) {
|
||||
// Keyboard already set, just enable kiosk
|
||||
await this.enableKiosk()
|
||||
return
|
||||
}
|
||||
|
||||
// No keyboard set - prompt user to select from all keyboards
|
||||
const keyboards = getAllKeyboardsSorted(LANGUAGE_TO_CODE[server.language])
|
||||
this.promptKeyboardSelection(keyboards)
|
||||
}
|
||||
|
||||
private promptKeyboardSelection(keyboards: Keyboard[]) {
|
||||
this.dialog
|
||||
.openComponent<Keyboard | null>(
|
||||
new PolymorpheusComponent(KeyboardSelectComponent, this.injector),
|
||||
{
|
||||
label: 'Select Keyboard Layout',
|
||||
size: 's',
|
||||
data: { keyboards, currentLayout: null },
|
||||
},
|
||||
)
|
||||
.pipe(filter((keyboard): keyboard is Keyboard => keyboard !== null))
|
||||
.subscribe(keyboard => {
|
||||
this.enableKioskWithKeyboard(keyboard)
|
||||
})
|
||||
}
|
||||
|
||||
private async enableKiosk() {
|
||||
const loader = this.loader.open('Enabling').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.toggleKiosk(!kiosk)
|
||||
this.dialog
|
||||
.openAlert('This change will take effect after the next boot', {
|
||||
label: 'Restart to apply',
|
||||
})
|
||||
.subscribe()
|
||||
await this.api.toggleKiosk(true)
|
||||
this.promptRestart()
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
@@ -372,6 +485,53 @@ export default class SystemGeneralComponent {
|
||||
}
|
||||
}
|
||||
|
||||
private async enableKioskWithKeyboard(keyboard: Keyboard) {
|
||||
const loader = this.loader.open('Enabling').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.setKeyboard({
|
||||
layout: keyboard.layout,
|
||||
keymap: keyboard.keymap,
|
||||
model: null,
|
||||
variant: null,
|
||||
options: [],
|
||||
})
|
||||
await this.api.toggleKiosk(true)
|
||||
this.promptRestart()
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async disableKiosk() {
|
||||
const loader = this.loader.open('Disabling').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.toggleKiosk(false)
|
||||
this.promptRestart()
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private promptRestart() {
|
||||
this.dialog
|
||||
.openConfirm({
|
||||
label: 'Restart to apply',
|
||||
data: {
|
||||
content: 'This change will take effect after the next boot',
|
||||
yes: 'Restart now',
|
||||
no: 'Later',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => this.restart())
|
||||
}
|
||||
|
||||
private async resetTor(wipeState: boolean) {
|
||||
const loader = this.loader.open().subscribe()
|
||||
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { i18nPipe, Keyboard, KeyboardLayout } from '@start9labs/shared'
|
||||
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
|
||||
import { TuiButton, TuiDialogContext, TuiTextfield } from '@taiga-ui/core'
|
||||
import { TuiChevron, TuiDataListWrapper, TuiSelect } from '@taiga-ui/kit'
|
||||
import { injectContext } from '@taiga-ui/polymorpheus'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<tui-textfield
|
||||
tuiChevron
|
||||
[stringify]="stringify"
|
||||
[tuiTextfieldCleaner]="false"
|
||||
>
|
||||
<label tuiLabel>{{ 'Keyboard' | i18n }}</label>
|
||||
@if (mobile) {
|
||||
<select tuiSelect [(ngModel)]="selected" [items]="keyboards"></select>
|
||||
} @else {
|
||||
<input tuiSelect [(ngModel)]="selected" />
|
||||
}
|
||||
@if (!mobile) {
|
||||
<tui-data-list-wrapper new *tuiTextfieldDropdown [items]="keyboards" />
|
||||
}
|
||||
</tui-textfield>
|
||||
<footer>
|
||||
<button tuiButton appearance="secondary" (click)="cancel()">
|
||||
{{ 'Cancel' | i18n }}
|
||||
</button>
|
||||
<button
|
||||
tuiButton
|
||||
[disabled]="!selected || selected.layout === initialLayout"
|
||||
(click)="confirm()"
|
||||
>
|
||||
{{ 'Confirm' | i18n }}
|
||||
</button>
|
||||
</footer>
|
||||
`,
|
||||
styles: `
|
||||
p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
FormsModule,
|
||||
TuiButton,
|
||||
TuiTextfield,
|
||||
TuiChevron,
|
||||
TuiSelect,
|
||||
TuiDataListWrapper,
|
||||
i18nPipe,
|
||||
],
|
||||
})
|
||||
export class KeyboardSelectComponent {
|
||||
private readonly context =
|
||||
injectContext<
|
||||
TuiDialogContext<
|
||||
Keyboard | null,
|
||||
{ keyboards: Keyboard[]; currentLayout: KeyboardLayout | null }
|
||||
>
|
||||
>()
|
||||
|
||||
protected readonly mobile = inject(TUI_IS_MOBILE)
|
||||
readonly keyboards = this.context.data.keyboards
|
||||
readonly initialLayout = this.context.data.currentLayout
|
||||
selected =
|
||||
this.keyboards.find(kb => kb.layout === this.initialLayout) ||
|
||||
this.keyboards[0]!
|
||||
|
||||
readonly stringify = (kb: Keyboard) => kb.name
|
||||
|
||||
cancel() {
|
||||
this.context.completeWith(null)
|
||||
}
|
||||
|
||||
confirm() {
|
||||
this.context.completeWith(this.selected)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DialogService,
|
||||
i18nKey,
|
||||
i18nPipe,
|
||||
LocalizePipe,
|
||||
MarkdownPipe,
|
||||
SafeLinksDirective,
|
||||
} from '@start9labs/shared'
|
||||
@@ -138,7 +139,7 @@ import UpdatesComponent from './updates.component'
|
||||
</p>
|
||||
<p
|
||||
safeLinks
|
||||
[innerHTML]="item().releaseNotes | markdown | dompurify"
|
||||
[innerHTML]="item().releaseNotes | localize | markdown | dompurify"
|
||||
></p>
|
||||
</tui-expand>
|
||||
</td>
|
||||
@@ -237,6 +238,7 @@ import UpdatesComponent from './updates.component'
|
||||
TuiProgressCircle,
|
||||
TuiTitle,
|
||||
TuiFade,
|
||||
LocalizePipe,
|
||||
MarkdownPipe,
|
||||
NgDompurifyPipe,
|
||||
SafeLinksDirective,
|
||||
|
||||
@@ -410,7 +410,7 @@ export namespace Mock {
|
||||
docsUrl: 'https://bitcoin.org',
|
||||
releaseNotes: 'Even better support for Bitcoin and wallets!',
|
||||
osVersion: '0.3.6',
|
||||
sdkVersion: '0.4.0-beta.47',
|
||||
sdkVersion: '0.4.0-beta.48',
|
||||
gitHash: 'fakehash',
|
||||
icon: BTC_ICON,
|
||||
sourceVersion: null,
|
||||
@@ -452,7 +452,7 @@ export namespace Mock {
|
||||
docsUrl: 'https://bitcoinknots.org',
|
||||
releaseNotes: 'Even better support for Bitcoin and wallets!',
|
||||
osVersion: '0.3.6',
|
||||
sdkVersion: '0.4.0-beta.47',
|
||||
sdkVersion: '0.4.0-beta.48',
|
||||
gitHash: 'fakehash',
|
||||
icon: BTC_ICON,
|
||||
sourceVersion: null,
|
||||
@@ -504,7 +504,7 @@ export namespace Mock {
|
||||
docsUrl: 'https://bitcoin.org',
|
||||
releaseNotes: 'Even better support for Bitcoin and wallets!',
|
||||
osVersion: '0.3.6',
|
||||
sdkVersion: '0.4.0-beta.47',
|
||||
sdkVersion: '0.4.0-beta.48',
|
||||
gitHash: 'fakehash',
|
||||
icon: BTC_ICON,
|
||||
sourceVersion: null,
|
||||
@@ -546,7 +546,7 @@ export namespace Mock {
|
||||
docsUrl: 'https://bitcoinknots.org',
|
||||
releaseNotes: 'Even better support for Bitcoin and wallets!',
|
||||
osVersion: '0.3.6',
|
||||
sdkVersion: '0.4.0-beta.47',
|
||||
sdkVersion: '0.4.0-beta.48',
|
||||
gitHash: 'fakehash',
|
||||
icon: BTC_ICON,
|
||||
sourceVersion: null,
|
||||
@@ -600,7 +600,7 @@ export namespace Mock {
|
||||
docsUrl: 'https://lightning.engineering/',
|
||||
releaseNotes: 'Upstream release to 0.17.5',
|
||||
osVersion: '0.3.6',
|
||||
sdkVersion: '0.4.0-beta.47',
|
||||
sdkVersion: '0.4.0-beta.48',
|
||||
gitHash: 'fakehash',
|
||||
icon: LND_ICON,
|
||||
sourceVersion: null,
|
||||
@@ -655,7 +655,7 @@ export namespace Mock {
|
||||
docsUrl: 'https://lightning.engineering/',
|
||||
releaseNotes: 'Upstream release to 0.17.4',
|
||||
osVersion: '0.3.6',
|
||||
sdkVersion: '0.4.0-beta.47',
|
||||
sdkVersion: '0.4.0-beta.48',
|
||||
gitHash: 'fakehash',
|
||||
icon: LND_ICON,
|
||||
sourceVersion: null,
|
||||
@@ -714,7 +714,7 @@ export namespace Mock {
|
||||
docsUrl: 'https://bitcoin.org',
|
||||
releaseNotes: 'Even better support for Bitcoin and wallets!',
|
||||
osVersion: '0.3.6',
|
||||
sdkVersion: '0.4.0-beta.47',
|
||||
sdkVersion: '0.4.0-beta.48',
|
||||
gitHash: 'fakehash',
|
||||
icon: BTC_ICON,
|
||||
sourceVersion: null,
|
||||
@@ -756,7 +756,7 @@ export namespace Mock {
|
||||
docsUrl: 'https://bitcoinknots.org',
|
||||
releaseNotes: 'Even better support for Bitcoin and wallets!',
|
||||
osVersion: '0.3.6',
|
||||
sdkVersion: '0.4.0-beta.47',
|
||||
sdkVersion: '0.4.0-beta.48',
|
||||
gitHash: 'fakehash',
|
||||
icon: BTC_ICON,
|
||||
sourceVersion: null,
|
||||
@@ -808,7 +808,7 @@ export namespace Mock {
|
||||
docsUrl: 'https://lightning.engineering/',
|
||||
releaseNotes: 'Upstream release and minor fixes.',
|
||||
osVersion: '0.3.6',
|
||||
sdkVersion: '0.4.0-beta.47',
|
||||
sdkVersion: '0.4.0-beta.48',
|
||||
gitHash: 'fakehash',
|
||||
icon: LND_ICON,
|
||||
sourceVersion: null,
|
||||
@@ -863,7 +863,7 @@ export namespace Mock {
|
||||
marketingSite: '',
|
||||
releaseNotes: 'Upstream release and minor fixes.',
|
||||
osVersion: '0.3.6',
|
||||
sdkVersion: '0.4.0-beta.47',
|
||||
sdkVersion: '0.4.0-beta.48',
|
||||
gitHash: 'fakehash',
|
||||
icon: PROXY_ICON,
|
||||
sourceVersion: null,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { Dump } from 'patch-db-client'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { StartOSDiskInfo, FetchLogsReq, FetchLogsRes } from '@start9labs/shared'
|
||||
import {
|
||||
FetchLogsReq,
|
||||
FetchLogsRes,
|
||||
FullKeyboard,
|
||||
SetLanguageParams,
|
||||
StartOSDiskInfo,
|
||||
} from '@start9labs/shared'
|
||||
import { IST, T } from '@start9labs/start-sdk'
|
||||
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
|
||||
import {
|
||||
@@ -120,6 +126,12 @@ export namespace RR {
|
||||
} // net.tor.reset
|
||||
export type ResetTorRes = null
|
||||
|
||||
export type SetKeyboardReq = FullKeyboard // server.set-keyboard
|
||||
export type SetKeyboardRes = null
|
||||
|
||||
export type SetLanguageReq = SetLanguageParams // server.set-language
|
||||
export type SetLanguageRes = null
|
||||
|
||||
// smtp
|
||||
|
||||
export type SetSMTPReq = T.SmtpValue // server.set-smtp
|
||||
|
||||
@@ -117,6 +117,10 @@ export abstract class ApiService {
|
||||
|
||||
abstract toggleKiosk(enable: boolean): Promise<null>
|
||||
|
||||
abstract setKeyboard(params: RR.SetKeyboardReq): Promise<RR.SetKeyboardRes>
|
||||
|
||||
abstract setLanguage(params: RR.SetLanguageReq): Promise<RR.SetLanguageRes>
|
||||
|
||||
abstract setDns(params: RR.SetDnsReq): Promise<RR.SetDnsRes>
|
||||
|
||||
abstract queryDns(params: RR.QueryDnsReq): Promise<RR.QueryDnsRes>
|
||||
|
||||
@@ -256,6 +256,14 @@ export class LiveApiService extends ApiService {
|
||||
})
|
||||
}
|
||||
|
||||
async setKeyboard(params: RR.SetKeyboardReq): Promise<RR.SetKeyboardRes> {
|
||||
return this.rpcRequest({ method: 'server.set-keyboard', params })
|
||||
}
|
||||
|
||||
async setLanguage(params: RR.SetLanguageReq): Promise<RR.SetLanguageRes> {
|
||||
return this.rpcRequest({ method: 'server.set-language', params })
|
||||
}
|
||||
|
||||
async setDns(params: RR.SetDnsReq): Promise<RR.SetDnsRes> {
|
||||
return this.rpcRequest({
|
||||
method: 'net.dns.set-static',
|
||||
|
||||
@@ -22,7 +22,6 @@ import { from, interval, map, shareReplay, startWith, Subject, tap } from 'rxjs'
|
||||
import { mockPatchData } from './mock-patch'
|
||||
import { AuthService } from '../auth.service'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
import { MarketplacePkg } from '@start9labs/marketplace'
|
||||
import { WebSocketSubject } from 'rxjs/webSocket'
|
||||
import { toAuthorityUrl } from 'src/app/utils/acme'
|
||||
|
||||
@@ -454,6 +453,36 @@ export class MockApiService extends ApiService {
|
||||
return null
|
||||
}
|
||||
|
||||
async setKeyboard(params: RR.SetKeyboardReq): Promise<RR.SetKeyboardRes> {
|
||||
await pauseFor(1000)
|
||||
|
||||
const patch = [
|
||||
{
|
||||
op: PatchOp.REPLACE,
|
||||
path: '/serverInfo/keyboard',
|
||||
value: params,
|
||||
},
|
||||
]
|
||||
this.mockRevision(patch)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async setLanguage(params: RR.SetLanguageReq): Promise<RR.SetLanguageRes> {
|
||||
await pauseFor(1000)
|
||||
|
||||
const patch = [
|
||||
{
|
||||
op: PatchOp.REPLACE,
|
||||
path: '/serverInfo/language',
|
||||
value: params.language,
|
||||
},
|
||||
]
|
||||
this.mockRevision(patch)
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async setDns(params: RR.SetDnsReq): Promise<RR.SetDnsRes> {
|
||||
await pauseFor(2000)
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ export const mockPatchData: DataModel = {
|
||||
},
|
||||
startosRegistry: 'https://registry.start9.com/',
|
||||
snakeHighScore: 0,
|
||||
language: 'english',
|
||||
},
|
||||
serverInfo: {
|
||||
arch: 'x86_64',
|
||||
@@ -220,6 +219,15 @@ export const mockPatchData: DataModel = {
|
||||
ram: 8 * 1024 * 1024 * 1024,
|
||||
devices: [],
|
||||
kiosk: true,
|
||||
language: 'en_US',
|
||||
keyboard: {
|
||||
layout: 'us',
|
||||
keymap: 'us',
|
||||
model: null,
|
||||
variant: null,
|
||||
options: [],
|
||||
},
|
||||
// keyboard: null,
|
||||
},
|
||||
packageData: {
|
||||
lnd: {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Languages } from '@start9labs/shared'
|
||||
import { FullKeyboard, Languages } from '@start9labs/shared'
|
||||
import { T } from '@start9labs/start-sdk'
|
||||
|
||||
export type DataModel = T.Public & {
|
||||
export type DataModel = {
|
||||
ui: UIData
|
||||
serverInfo: T.ServerInfo & {
|
||||
language: Languages
|
||||
keyboard: FullKeyboard | null
|
||||
}
|
||||
packageData: AllPackageData
|
||||
}
|
||||
|
||||
@@ -11,7 +15,6 @@ export type UIData = {
|
||||
registries: Record<string, string | null>
|
||||
snakeHighScore: number
|
||||
startosRegistry: string
|
||||
language: Languages
|
||||
}
|
||||
|
||||
export type PackageDataEntry<T extends StateInfo = StateInfo> =
|
||||
|
||||
Reference in New Issue
Block a user