mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 02:11:53 +00:00
chore: refactor install and setup wizards (#2561)
* chore: refactor install and setup wizards * chore: return tui-root
This commit is contained in:
@@ -184,12 +184,7 @@
|
||||
}
|
||||
],
|
||||
"styles": [
|
||||
"node_modules/@taiga-ui/core/styles/taiga-ui-theme.less",
|
||||
"projects/shared/styles/taiga.scss",
|
||||
"projects/shared/styles/variables.scss",
|
||||
"projects/shared/styles/global.scss",
|
||||
"projects/shared/styles/shared.scss",
|
||||
"projects/install-wizard/src/styles.scss"
|
||||
"node_modules/@taiga-ui/core/styles/taiga-ui-theme.less"
|
||||
],
|
||||
"scripts": []
|
||||
},
|
||||
@@ -322,10 +317,6 @@
|
||||
],
|
||||
"styles": [
|
||||
"node_modules/@taiga-ui/core/styles/taiga-ui-theme.less",
|
||||
"projects/shared/styles/taiga.scss",
|
||||
"projects/shared/styles/variables.scss",
|
||||
"projects/shared/styles/global.scss",
|
||||
"projects/shared/styles/shared.scss",
|
||||
"projects/setup-wizard/src/styles.scss"
|
||||
],
|
||||
"scripts": []
|
||||
|
||||
26
web/package-lock.json
generated
26
web/package-lock.json
generated
@@ -24,15 +24,15 @@
|
||||
"@start9labs/argon2": "^0.1.0",
|
||||
"@start9labs/emver": "^0.1.5",
|
||||
"@start9labs/start-sdk": "0.4.0-rev0.lib0.rc8.beta2",
|
||||
"@taiga-ui/addon-charts": "^3.65.0",
|
||||
"@taiga-ui/addon-commerce": "^3.65.0",
|
||||
"@taiga-ui/addon-mobile": "^3.65.0",
|
||||
"@taiga-ui/cdk": "^3.65.0",
|
||||
"@taiga-ui/core": "^3.65.0",
|
||||
"@taiga-ui/experimental": "^3.65.0",
|
||||
"@taiga-ui/icons": "^3.65.0",
|
||||
"@taiga-ui/kit": "^3.65.0",
|
||||
"@taiga-ui/styles": "^3.65.0",
|
||||
"@taiga-ui/addon-charts": "3.65.0",
|
||||
"@taiga-ui/addon-commerce": "3.65.0",
|
||||
"@taiga-ui/addon-mobile": "3.65.0",
|
||||
"@taiga-ui/cdk": "3.65.0",
|
||||
"@taiga-ui/core": "3.65.0",
|
||||
"@taiga-ui/experimental": "3.65.0",
|
||||
"@taiga-ui/icons": "3.65.0",
|
||||
"@taiga-ui/kit": "3.65.0",
|
||||
"@taiga-ui/styles": "3.65.0",
|
||||
"@tinkoff/ng-dompurify": "4.0.0",
|
||||
"@tinkoff/ng-event-plugins": "3.1.0",
|
||||
"ansi-to-html": "^0.7.2",
|
||||
@@ -83,7 +83,7 @@
|
||||
"ng-packagr": "^17.0.2",
|
||||
"node-html-parser": "^5.3.3",
|
||||
"postcss": "^8.4.21",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier": "^3.2.5",
|
||||
"raw-loader": "^4.0.2",
|
||||
"ts-node": "^10.7.0",
|
||||
"tslint": "^6.1.3",
|
||||
@@ -14107,9 +14107,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.1.tgz",
|
||||
"integrity": "sha512-22UbSzg8luF4UuZtzgiUOfcGM8s4tjBv6dJRT7j275NXsy2jb4aJa4NNveul5x4eqlF1wuhuR2RElK71RvmVaw==",
|
||||
"version": "3.2.5",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.5.tgz",
|
||||
"integrity": "sha512-3/GWa9aOC0YeD7LUfvOG2NiDyhOWRvt1k+rcKhOuYnMY24iiCphgneUfJDyFXd6rZCAnuLBv6UeAULtrhT/F4A==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
"ng-packagr": "^17.0.2",
|
||||
"node-html-parser": "^5.3.3",
|
||||
"postcss": "^8.4.21",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier": "^3.2.5",
|
||||
"raw-loader": "^4.0.2",
|
||||
"ts-node": "^10.7.0",
|
||||
"tslint": "^6.1.3",
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { PreloadAllModules, RouterModule, Routes } from '@angular/router'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadChildren: () =>
|
||||
import('./pages/home/home.module').then(m => m.HomePageModule),
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forRoot(routes, {
|
||||
scrollPositionRestoration: 'enabled',
|
||||
preloadingStrategy: PreloadAllModules,
|
||||
useHash: true,
|
||||
}),
|
||||
],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class AppRoutingModule {}
|
||||
@@ -1,6 +1,58 @@
|
||||
<tui-theme-night></tui-theme-night>
|
||||
<tui-root tuiMode="onDark">
|
||||
<ion-app>
|
||||
<ion-router-outlet></ion-router-outlet>
|
||||
</ion-app>
|
||||
<tui-root>
|
||||
<main>
|
||||
<img class="logo" src="assets/img/icon.png" alt="Start9" />
|
||||
<section tuiCardLarge tuiSurface="elevated" class="card">
|
||||
<header class="header">
|
||||
@if (selected) {
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="flat"
|
||||
size="m"
|
||||
class="back"
|
||||
iconLeft="tuiIconChevronLeft"
|
||||
[style.border-radius.rem]="10"
|
||||
(click)="selected = null"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
}
|
||||
<h1>{{ selected ? 'Install Type' : 'Select Disk' }}</h1>
|
||||
<div [style.color]="'var(--tui-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="tuiIconLifeBuoyLarge" />
|
||||
<span tuiTitle>
|
||||
<strong [style.color]="'var(--tui-positive)'">
|
||||
Re-Install StartOS
|
||||
</strong>
|
||||
<span tuiSubtitle>Will preserve existing StartOS data</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button tuiCell [disabled]="!selected" (click)="warn()">
|
||||
<tui-icon icon="tuiIconDownload" />
|
||||
<span tuiTitle>
|
||||
@if (guid) {
|
||||
<span [style.color]="'var(--tui-negative)'">Factory Reset</span>
|
||||
} @else {
|
||||
<span [style.color]="'var(--tui-positive)'">
|
||||
Install StartOS
|
||||
</span>
|
||||
}
|
||||
<span tuiSubtitle>Will delete existing data on disk</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</tui-root>
|
||||
|
||||
@@ -1,8 +1,63 @@
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
::ng-deep html,
|
||||
::ng-deep body,
|
||||
tui-root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
color: var(--tui-text-01);
|
||||
}
|
||||
|
||||
main {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: var(--tui-base-08);
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.back {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
}
|
||||
|
||||
.pages {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.options {
|
||||
@include transition(margin);
|
||||
|
||||
min-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
|
||||
&_selected {
|
||||
margin-left: -100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { DiskInfo, LoadingService, toGuid } from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
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',
|
||||
@@ -6,5 +12,65 @@ import { Component } from '@angular/core'
|
||||
styleUrls: ['app.component.scss'],
|
||||
})
|
||||
export class AppComponent {
|
||||
constructor() {}
|
||||
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...').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_PROMPT, toWarning(this.selected))
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => {
|
||||
this.install(true)
|
||||
})
|
||||
}
|
||||
|
||||
private async reboot() {
|
||||
this.dialogs
|
||||
.open(
|
||||
'Remove the USB stick and reboot your device to begin using your new Start9 server',
|
||||
SUCCESS,
|
||||
)
|
||||
.subscribe({
|
||||
complete: async () => {
|
||||
const loader = this.loader.open('').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.reboot()
|
||||
this.dialogs
|
||||
.open(
|
||||
'Please wait for StartOS to restart, then refresh this page',
|
||||
{ label: 'Rebooting', size: 's' },
|
||||
)
|
||||
.subscribe()
|
||||
} catch (e: any) {
|
||||
this.error = e.message
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,26 @@
|
||||
import { HttpClientModule } from '@angular/common/http'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
|
||||
import { RouteReuseStrategy } from '@angular/router'
|
||||
import { IonicModule, IonicRouteStrategy } from '@ionic/angular'
|
||||
import {
|
||||
TuiDialogModule,
|
||||
TuiModeModule,
|
||||
TuiRootModule,
|
||||
TuiThemeNightModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { AppComponent } from './app.component'
|
||||
import { AppRoutingModule } from './app-routing.module'
|
||||
import { HttpClientModule } from '@angular/common/http'
|
||||
import { ApiService } from './services/api/api.service'
|
||||
import { MockApiService } from './services/api/mock-api.service'
|
||||
import { LiveApiService } from './services/api/live-api.service'
|
||||
import {
|
||||
DriveComponent,
|
||||
LoadingModule,
|
||||
RELATIVE_URL,
|
||||
UnitConversionPipesModule,
|
||||
WorkspaceConfig,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiDialogModule, TuiRootModule } from '@taiga-ui/core'
|
||||
import {
|
||||
TuiButtonModule,
|
||||
TuiCardModule,
|
||||
TuiCellModule,
|
||||
TuiIconModule,
|
||||
TuiSurfaceModule,
|
||||
TuiTitleModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
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,
|
||||
@@ -30,18 +32,19 @@ const {
|
||||
imports: [
|
||||
HttpClientModule,
|
||||
BrowserAnimationsModule,
|
||||
IonicModule.forRoot({
|
||||
mode: 'md',
|
||||
}),
|
||||
AppRoutingModule,
|
||||
TuiRootModule,
|
||||
TuiDialogModule,
|
||||
LoadingModule,
|
||||
TuiModeModule,
|
||||
TuiThemeNightModule,
|
||||
DriveComponent,
|
||||
TuiButtonModule,
|
||||
TuiCardModule,
|
||||
TuiCellModule,
|
||||
TuiIconModule,
|
||||
TuiSurfaceModule,
|
||||
TuiTitleModule,
|
||||
UnitConversionPipesModule,
|
||||
],
|
||||
providers: [
|
||||
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
|
||||
{
|
||||
provide: ApiService,
|
||||
useClass: useMocks ? MockApiService : LiveApiService,
|
||||
|
||||
27
web/projects/install-wizard/src/app/app.utils.ts
Normal file
27
web/projects/install-wizard/src/app/app.utils.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { DiskInfo } from '@start9labs/shared'
|
||||
import { TuiDialogOptions } from '@taiga-ui/core'
|
||||
import { TuiPromptData } from '@taiga-ui/kit'
|
||||
|
||||
export const SUCCESS: Partial<TuiDialogOptions<any>> = {
|
||||
label: 'Install Success',
|
||||
closeable: false,
|
||||
dismissible: false,
|
||||
size: 's',
|
||||
data: { button: 'Reboot' },
|
||||
}
|
||||
|
||||
export function toWarning(
|
||||
disk: DiskInfo | null,
|
||||
): Partial<TuiDialogOptions<TuiPromptData>> {
|
||||
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,32 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { HomePage } from './home.page'
|
||||
import { SwiperModule } from 'swiper/angular'
|
||||
import {
|
||||
UnitConversionPipesModule,
|
||||
GuidPipePipesModule,
|
||||
} from '@start9labs/shared'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: HomePage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
SwiperModule,
|
||||
UnitConversionPipesModule,
|
||||
GuidPipePipesModule,
|
||||
],
|
||||
declarations: [HomePage],
|
||||
})
|
||||
export class HomePageModule {}
|
||||
@@ -1,107 +0,0 @@
|
||||
<ion-content>
|
||||
<ion-grid>
|
||||
<ion-row>
|
||||
<ion-col class="ion-text-center">
|
||||
<div style="padding: 64px 0 32px 0">
|
||||
<img src="assets/img/icon.png" style="max-width: 100px" />
|
||||
</div>
|
||||
|
||||
<ion-card color="dark">
|
||||
<ion-card-header>
|
||||
<ion-button
|
||||
*ngIf="swiper?.activeIndex === 1"
|
||||
class="back-button"
|
||||
fill="clear"
|
||||
color="light"
|
||||
(click)="previous()"
|
||||
>
|
||||
<ion-icon slot="icon-only" name="arrow-back"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-card-title>
|
||||
{{ !swiper || swiper.activeIndex === 0 ? 'Select Disk' : 'Install
|
||||
Type' }}
|
||||
</ion-card-title>
|
||||
<ion-card-subtitle *ngIf="error">
|
||||
<ion-text color="danger">{{ error }}</ion-text>
|
||||
</ion-card-subtitle>
|
||||
</ion-card-header>
|
||||
<ion-card-content class="ion-margin-bottom">
|
||||
<swiper (swiper)="setSwiperInstance($event)">
|
||||
<!-- SLIDE 1 -->
|
||||
<ng-template swiperSlide>
|
||||
<ion-item
|
||||
*ngFor="let disk of disks"
|
||||
button
|
||||
(click)="next(disk)"
|
||||
>
|
||||
<ion-icon
|
||||
slot="start"
|
||||
name="save-outline"
|
||||
size="large"
|
||||
color="dark"
|
||||
></ion-icon>
|
||||
<ion-label class="ion-text-wrap">
|
||||
<h1>
|
||||
{{ disk.vendor || 'Unknown Vendor' }} - {{ disk.model ||
|
||||
'Unknown Model' }}
|
||||
</h1>
|
||||
<h2>
|
||||
{{ disk.logicalname }} - {{ disk.capacity | convertBytes
|
||||
}}
|
||||
</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-template>
|
||||
|
||||
<!-- SLIDE 2 -->
|
||||
<ng-template swiperSlide>
|
||||
<ng-container *ngIf="selectedDisk">
|
||||
<!-- re-install -->
|
||||
<ion-item
|
||||
*ngIf="selectedDisk | guid"
|
||||
button
|
||||
(click)="tryInstall(false)"
|
||||
>
|
||||
<ion-icon
|
||||
color="dark"
|
||||
slot="start"
|
||||
size="large"
|
||||
name="medkit-outline"
|
||||
></ion-icon>
|
||||
<ion-label>
|
||||
<h1>
|
||||
<ion-text color="success">Re-Install StartOS</ion-text>
|
||||
</h1>
|
||||
<h2>Will preserve existing StartOS data</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- fresh install -->
|
||||
<ion-item button lines="none" (click)="tryInstall(true)">
|
||||
<ion-icon
|
||||
color="dark"
|
||||
slot="start"
|
||||
size="large"
|
||||
name="download-outline"
|
||||
></ion-icon>
|
||||
<ion-label>
|
||||
<h1>
|
||||
<ion-text
|
||||
[color]="(selectedDisk | guid) ? 'danger' : 'success'"
|
||||
>
|
||||
{{ (selectedDisk | guid) ? 'Factory Reset' : 'Install
|
||||
StartOS' }}
|
||||
</ion-text>
|
||||
</h1>
|
||||
<h2>Will delete existing data on disk</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</swiper>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -1,28 +0,0 @@
|
||||
/** Ionic CSS Variables overrides **/
|
||||
:root {
|
||||
--ion-font-family: 'Benton Sans', sans-serif;
|
||||
}
|
||||
|
||||
ion-content {
|
||||
--background: var(--ion-color-medium);
|
||||
}
|
||||
|
||||
ion-grid {
|
||||
padding-top: 32px;
|
||||
height: 100%;
|
||||
max-width: 640px;
|
||||
}
|
||||
|
||||
.back-button {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 24px;
|
||||
z-index: 1000000;
|
||||
}
|
||||
|
||||
ion-card-title {
|
||||
margin: 16px 0;
|
||||
font-family: 'Montserrat';
|
||||
font-size: x-large;
|
||||
--color: var(--ion-color-light);
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { IonicSlides } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import SwiperCore, { Swiper } from 'swiper'
|
||||
import { DiskInfo, LoadingService } from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { filter } from 'rxjs'
|
||||
|
||||
SwiperCore.use([IonicSlides])
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
templateUrl: 'home.page.html',
|
||||
styleUrls: ['home.page.scss'],
|
||||
})
|
||||
export class HomePage {
|
||||
swiper?: Swiper
|
||||
disks: DiskInfo[] = []
|
||||
selectedDisk?: DiskInfo
|
||||
error = ''
|
||||
|
||||
constructor(
|
||||
private readonly loader: LoadingService,
|
||||
private readonly api: ApiService,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.disks = await this.api.getDisks()
|
||||
}
|
||||
|
||||
async ionViewDidEnter() {
|
||||
if (this.swiper) {
|
||||
this.swiper.allowTouchMove = false
|
||||
}
|
||||
}
|
||||
|
||||
setSwiperInstance(swiper: any) {
|
||||
this.swiper = swiper
|
||||
}
|
||||
|
||||
next(disk: DiskInfo) {
|
||||
this.selectedDisk = disk
|
||||
this.swiper?.slideNext(500)
|
||||
}
|
||||
|
||||
previous() {
|
||||
this.swiper?.slidePrev(500)
|
||||
}
|
||||
|
||||
async tryInstall(overwrite: boolean) {
|
||||
if (overwrite) {
|
||||
return this.presentAlertDanger()
|
||||
}
|
||||
|
||||
this.install(false)
|
||||
}
|
||||
|
||||
private async install(overwrite: boolean) {
|
||||
const loader = this.loader.open('Installing StartOS...').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.install({
|
||||
logicalname: this.selectedDisk!.logicalname,
|
||||
overwrite,
|
||||
})
|
||||
this.presentAlertReboot()
|
||||
} catch (e: any) {
|
||||
this.error = e.message
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private presentAlertDanger() {
|
||||
const { vendor, model } = this.selectedDisk!
|
||||
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content: `This action will COMPLETELY erase the disk ${
|
||||
vendor || 'Unknown Vendor'
|
||||
} - ${model || 'Unknown Model'} and install StartOS in its place`,
|
||||
yes: 'Continue',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => {
|
||||
this.install(true)
|
||||
})
|
||||
}
|
||||
|
||||
private async presentAlertReboot() {
|
||||
this.dialogs
|
||||
.open(
|
||||
'Remove the USB stick and reboot your device to begin using your new Start9 server',
|
||||
{
|
||||
label: 'Install Success',
|
||||
closeable: false,
|
||||
dismissible: false,
|
||||
size: 's',
|
||||
data: { button: 'Reboot' },
|
||||
},
|
||||
)
|
||||
.subscribe({
|
||||
complete: () => {
|
||||
this.reboot()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
private async reboot() {
|
||||
const loader = this.loader.open('').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.reboot()
|
||||
this.presentAlertComplete()
|
||||
} catch (e: any) {
|
||||
this.error = e.message
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private presentAlertComplete() {
|
||||
this.dialogs
|
||||
.open('Please wait for StartOS to restart, then refresh this page', {
|
||||
label: 'Rebooting',
|
||||
size: 's',
|
||||
})
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
src: url('/assets/fonts/Montserrat/Montserrat-Regular.ttf');
|
||||
}
|
||||
|
||||
/** Ionic CSS Variables overrides **/
|
||||
:root {
|
||||
--ion-font-family: 'Montserrat', sans-serif;
|
||||
|
||||
--ion-color-primary: #0075e1;
|
||||
|
||||
--ion-color-medium: #989aa2;
|
||||
--ion-color-medium-rgb: 152,154,162;
|
||||
--ion-color-medium-contrast: #000000;
|
||||
--ion-color-medium-contrast-rgb: 0,0,0;
|
||||
--ion-color-medium-shade: #86888f;
|
||||
--ion-color-medium-tint: #a2a4ab;
|
||||
|
||||
--ion-color-light: #222428;
|
||||
--ion-color-light-rgb: 34,36,40;
|
||||
--ion-color-light-contrast: #ffffff;
|
||||
--ion-color-light-contrast-rgb: 255,255,255;
|
||||
--ion-color-light-shade: #1e2023;
|
||||
--ion-color-light-tint: #383a3e;
|
||||
|
||||
--ion-item-background: #2b2b2b;
|
||||
--ion-toolbar-background: #2b2b2b;
|
||||
--ion-card-background: #2b2b2b;
|
||||
|
||||
--ion-background-color: #282828;
|
||||
--ion-background-color-rgb: 30,30,30;
|
||||
--ion-text-color: var(--ion-color-dark);
|
||||
--ion-text-color-rgb: var(--ion-color-dark-rgb);
|
||||
}
|
||||
|
||||
.loader {
|
||||
--spinner-color: var(--ion-color-warning) !important;
|
||||
z-index: 40000 !important;
|
||||
}
|
||||
|
||||
.alert-danger-message {
|
||||
.alert-title {
|
||||
color: var(--ion-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.alert-success-message {
|
||||
.alert-title {
|
||||
color: var(--ion-color-success);
|
||||
}
|
||||
}
|
||||
|
||||
ion-alert {
|
||||
.alert-button {
|
||||
color: var(--ion-color-dark) !important;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
import { FormsModule } from "@angular/forms";
|
||||
import { ResponsiveColDirective } from "@start9labs/shared";
|
||||
import { SearchComponent } from "./search.component";
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { SearchComponent } from './search.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [FormsModule, CommonModule, ResponsiveColDirective],
|
||||
imports: [FormsModule, CommonModule],
|
||||
declarations: [SearchComponent],
|
||||
exports: [SearchComponent],
|
||||
})
|
||||
|
||||
@@ -1,19 +1,17 @@
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { NgModule } from "@angular/core";
|
||||
import { AdditionalComponent } from "./additional.component";
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { AdditionalComponent } from './additional.component'
|
||||
import {
|
||||
TuiRadioListModule,
|
||||
TuiStringifyContentPipeModule,
|
||||
} from "@taiga-ui/kit";
|
||||
import { FormsModule } from "@angular/forms";
|
||||
import { TuiButtonModule, TuiLabelModule } from "@taiga-ui/core";
|
||||
import { AdditionalLinkModule } from "./additional-link/additional-link.component.module";
|
||||
import { ResponsiveColDirective } from "@start9labs/shared";
|
||||
} from '@taiga-ui/kit'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { TuiButtonModule, TuiLabelModule } from '@taiga-ui/core'
|
||||
import { AdditionalLinkModule } from './additional-link/additional-link.component.module'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
ResponsiveColDirective,
|
||||
TuiRadioListModule,
|
||||
FormsModule,
|
||||
TuiStringifyContentPipeModule,
|
||||
|
||||
@@ -1,17 +1,11 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { EmverPipesModule, ResponsiveColDirective } from '@start9labs/shared'
|
||||
import { EmverPipesModule } from '@start9labs/shared'
|
||||
import { DependenciesComponent } from './dependencies.component'
|
||||
import { TuiAvatarModule } from '@taiga-ui/experimental'
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterModule,
|
||||
ResponsiveColDirective,
|
||||
TuiAvatarModule,
|
||||
EmverPipesModule,
|
||||
],
|
||||
imports: [CommonModule, RouterModule, TuiAvatarModule, EmverPipesModule],
|
||||
declarations: [DependenciesComponent],
|
||||
exports: [DependenciesComponent],
|
||||
})
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { PreloadAllModules, RouterModule, Routes } from '@angular/router'
|
||||
|
||||
const routes: Routes = [
|
||||
{ path: '', redirectTo: '/home', pathMatch: 'full' },
|
||||
{
|
||||
path: 'home',
|
||||
loadChildren: () =>
|
||||
import('./pages/home/home.module').then(m => m.HomePageModule),
|
||||
},
|
||||
{
|
||||
path: 'attach',
|
||||
loadChildren: () =>
|
||||
import('./pages/attach/attach.module').then(m => m.AttachPageModule),
|
||||
},
|
||||
{
|
||||
path: 'recover',
|
||||
loadChildren: () =>
|
||||
import('./pages/recover/recover.module').then(m => m.RecoverPageModule),
|
||||
},
|
||||
{
|
||||
path: 'transfer',
|
||||
loadChildren: () =>
|
||||
import('./pages/transfer/transfer.module').then(
|
||||
m => m.TransferPageModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'storage',
|
||||
loadChildren: () =>
|
||||
import('./pages/embassy/embassy.module').then(m => m.EmbassyPageModule),
|
||||
},
|
||||
{
|
||||
path: 'loading',
|
||||
loadChildren: () =>
|
||||
import('./pages/loading/loading.module').then(m => m.LoadingPageModule),
|
||||
},
|
||||
{
|
||||
path: 'success',
|
||||
loadChildren: () =>
|
||||
import('./pages/success/success.module').then(m => m.SuccessPageModule),
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forRoot(routes, {
|
||||
scrollPositionRestoration: 'enabled',
|
||||
preloadingStrategy: PreloadAllModules,
|
||||
useHash: true,
|
||||
initialNavigation: 'disabled',
|
||||
}),
|
||||
],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class AppRoutingModule {}
|
||||
@@ -1,6 +0,0 @@
|
||||
<tui-theme-night></tui-theme-night>
|
||||
<tui-root tuiMode="onDark">
|
||||
<ion-app>
|
||||
<ion-router-outlet></ion-router-outlet>
|
||||
</ion-app>
|
||||
</tui-root>
|
||||
@@ -1,8 +0,0 @@
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
tui-root {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -1,30 +1,31 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { ApiService } from './services/api/api.service'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { ApiService } from 'src/app/services/api.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: 'app.component.html',
|
||||
styleUrls: ['app.component.scss'],
|
||||
template: `
|
||||
<tui-theme-night></tui-theme-night>
|
||||
<tui-root tuiMode="onDark"><router-outlet /></tui-root>
|
||||
`,
|
||||
})
|
||||
export class AppComponent {
|
||||
constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly navCtrl: NavController,
|
||||
) {}
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly router = inject(Router)
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
const inProgress = await this.apiService.getSetupStatus()
|
||||
const inProgress = await this.api.getSetupStatus()
|
||||
|
||||
let route = 'home'
|
||||
|
||||
let route = '/home'
|
||||
if (inProgress) {
|
||||
route = inProgress.complete ? '/success' : '/loading'
|
||||
route = inProgress.complete ? 'success' : 'loading'
|
||||
}
|
||||
|
||||
await this.navCtrl.navigateForward(route)
|
||||
await this.router.navigate([route])
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
}
|
||||
|
||||
@@ -1,30 +1,7 @@
|
||||
import { HttpClientModule } from '@angular/common/http'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
|
||||
import { RouteReuseStrategy } from '@angular/router'
|
||||
import { HttpClientModule } from '@angular/common/http'
|
||||
import {
|
||||
TuiAlertModule,
|
||||
tuiButtonOptionsProvider,
|
||||
TuiDialogModule,
|
||||
TuiModeModule,
|
||||
TuiRootModule,
|
||||
TuiThemeNightModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { ApiService } from './services/api/api.service'
|
||||
import { MockApiService } from './services/api/mock-api.service'
|
||||
import { LiveApiService } from './services/api/live-api.service'
|
||||
import {
|
||||
IonicModule,
|
||||
IonicRouteStrategy,
|
||||
iosTransitionAnimation,
|
||||
} from '@ionic/angular'
|
||||
import { AppComponent } from './app.component'
|
||||
import { AppRoutingModule } from './app-routing.module'
|
||||
import { SuccessPageModule } from './pages/success/success.module'
|
||||
import { HomePageModule } from './pages/home/home.module'
|
||||
import { LoadingPageModule } from './pages/loading/loading.module'
|
||||
import { RecoverPageModule } from './pages/recover/recover.module'
|
||||
import { TransferPageModule } from './pages/transfer/transfer.module'
|
||||
import { PreloadAllModules, RouterModule } from '@angular/router'
|
||||
import {
|
||||
LoadingModule,
|
||||
provideSetupLogsService,
|
||||
@@ -32,6 +9,19 @@ import {
|
||||
RELATIVE_URL,
|
||||
WorkspaceConfig,
|
||||
} from '@start9labs/shared'
|
||||
import {
|
||||
TuiAlertModule,
|
||||
TuiDialogModule,
|
||||
TuiModeModule,
|
||||
TuiRootModule,
|
||||
TuiThemeNightModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { tuiButtonOptionsProvider } from '@taiga-ui/experimental'
|
||||
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'
|
||||
import { ROUTES } from './app.routes'
|
||||
|
||||
const {
|
||||
useMocks,
|
||||
@@ -42,21 +32,15 @@ const {
|
||||
declarations: [AppComponent],
|
||||
imports: [
|
||||
BrowserAnimationsModule,
|
||||
IonicModule.forRoot({
|
||||
mode: 'md',
|
||||
navAnimation: iosTransitionAnimation,
|
||||
}),
|
||||
AppRoutingModule,
|
||||
HttpClientModule,
|
||||
SuccessPageModule,
|
||||
HomePageModule,
|
||||
LoadingPageModule,
|
||||
RecoverPageModule,
|
||||
TransferPageModule,
|
||||
RouterModule.forRoot(ROUTES, {
|
||||
preloadingStrategy: PreloadAllModules,
|
||||
initialNavigation: 'disabled',
|
||||
}),
|
||||
LoadingModule,
|
||||
TuiRootModule,
|
||||
TuiDialogModule,
|
||||
TuiAlertModule,
|
||||
LoadingModule,
|
||||
TuiModeModule,
|
||||
TuiThemeNightModule,
|
||||
],
|
||||
@@ -64,7 +48,6 @@ const {
|
||||
provideSetupService(ApiService),
|
||||
provideSetupLogsService(ApiService),
|
||||
tuiButtonOptionsProvider({ size: 'm' }),
|
||||
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
|
||||
{
|
||||
provide: ApiService,
|
||||
useClass: useMocks ? MockApiService : LiveApiService,
|
||||
|
||||
33
web/projects/setup-wizard/src/app/app.routes.ts
Normal file
33
web/projects/setup-wizard/src/app/app.routes.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { Routes } from '@angular/router'
|
||||
|
||||
export const ROUTES: Routes = [
|
||||
{ path: '', redirectTo: '/home', pathMatch: 'full' },
|
||||
{
|
||||
path: 'home',
|
||||
loadComponent: () => import('src/app/pages/home.page'),
|
||||
},
|
||||
{
|
||||
path: 'attach',
|
||||
loadComponent: () => import('src/app/pages/attach.page'),
|
||||
},
|
||||
{
|
||||
path: 'recover',
|
||||
loadComponent: () => import('src/app/pages/recover.page'),
|
||||
},
|
||||
{
|
||||
path: 'transfer',
|
||||
loadComponent: () => import('src/app/pages/transfer.page'),
|
||||
},
|
||||
{
|
||||
path: 'storage',
|
||||
loadComponent: () => import('src/app/pages/storage.page'),
|
||||
},
|
||||
{
|
||||
path: 'loading',
|
||||
loadComponent: () => import('src/app/pages/loading.page'),
|
||||
},
|
||||
{
|
||||
path: 'success',
|
||||
loadComponent: () => import('src/app/pages/success.page'),
|
||||
},
|
||||
]
|
||||
195
web/projects/setup-wizard/src/app/components/cifs.component.ts
Normal file
195
web/projects/setup-wizard/src/app/components/cifs.component.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Component, inject, Inject } from '@angular/core'
|
||||
import {
|
||||
FormControl,
|
||||
FormGroup,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
Validators,
|
||||
} from '@angular/forms'
|
||||
import { LoadingService, StartOSDiskInfo } from '@start9labs/shared'
|
||||
import {
|
||||
TuiButtonModule,
|
||||
TuiDialogContext,
|
||||
TuiDialogService,
|
||||
TuiErrorModule,
|
||||
} from '@taiga-ui/core'
|
||||
import {
|
||||
TUI_VALIDATION_ERRORS,
|
||||
TuiFieldErrorPipeModule,
|
||||
TuiInputModule,
|
||||
TuiInputPasswordModule,
|
||||
} from '@taiga-ui/kit'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { PASSWORD } from 'src/app/components/password.component'
|
||||
import {
|
||||
ApiService,
|
||||
CifsBackupTarget,
|
||||
CifsRecoverySource,
|
||||
} from 'src/app/services/api.service'
|
||||
|
||||
interface Context {
|
||||
cifs: CifsRecoverySource
|
||||
recoveryPassword: string
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
<tui-input formControlName="hostname">
|
||||
Hostname
|
||||
<input
|
||||
tuiTextfield
|
||||
placeholder="'My Computer' OR 'my-computer.local'"
|
||||
/>
|
||||
</tui-input>
|
||||
<tui-error
|
||||
formControlName="hostname"
|
||||
[error]="['required'] | tuiFieldError | async"
|
||||
></tui-error>
|
||||
|
||||
<tui-input formControlName="path" class="input">
|
||||
Path
|
||||
<input tuiTextfield placeholder="/Desktop/my-folder'" />
|
||||
</tui-input>
|
||||
<tui-error
|
||||
formControlName="path"
|
||||
[error]="[] | tuiFieldError | async"
|
||||
></tui-error>
|
||||
|
||||
<tui-input formControlName="username" class="input">
|
||||
Username
|
||||
<input tuiTextfield placeholder="Enter username" />
|
||||
</tui-input>
|
||||
<tui-error
|
||||
formControlName="username"
|
||||
[error]="[] | tuiFieldError | async"
|
||||
></tui-error>
|
||||
|
||||
<tui-input-password formControlName="password" class="input">
|
||||
Password
|
||||
</tui-input-password>
|
||||
|
||||
<footer>
|
||||
<button
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
type="button"
|
||||
(click)="cancel()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button tuiButton [disabled]="form.invalid">Verify</button>
|
||||
</footer>
|
||||
</form>
|
||||
`,
|
||||
styles: [
|
||||
'.input { margin-top: 1rem }',
|
||||
'footer { display: flex; gap: 1rem; margin-top: 1rem }',
|
||||
],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
TuiButtonModule,
|
||||
TuiInputModule,
|
||||
TuiInputPasswordModule,
|
||||
TuiErrorModule,
|
||||
TuiFieldErrorPipeModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: TUI_VALIDATION_ERRORS,
|
||||
useValue: {
|
||||
required: 'This field is required',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
export class CifsComponent {
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly loader = inject(LoadingService)
|
||||
private readonly context =
|
||||
inject<TuiDialogContext<Context>>(POLYMORPHEUS_CONTEXT)
|
||||
|
||||
readonly form = new FormGroup({
|
||||
hostname: new FormControl('', {
|
||||
validators: [
|
||||
Validators.required,
|
||||
Validators.pattern(/^[a-zA-Z0-9._-]+( [a-zA-Z0-9]+)*$/),
|
||||
],
|
||||
nonNullable: true,
|
||||
}),
|
||||
path: new FormControl('', {
|
||||
validators: [Validators.required],
|
||||
nonNullable: true,
|
||||
}),
|
||||
username: new FormControl('', {
|
||||
validators: [Validators.required],
|
||||
nonNullable: true,
|
||||
}),
|
||||
password: new FormControl(),
|
||||
})
|
||||
|
||||
cancel() {
|
||||
this.context.$implicit.complete()
|
||||
}
|
||||
|
||||
async submit(): Promise<void> {
|
||||
const loader = this.loader
|
||||
.open('Connecting to shared folder...')
|
||||
.subscribe()
|
||||
|
||||
try {
|
||||
const diskInfo = await this.api.verifyCifs({
|
||||
...this.form.getRawValue(),
|
||||
type: 'cifs',
|
||||
password: this.form.value.password
|
||||
? await this.api.encrypt(String(this.form.value.password))
|
||||
: null,
|
||||
})
|
||||
|
||||
loader.unsubscribe()
|
||||
|
||||
this.presentModalPassword(diskInfo)
|
||||
} catch (e) {
|
||||
loader.unsubscribe()
|
||||
this.presentAlertFailed()
|
||||
}
|
||||
}
|
||||
|
||||
private presentModalPassword(diskInfo: StartOSDiskInfo) {
|
||||
const target: CifsBackupTarget = {
|
||||
...this.form.getRawValue(),
|
||||
mountable: true,
|
||||
'embassy-os': diskInfo,
|
||||
}
|
||||
|
||||
this.dialogs
|
||||
.open<string>(PASSWORD, {
|
||||
label: 'Unlock Drive',
|
||||
size: 's',
|
||||
data: { target },
|
||||
})
|
||||
.subscribe(recoveryPassword => {
|
||||
this.context.completeWith({
|
||||
cifs: { ...this.form.getRawValue(), type: 'cifs' },
|
||||
recoveryPassword,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private presentAlertFailed() {
|
||||
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.',
|
||||
{
|
||||
label: 'Connection Failed',
|
||||
size: 's',
|
||||
},
|
||||
)
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-documentation',
|
||||
template: `
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>StartOS Address Info</title>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
style="
|
||||
font-family: Montserrat, sans-serif;
|
||||
color: #333333;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: auto;
|
||||
width: clamp(900px, 35vw, 600px);
|
||||
"
|
||||
>
|
||||
<h1
|
||||
style="
|
||||
font-variant-caps: all-small-caps;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
"
|
||||
>
|
||||
StartOS Address Info
|
||||
</h1>
|
||||
|
||||
<section
|
||||
style="
|
||||
padding: 1rem 3rem 2rem 3rem;
|
||||
margin-bottom: 24px;
|
||||
border: solid #c4c4c5 3px;
|
||||
border-radius: 20px;
|
||||
"
|
||||
>
|
||||
<div>
|
||||
<h3 style="color: #f8546a; font-weight: bold">Important!</h3>
|
||||
<p>
|
||||
Download your server's Root CA and
|
||||
<a
|
||||
href="https://docs.start9.com/0.3.5.x/user-manual/connecting-lan"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style="color: #6866cc; font-weight: bold; text-decoration: none"
|
||||
>
|
||||
follow the instructions
|
||||
</a>
|
||||
to establish a secure connection with your server.
|
||||
</p>
|
||||
</div>
|
||||
<div style="text-align: center">
|
||||
<a
|
||||
id="cert"
|
||||
[download]="crtName"
|
||||
style="
|
||||
display: inline-block;
|
||||
padding: 1em 1.2em;
|
||||
box-sizing: border-box;
|
||||
font-size: 1rem;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
border-radius: clamp(2rem, 3rem, 4rem);
|
||||
cursor: pointer;
|
||||
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 6px -1px,
|
||||
rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
|
||||
background: #6866cc;
|
||||
color: #f4f4f5;
|
||||
"
|
||||
>
|
||||
Download certificate
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
style="
|
||||
padding: 1rem 3rem 2rem 3rem;
|
||||
border: solid #c4c4c5 3px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 24px;
|
||||
"
|
||||
>
|
||||
<h2 style="font-variant-caps: all-small-caps">
|
||||
Access from home (LAN)
|
||||
</h2>
|
||||
<p>
|
||||
Visit the address below when you are connected to the same WiFi or
|
||||
Local Area Network (LAN) as your server.
|
||||
</p>
|
||||
<p
|
||||
style="
|
||||
padding: 16px;
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
overflow: auto;
|
||||
"
|
||||
>
|
||||
<code id="lan-addr"></code>
|
||||
</p>
|
||||
|
||||
<h2 style="font-variant-caps: all-small-caps">
|
||||
Access on the go (Tor)
|
||||
</h2>
|
||||
<p>Visit the address below when you are away from home.</p>
|
||||
<p>
|
||||
<span style="font-weight: bold">Note:</span>
|
||||
This address will only work from a Tor-enabled browser.
|
||||
<a
|
||||
href="https://docs.start9.com/0.3.5.x/user-manual/connecting-tor"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style="color: #6866cc; font-weight: bold; text-decoration: none"
|
||||
>
|
||||
Follow the instructions
|
||||
</a>
|
||||
to get setup.
|
||||
</p>
|
||||
<p
|
||||
style="
|
||||
padding: 16px;
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
overflow: auto;
|
||||
"
|
||||
>
|
||||
<code id="tor-addr"></code>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DocumentationComponent {
|
||||
@Input({ required: true }) lanAddress!: string
|
||||
|
||||
get crtName(): string {
|
||||
return `${new URL(this.lanAddress).hostname}.crt`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
Component,
|
||||
Directive,
|
||||
ElementRef,
|
||||
inject,
|
||||
NgZone,
|
||||
OnInit,
|
||||
} from '@angular/core'
|
||||
import { WINDOW } from '@ng-web-apis/common'
|
||||
|
||||
// a higher fade factor will make the characters fade quicker
|
||||
const FADE_FACTOR = 0.07
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'canvas[matrix]',
|
||||
template: 'Your browser does not support the canvas element.',
|
||||
styles: ':host { position: fixed; }',
|
||||
})
|
||||
export class MatrixComponent implements OnInit {
|
||||
private readonly ngZone = inject(NgZone)
|
||||
private readonly window = inject(WINDOW)
|
||||
private readonly el: HTMLCanvasElement = inject(ElementRef).nativeElement
|
||||
private readonly ctx = this.el.getContext('2d')!
|
||||
|
||||
private tileSize = 16
|
||||
private columns: any[] = []
|
||||
private maxStackHeight = 0
|
||||
|
||||
ngOnInit() {
|
||||
this.ngZone.runOutsideAngular(() => {
|
||||
this.setupMatrixGrid()
|
||||
this.tick()
|
||||
})
|
||||
}
|
||||
|
||||
private setupMatrixGrid() {
|
||||
this.el.width = Math.max(this.window.innerWidth, 1920)
|
||||
this.el.height = Math.max(this.window.innerHeight, 1080)
|
||||
this.maxStackHeight = Math.ceil(this.ctx.canvas.height / this.tileSize)
|
||||
// divide the canvas into columns
|
||||
for (let i = 0; i < this.ctx.canvas.width / this.tileSize; ++i) {
|
||||
const column = {} as any
|
||||
// save the x position of the column
|
||||
column.x = i * this.tileSize
|
||||
// create a random stack height for the column
|
||||
column.stackHeight = 10 + Math.random() * this.maxStackHeight
|
||||
// add a counter to count the stack height
|
||||
column.stackCounter = 0
|
||||
// add the column to the list
|
||||
this.columns.push(column)
|
||||
}
|
||||
}
|
||||
|
||||
private draw() {
|
||||
// draw a semi transparent black rectangle on top of the scene to slowly fade older characters
|
||||
this.ctx.fillStyle = `rgba(0, 0, 0, ${FADE_FACTOR})`
|
||||
this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height)
|
||||
// pick a font slightly smaller than the tile size
|
||||
this.ctx.font = `${this.tileSize - 2}px monospace`
|
||||
this.ctx.fillStyle = '#ff4961'
|
||||
for (let i = 0; i < this.columns.length; ++i) {
|
||||
// pick a random ascii character (change the 94 to a higher number to include more characters)
|
||||
const { x, stackCounter } = this.columns[i]
|
||||
const char = String.fromCharCode(33 + Math.floor(Math.random() * 94))
|
||||
this.ctx.fillText(char, x, stackCounter * this.tileSize + this.tileSize)
|
||||
// if the stack is at its height limit, pick a new random height and reset the counter
|
||||
if (++this.columns[i].stackCounter >= this.columns[i].stackHeight) {
|
||||
this.columns[i].stackHeight = 10 + Math.random() * this.maxStackHeight
|
||||
this.columns[i].stackCounter = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private tick() {
|
||||
this.draw()
|
||||
setTimeout(this.tick.bind(this), 50)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import * as argon2 from '@start9labs/argon2'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import {
|
||||
TuiButtonModule,
|
||||
TuiDialogContext,
|
||||
TuiErrorModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiInputPasswordModule } from '@taiga-ui/kit'
|
||||
import {
|
||||
POLYMORPHEUS_CONTEXT,
|
||||
PolymorpheusComponent,
|
||||
} from '@tinkoff/ng-polymorpheus'
|
||||
import {
|
||||
CifsBackupTarget,
|
||||
DiskBackupTarget,
|
||||
} from 'src/app/services/api.service'
|
||||
|
||||
interface DialogData {
|
||||
target?: CifsBackupTarget | DiskBackupTarget
|
||||
storageDrive?: boolean
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
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 [style.margin-top.rem]="1" (ngSubmit)="submit()">
|
||||
<tui-input-password [formControl]="password">
|
||||
Enter Password
|
||||
<input tuiTextfield maxlength="64" />
|
||||
</tui-input-password>
|
||||
<tui-error [error]="passwordError"></tui-error>
|
||||
@if (storageDrive) {
|
||||
<tui-input-password [style.margin-top.rem]="1" [formControl]="confirm">
|
||||
Retype Password
|
||||
<input tuiTextfield maxlength="64" />
|
||||
</tui-input-password>
|
||||
<tui-error [error]="confirmError"></tui-error>
|
||||
}
|
||||
<footer>
|
||||
<button
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
type="button"
|
||||
(click)="cancel()"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
tuiButton
|
||||
[disabled]="!password.value || !!confirmError || !!passwordError"
|
||||
>
|
||||
{{ storageDrive ? 'Finish' : 'Unlock' }}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
`,
|
||||
styles: ['footer { display: flex; gap: 1rem; margin-top: 1rem }'],
|
||||
imports: [
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
TuiButtonModule,
|
||||
TuiInputPasswordModule,
|
||||
TuiErrorModule,
|
||||
],
|
||||
})
|
||||
export class PasswordComponent {
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly context =
|
||||
inject<TuiDialogContext<string, DialogData>>(POLYMORPHEUS_CONTEXT)
|
||||
|
||||
readonly target = this.context.data.target
|
||||
readonly storageDrive = this.context.data.storageDrive
|
||||
readonly password = new FormControl('', { nonNullable: true })
|
||||
readonly confirm = new FormControl('', { nonNullable: true })
|
||||
|
||||
get passwordError(): string | null {
|
||||
if (!this.password.touched || this.target) return null
|
||||
|
||||
if (!this.storageDrive && !this.target?.['embassy-os'])
|
||||
return 'No recovery target' // unreachable
|
||||
|
||||
if (this.password.value.length < 12)
|
||||
return 'Must be 12 characters or greater'
|
||||
|
||||
if (this.password.value.length > 64)
|
||||
return 'Must be less than 65 characters'
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
get confirmError(): string | null {
|
||||
return this.confirm.touched && this.password.value !== this.confirm.value
|
||||
? 'Passwords do not match'
|
||||
: null
|
||||
}
|
||||
|
||||
submit() {
|
||||
if (this.storageDrive) {
|
||||
this.context.completeWith(this.password.value)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const passwordHash = this.target!['embassy-os']?.['password-hash'] || ''
|
||||
|
||||
argon2.verify(passwordHash, this.password.value)
|
||||
this.context.completeWith(this.password.value)
|
||||
} catch (e) {
|
||||
this.errorService.handleError('Incorrect password provided')
|
||||
}
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.context.$implicit.complete()
|
||||
}
|
||||
}
|
||||
|
||||
export const PASSWORD = new PolymorpheusComponent(PasswordComponent)
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import {
|
||||
TuiCellModule,
|
||||
TuiIconModule,
|
||||
TuiTitleModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-recover',
|
||||
template: `
|
||||
<a tuiCell [routerLink]="disabled ? null : '/attach'">
|
||||
<tui-icon icon="tuiIconBoxLarge" />
|
||||
<span tuiTitle>
|
||||
<span class="g-success">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="tuiIconShareLarge" />
|
||||
<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="tuiIconSave" />
|
||||
<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: [RouterLink, TuiIconModule, TuiCellModule, TuiTitleModule],
|
||||
})
|
||||
export class RecoverComponent {
|
||||
@Input() disabled = false
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { TuiButtonModule, TuiErrorModule } from '@taiga-ui/core'
|
||||
import {
|
||||
TuiFieldErrorPipeModule,
|
||||
TuiInputModule,
|
||||
TuiInputPasswordModule,
|
||||
} from '@taiga-ui/kit'
|
||||
import { CifsModal } from './cifs-modal.page'
|
||||
|
||||
@NgModule({
|
||||
declarations: [CifsModal],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
TuiButtonModule,
|
||||
TuiInputModule,
|
||||
TuiErrorModule,
|
||||
ReactiveFormsModule,
|
||||
TuiFieldErrorPipeModule,
|
||||
TuiInputPasswordModule,
|
||||
],
|
||||
exports: [CifsModal],
|
||||
})
|
||||
export class CifsModalModule {}
|
||||
@@ -1,39 +0,0 @@
|
||||
<form [formGroup]="form" (ngSubmit)="submit()">
|
||||
<tui-input formControlName="hostname">
|
||||
Hostname
|
||||
<input tuiTextfield placeholder="'My Computer' OR 'my-computer.local'" />
|
||||
</tui-input>
|
||||
<tui-error
|
||||
formControlName="hostname"
|
||||
[error]="['required'] | tuiFieldError | async"
|
||||
></tui-error>
|
||||
|
||||
<tui-input formControlName="path" class="input">
|
||||
Path
|
||||
<input tuiTextfield placeholder="/Desktop/my-folder'" />
|
||||
</tui-input>
|
||||
<tui-error
|
||||
formControlName="path"
|
||||
[error]="[] | tuiFieldError | async"
|
||||
></tui-error>
|
||||
|
||||
<tui-input formControlName="username" class="input">
|
||||
Username
|
||||
<input tuiTextfield placeholder="Enter username" />
|
||||
</tui-input>
|
||||
<tui-error
|
||||
formControlName="username"
|
||||
[error]="[] | tuiFieldError | async"
|
||||
></tui-error>
|
||||
|
||||
<tui-input-password formControlName="password" class="input">
|
||||
Password
|
||||
</tui-input-password>
|
||||
|
||||
<footer class="modal-buttons">
|
||||
<button tuiButton appearance="secondary" type="button" (click)="cancel()">
|
||||
Cancel
|
||||
</button>
|
||||
<button tuiButton [disabled]="form.invalid">Verify</button>
|
||||
</footer>
|
||||
</form>
|
||||
@@ -1,3 +0,0 @@
|
||||
.input {
|
||||
margin-top: 16px;
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
import { Component, Inject } from '@angular/core'
|
||||
import { FormControl, FormGroup, Validators } from '@angular/forms'
|
||||
import { TUI_VALIDATION_ERRORS } from '@taiga-ui/kit'
|
||||
import { LoadingService, StartOSDiskInfo } from '@start9labs/shared'
|
||||
import { TuiDialogContext, TuiDialogService } from '@taiga-ui/core'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import {
|
||||
ApiService,
|
||||
CifsBackupTarget,
|
||||
CifsRecoverySource,
|
||||
} from 'src/app/services/api/api.service'
|
||||
import { PASSWORD } from '../password/password.page'
|
||||
|
||||
@Component({
|
||||
selector: 'cifs-modal',
|
||||
templateUrl: 'cifs-modal.page.html',
|
||||
styleUrls: ['cifs-modal.page.scss'],
|
||||
providers: [
|
||||
{
|
||||
provide: TUI_VALIDATION_ERRORS,
|
||||
useValue: {
|
||||
required: 'This field is required',
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
export class CifsModal {
|
||||
readonly form = new FormGroup({
|
||||
hostname: new FormControl('', {
|
||||
validators: [
|
||||
Validators.required,
|
||||
Validators.pattern(/^[a-zA-Z0-9._-]+( [a-zA-Z0-9]+)*$/),
|
||||
],
|
||||
nonNullable: true,
|
||||
}),
|
||||
path: new FormControl('', {
|
||||
validators: [Validators.required],
|
||||
nonNullable: true,
|
||||
}),
|
||||
username: new FormControl('', {
|
||||
validators: [Validators.required],
|
||||
nonNullable: true,
|
||||
}),
|
||||
password: new FormControl(),
|
||||
})
|
||||
|
||||
constructor(
|
||||
@Inject(POLYMORPHEUS_CONTEXT)
|
||||
private readonly context: TuiDialogContext<{
|
||||
cifs: CifsRecoverySource
|
||||
recoveryPassword: string
|
||||
}>,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly api: ApiService,
|
||||
private readonly loader: LoadingService,
|
||||
) {}
|
||||
|
||||
cancel() {
|
||||
this.context.$implicit.complete()
|
||||
}
|
||||
|
||||
async submit(): Promise<void> {
|
||||
const loader = this.loader
|
||||
.open('Connecting to shared folder...')
|
||||
.subscribe()
|
||||
|
||||
try {
|
||||
const diskInfo = await this.api.verifyCifs({
|
||||
...this.form.getRawValue(),
|
||||
type: 'cifs',
|
||||
password: this.form.value.password
|
||||
? await this.api.encrypt(String(this.form.value.password))
|
||||
: null,
|
||||
})
|
||||
|
||||
loader.unsubscribe()
|
||||
|
||||
this.presentModalPassword(diskInfo)
|
||||
} catch (e) {
|
||||
loader.unsubscribe()
|
||||
this.presentAlertFailed()
|
||||
}
|
||||
}
|
||||
|
||||
private presentModalPassword(diskInfo: StartOSDiskInfo) {
|
||||
const target: CifsBackupTarget = {
|
||||
...this.form.getRawValue(),
|
||||
mountable: true,
|
||||
'embassy-os': diskInfo,
|
||||
}
|
||||
|
||||
this.dialogs
|
||||
.open<string>(PASSWORD, {
|
||||
label: 'Unlock Drive',
|
||||
size: 's',
|
||||
data: { target },
|
||||
})
|
||||
.subscribe(recoveryPassword => {
|
||||
this.context.completeWith({
|
||||
cifs: { ...this.form.getRawValue(), type: 'cifs' },
|
||||
recoveryPassword,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private presentAlertFailed() {
|
||||
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.',
|
||||
{
|
||||
label: 'Connection Failed',
|
||||
size: 's',
|
||||
},
|
||||
)
|
||||
.subscribe()
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { TuiButtonModule, TuiErrorModule } from '@taiga-ui/core'
|
||||
import { TuiInputPasswordModule } from '@taiga-ui/kit'
|
||||
import { PasswordPage } from './password.page'
|
||||
|
||||
@NgModule({
|
||||
declarations: [PasswordPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
TuiButtonModule,
|
||||
TuiInputPasswordModule,
|
||||
TuiErrorModule,
|
||||
ReactiveFormsModule,
|
||||
],
|
||||
exports: [PasswordPage],
|
||||
})
|
||||
export class PasswordPageModule {}
|
||||
@@ -1,35 +0,0 @@
|
||||
<p *ngIf="!storageDrive else choose">
|
||||
Enter the password that was used to encrypt this drive.
|
||||
</p>
|
||||
<ng-template #choose>
|
||||
<p>
|
||||
Choose a password for your server.
|
||||
<i>Make it good. Write it down.</i>
|
||||
</p>
|
||||
</ng-template>
|
||||
|
||||
<form (ngSubmit)="storageDrive ? submitPw() : verifyPw()">
|
||||
<tui-input-password [formControl]="password">
|
||||
Enter Password
|
||||
<input tuiTextfield maxlength="64" />
|
||||
</tui-input-password>
|
||||
<tui-error [error]="passwordError"></tui-error>
|
||||
<ng-container *ngIf="storageDrive">
|
||||
<tui-input-password style="margin-top: 16px" [formControl]="confirm">
|
||||
Retype Password
|
||||
<input tuiTextfield maxlength="64" />
|
||||
</tui-input-password>
|
||||
<tui-error [error]="confirmError"></tui-error>
|
||||
</ng-container>
|
||||
<footer class="modal-buttons">
|
||||
<button tuiButton appearance="secondary" type="button" (click)="cancel()">
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
tuiButton
|
||||
[disabled]="!password.value || !!confirmError || !!passwordError"
|
||||
>
|
||||
{{ storageDrive ? 'Finish' : 'Unlock' }}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Component, Inject } from '@angular/core'
|
||||
import { FormControl } from '@angular/forms'
|
||||
import * as argon2 from '@start9labs/argon2'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
import {
|
||||
PolymorpheusComponent,
|
||||
POLYMORPHEUS_CONTEXT,
|
||||
} from '@tinkoff/ng-polymorpheus'
|
||||
import {
|
||||
CifsBackupTarget,
|
||||
DiskBackupTarget,
|
||||
} from 'src/app/services/api/api.service'
|
||||
|
||||
interface DialogData {
|
||||
target?: CifsBackupTarget | DiskBackupTarget
|
||||
storageDrive?: boolean
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-password',
|
||||
templateUrl: 'password.page.html',
|
||||
})
|
||||
export class PasswordPage {
|
||||
readonly target = this.context.data.target
|
||||
readonly storageDrive = this.context.data.storageDrive
|
||||
readonly password = new FormControl('', { nonNullable: true })
|
||||
readonly confirm = new FormControl('', { nonNullable: true })
|
||||
|
||||
constructor(
|
||||
@Inject(POLYMORPHEUS_CONTEXT)
|
||||
private readonly context: TuiDialogContext<string, DialogData>,
|
||||
private readonly errorService: ErrorService,
|
||||
) {}
|
||||
|
||||
get passwordError(): string | null {
|
||||
if (!this.password.touched || this.target) return null
|
||||
|
||||
if (!this.storageDrive && !this.target?.['embassy-os'])
|
||||
return 'No recovery target' // unreachable
|
||||
|
||||
if (this.password.value.length < 12)
|
||||
return 'Must be 12 characters or greater'
|
||||
|
||||
if (this.password.value.length > 64)
|
||||
return 'Must be less than 65 characters'
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
get confirmError(): string | null {
|
||||
return this.confirm.touched && this.password.value !== this.confirm.value
|
||||
? 'Passwords do not match'
|
||||
: null
|
||||
}
|
||||
|
||||
verifyPw() {
|
||||
try {
|
||||
const passwordHash = this.target!['embassy-os']?.['password-hash'] || ''
|
||||
|
||||
argon2.verify(passwordHash, this.password.value)
|
||||
this.context.completeWith(this.password.value)
|
||||
} catch (e) {
|
||||
this.errorService.handleError('Incorrect password provided')
|
||||
}
|
||||
}
|
||||
|
||||
submitPw() {
|
||||
this.context.completeWith(this.password.value)
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.context.$implicit.complete()
|
||||
}
|
||||
}
|
||||
|
||||
export const PASSWORD = new PolymorpheusComponent(PasswordPage)
|
||||
108
web/projects/setup-wizard/src/app/pages/attach.page.ts
Normal file
108
web/projects/setup-wizard/src/app/pages/attach.page.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import {
|
||||
DiskInfo,
|
||||
DriveComponent,
|
||||
ErrorService,
|
||||
LoadingService,
|
||||
toGuid,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiDialogService, TuiLoaderModule } from '@taiga-ui/core'
|
||||
import {
|
||||
TuiButtonModule,
|
||||
TuiCardModule,
|
||||
TuiCellModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
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({
|
||||
standalone: true,
|
||||
template: `
|
||||
<section tuiCardLarge>
|
||||
<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.
|
||||
}
|
||||
<button tuiButton iconLeft="tuiIconRotateCwLarge" (click)="refresh()">
|
||||
Refresh
|
||||
</button>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
imports: [
|
||||
TuiButtonModule,
|
||||
TuiCardModule,
|
||||
TuiCellModule,
|
||||
TuiLoaderModule,
|
||||
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...').subscribe()
|
||||
|
||||
try {
|
||||
await this.stateService.importDrive(guid, password)
|
||||
await this.router.navigate([`loading`])
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { AttachPage } from './attach.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: AttachPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class AttachPageRoutingModule {}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import {
|
||||
GuidPipePipesModule,
|
||||
UnitConversionPipesModule,
|
||||
} from '@start9labs/shared'
|
||||
import { AttachPage } from './attach.page'
|
||||
import { AttachPageRoutingModule } from './attach-routing.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [AttachPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
AttachPageRoutingModule,
|
||||
UnitConversionPipesModule,
|
||||
GuidPipePipesModule,
|
||||
],
|
||||
})
|
||||
export class AttachPageModule {}
|
||||
@@ -1,67 +0,0 @@
|
||||
<ion-content>
|
||||
<ion-grid>
|
||||
<ion-row class="ion-align-items-center">
|
||||
<ion-col>
|
||||
<ion-card color="dark">
|
||||
<ion-card-header class="ion-text-center">
|
||||
<ion-card-title>Use existing drive</ion-card-title>
|
||||
<div class="center-wrapper">
|
||||
<ion-card-subtitle>
|
||||
Select the physical drive containing your StartOS data
|
||||
</ion-card-subtitle>
|
||||
</div>
|
||||
</ion-card-header>
|
||||
|
||||
<ion-card-content class="ion-margin">
|
||||
<ion-spinner
|
||||
*ngIf="loading"
|
||||
class="center-spinner"
|
||||
name="lines"
|
||||
></ion-spinner>
|
||||
|
||||
<!-- loaded -->
|
||||
<ion-item-group *ngIf="!loading" class="ion-text-center">
|
||||
<!-- drives -->
|
||||
<p *ngIf="!drives.length">
|
||||
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.
|
||||
</p>
|
||||
|
||||
<ng-container *ngFor="let drive of drives">
|
||||
<ion-item
|
||||
*ngIf="drive | guid as guid"
|
||||
button
|
||||
(click)="select(guid)"
|
||||
lines="none"
|
||||
>
|
||||
<ion-icon
|
||||
slot="start"
|
||||
name="save-outline"
|
||||
size="large"
|
||||
></ion-icon>
|
||||
<ion-label>
|
||||
<h1>{{ drive.logicalname }}</h1>
|
||||
<p>
|
||||
{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model ||
|
||||
'Unknown Model' }}
|
||||
</p>
|
||||
<p>Capacity: {{ drive.capacity | convertBytes }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
<ion-button
|
||||
class="ion-margin-top"
|
||||
color="primary"
|
||||
(click)="refresh()"
|
||||
>
|
||||
<ion-icon slot="start" name="refresh"></ion-icon>
|
||||
Refresh
|
||||
</ion-button>
|
||||
</ion-item-group>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -1,71 +0,0 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { DiskInfo, ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { PASSWORD, PasswordPage } from 'src/app/modals/password/password.page'
|
||||
|
||||
@Component({
|
||||
selector: 'app-attach',
|
||||
templateUrl: 'attach.page.html',
|
||||
styleUrls: ['attach.page.scss'],
|
||||
})
|
||||
export class AttachPage {
|
||||
loading = true
|
||||
drives: DiskInfo[] = []
|
||||
|
||||
constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly stateService: StateService,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly loader: LoadingService,
|
||||
) {}
|
||||
|
||||
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()
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
select(guid: string) {
|
||||
this.dialogs
|
||||
.open<string>(PASSWORD, {
|
||||
label: 'Set Password',
|
||||
size: 's',
|
||||
data: { storageDrive: true },
|
||||
})
|
||||
.subscribe(password => {
|
||||
this.attachDrive(guid, password)
|
||||
})
|
||||
}
|
||||
|
||||
private async attachDrive(guid: string, password: string) {
|
||||
const loader = this.loader.open('Connecting to drive...').subscribe()
|
||||
|
||||
try {
|
||||
await this.stateService.importDrive(guid, password)
|
||||
await this.navCtrl.navigateForward(`/loading`)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { EmbassyPage } from './embassy.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: EmbassyPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class EmbassyPageRoutingModule { }
|
||||
@@ -1,25 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import {
|
||||
GuidPipePipesModule,
|
||||
UnitConversionPipesModule,
|
||||
} from '@start9labs/shared'
|
||||
import { EmbassyPage } from './embassy.page'
|
||||
import { PasswordPageModule } from '../../modals/password/password.module'
|
||||
import { EmbassyPageRoutingModule } from './embassy-routing.module'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
EmbassyPageRoutingModule,
|
||||
PasswordPageModule,
|
||||
UnitConversionPipesModule,
|
||||
GuidPipePipesModule,
|
||||
],
|
||||
declarations: [EmbassyPage],
|
||||
})
|
||||
export class EmbassyPageModule {}
|
||||
@@ -1,87 +0,0 @@
|
||||
<ion-content>
|
||||
<ion-grid>
|
||||
<ion-row class="ion-align-items-center">
|
||||
<ion-col class="ion-text-center">
|
||||
<ion-card color="dark">
|
||||
<ion-card-header
|
||||
class="ion-text-center"
|
||||
style="padding-bottom: 8px"
|
||||
*ngIf="loading || storageDrives.length; else empty"
|
||||
>
|
||||
<ion-card-title>Select storage drive</ion-card-title>
|
||||
<div class="center-wrapper">
|
||||
<ion-card-subtitle>
|
||||
This is the drive where your StartOS data will be stored.
|
||||
</ion-card-subtitle>
|
||||
</div>
|
||||
</ion-card-header>
|
||||
<ng-template #empty>
|
||||
<ion-card-header
|
||||
class="ion-text-center"
|
||||
style="padding-bottom: 8px"
|
||||
>
|
||||
<ion-card-title>No drives found</ion-card-title>
|
||||
<div class="center-wrapper">
|
||||
<ion-card-subtitle>
|
||||
Please connect a storage drive to your server. Then click
|
||||
"Refresh".
|
||||
</ion-card-subtitle>
|
||||
</div>
|
||||
</ion-card-header>
|
||||
</ng-template>
|
||||
|
||||
<ion-card-content class="ion-margin">
|
||||
<!-- loading -->
|
||||
<ion-spinner
|
||||
*ngIf="loading; else loaded"
|
||||
class="center-spinner"
|
||||
name="lines-sharp"
|
||||
></ion-spinner>
|
||||
|
||||
<!-- not loading -->
|
||||
<ng-template #loaded>
|
||||
<ion-item-group
|
||||
*ngIf="storageDrives.length"
|
||||
class="ion-padding-bottom"
|
||||
>
|
||||
<ion-item
|
||||
(click)="chooseDrive(drive)"
|
||||
class="ion-margin-bottom"
|
||||
[disabled]="tooSmall(drive)"
|
||||
button
|
||||
lines="none"
|
||||
*ngFor="let drive of storageDrives"
|
||||
>
|
||||
<ion-icon
|
||||
slot="start"
|
||||
name="save-outline"
|
||||
size="large"
|
||||
></ion-icon>
|
||||
<ion-label class="ion-text-wrap">
|
||||
<h1>
|
||||
{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model ||
|
||||
'Unknown Model' }}
|
||||
</h1>
|
||||
<h2>
|
||||
{{ drive.logicalname }} - {{ drive.capacity | convertBytes
|
||||
}}
|
||||
</h2>
|
||||
<p *ngIf="tooSmall(drive)">
|
||||
<ion-text color="danger">
|
||||
Drive capacity too small.
|
||||
</ion-text>
|
||||
</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
<ion-button fill="solid" color="primary" (click)="getDrives()">
|
||||
<ion-icon slot="start" name="refresh"></ion-icon>
|
||||
Refresh
|
||||
</ion-button>
|
||||
</ng-template>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
144
web/projects/setup-wizard/src/app/pages/home.page.ts
Normal file
144
web/projects/setup-wizard/src/app/pages/home.page.ts
Normal file
@@ -0,0 +1,144 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Component, inject, OnInit } from '@angular/core'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import {
|
||||
TuiButtonModule,
|
||||
TuiCardModule,
|
||||
TuiCellModule,
|
||||
TuiIconModule,
|
||||
TuiIconsModule,
|
||||
TuiTitleModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import { RecoverComponent } from 'src/app/components/recover.component'
|
||||
import { ApiService } from 'src/app/services/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<img class="logo" src="assets/img/icon.png" alt="Start9" />
|
||||
@if (!loading) {
|
||||
<section tuiCardLarge>
|
||||
<header [style.padding-top.rem]="1.25">
|
||||
@if (recover) {
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="flat"
|
||||
class="back"
|
||||
iconLeft="tuiIconChevronLeft"
|
||||
(click)="recover = false"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
}
|
||||
{{ recover ? 'StartOS Setup' : 'Recover Options' }}
|
||||
</header>
|
||||
<div class="pages">
|
||||
<div class="options" [class.options_recover]="recover">
|
||||
<a tuiCell [routerLink]="error || recover ? null : '/storage'">
|
||||
<tui-icon icon="tuiIconPlus" />
|
||||
<span tuiTitle>
|
||||
<span class="g-success">Start Fresh</span>
|
||||
<span tuiSubtitle>
|
||||
Get started with a brand new Start9 server
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
<button
|
||||
tuiCell
|
||||
[disabled]="error || recover"
|
||||
(click)="recover = true"
|
||||
>
|
||||
<tui-icon icon="tuiIconRotateCw" />
|
||||
<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>
|
||||
</section>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
.logo {
|
||||
width: 6rem;
|
||||
margin: auto auto -2rem;
|
||||
z-index: 1;
|
||||
|
||||
&: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 transition(margin);
|
||||
|
||||
min-width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
padding: 1rem;
|
||||
box-sizing: border-box;
|
||||
|
||||
&_recover {
|
||||
margin-left: -100%;
|
||||
}
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterLink,
|
||||
TuiCardModule,
|
||||
TuiButtonModule,
|
||||
TuiIconsModule,
|
||||
TuiCellModule,
|
||||
TuiIconModule,
|
||||
TuiTitleModule,
|
||||
RecoverComponent,
|
||||
],
|
||||
})
|
||||
export default class HomePage implements OnInit {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly stateService = inject(StateService)
|
||||
|
||||
error = false
|
||||
loading = true
|
||||
recover = false
|
||||
|
||||
async ngOnInit() {
|
||||
this.stateService.setupType = 'fresh'
|
||||
|
||||
try {
|
||||
await this.api.getPubKey()
|
||||
} catch (e: any) {
|
||||
this.error = true
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { HomePage } from './home.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: HomePage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class HomePageRoutingModule { }
|
||||
@@ -1,21 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { HomePage } from './home.page'
|
||||
import { PasswordPageModule } from '../../modals/password/password.module'
|
||||
import { HomePageRoutingModule } from './home-routing.module'
|
||||
import { SwiperModule } from 'swiper/angular'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
HomePageRoutingModule,
|
||||
PasswordPageModule,
|
||||
SwiperModule,
|
||||
],
|
||||
declarations: [HomePage],
|
||||
})
|
||||
export class HomePageModule {}
|
||||
@@ -1,129 +0,0 @@
|
||||
<ion-content>
|
||||
<ion-grid *ngIf="!loading">
|
||||
<ion-row class="ion-align-items-center">
|
||||
<ion-col class="ion-text-center">
|
||||
<div style="padding-bottom: 32px">
|
||||
<img
|
||||
src="assets/img/icon.png"
|
||||
class="pb-1"
|
||||
style="max-width: 100px"
|
||||
/>
|
||||
</div>
|
||||
<ion-card color="dark">
|
||||
<ion-card-header style="padding-bottom: 0">
|
||||
<ion-button
|
||||
*ngIf="swiper?.activeIndex === 1"
|
||||
class="back-button"
|
||||
fill="clear"
|
||||
color="light"
|
||||
(click)="previous()"
|
||||
>
|
||||
<ion-icon slot="icon-only" name="arrow-back"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-card-title>
|
||||
{{ swiper?.activeIndex === 0 ? 'StartOS Setup' : 'Recover Options'
|
||||
}}
|
||||
</ion-card-title>
|
||||
</ion-card-header>
|
||||
<ion-card-content class="ion-margin-bottom">
|
||||
<swiper
|
||||
[autoHeight]="true"
|
||||
[observeParents]="true"
|
||||
(swiper)="setSwiperInstance($event)"
|
||||
>
|
||||
<!-- SLIDE 1 -->
|
||||
<ng-template swiperSlide>
|
||||
<!-- fresh -->
|
||||
<ion-item
|
||||
button
|
||||
[disabled]="error"
|
||||
detail="false"
|
||||
lines="none"
|
||||
routerLink="/storage"
|
||||
>
|
||||
<ion-icon slot="start" name="add"></ion-icon>
|
||||
<ion-label>
|
||||
<h2><ion-text color="success">Start Fresh</ion-text></h2>
|
||||
<p>Get started with a brand new Start9 server</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- recover -->
|
||||
<ion-item
|
||||
button
|
||||
[disabled]="error"
|
||||
detail="false"
|
||||
lines="none"
|
||||
(click)="next()"
|
||||
>
|
||||
<ion-icon slot="start" name="reload"></ion-icon>
|
||||
<ion-label>
|
||||
<h2><ion-text color="danger">Recover</ion-text></h2>
|
||||
<p>Recover, restore, or transfer StartOS data</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-template>
|
||||
|
||||
<!-- SLIDE 2 -->
|
||||
<ng-template swiperSlide>
|
||||
<!-- attach -->
|
||||
<ion-item
|
||||
button
|
||||
detail="false"
|
||||
lines="none"
|
||||
routerLink="/attach"
|
||||
>
|
||||
<ion-icon slot="start" name="cube-outline"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>
|
||||
<ion-text color="success">Use Existing Drive</ion-text>
|
||||
</h2>
|
||||
<p>Attach an existing StartOS data drive (not a backup)</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- transfer -->
|
||||
<ion-item
|
||||
button
|
||||
detail="false"
|
||||
lines="none"
|
||||
routerLink="/transfer"
|
||||
>
|
||||
<ion-icon slot="start" name="share-outline"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>
|
||||
<ion-text color="primary">Transfer</ion-text>
|
||||
</h2>
|
||||
<p>
|
||||
Transfer data from an existing StartOS data drive (not a
|
||||
backup) to a new, preferred drive
|
||||
<br />
|
||||
</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- restore from backup -->
|
||||
<ion-item
|
||||
button
|
||||
lines="none"
|
||||
detail="false"
|
||||
routerLink="/recover"
|
||||
>
|
||||
<ion-icon slot="start" name="save-outline"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>
|
||||
<ion-text color="warning">
|
||||
Restore From Backup (Disaster Recovery)
|
||||
</ion-text>
|
||||
</h2>
|
||||
<p>Restore StartOS data from an encrypted backup</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-template>
|
||||
</swiper>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -1,13 +0,0 @@
|
||||
.back-button {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
top: 24px;
|
||||
z-index: 1000000;
|
||||
}
|
||||
|
||||
.inline {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { IonicSlides } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import SwiperCore, { Swiper } from 'swiper'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
SwiperCore.use([IonicSlides])
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
templateUrl: 'home.page.html',
|
||||
styleUrls: ['home.page.scss'],
|
||||
})
|
||||
export class HomePage {
|
||||
swiper?: Swiper
|
||||
error = false
|
||||
loading = true
|
||||
|
||||
constructor(
|
||||
private readonly api: ApiService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly stateService: StateService,
|
||||
) {}
|
||||
|
||||
async ionViewDidEnter() {
|
||||
this.stateService.setupType = 'fresh'
|
||||
if (this.swiper) {
|
||||
this.swiper.allowTouchMove = false
|
||||
}
|
||||
|
||||
try {
|
||||
await this.api.getPubKey()
|
||||
} catch (e: any) {
|
||||
this.error = true
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
setSwiperInstance(swiper: any) {
|
||||
this.swiper = swiper
|
||||
}
|
||||
|
||||
next() {
|
||||
this.swiper?.slideNext(500)
|
||||
}
|
||||
|
||||
previous() {
|
||||
this.swiper?.slidePrev(500)
|
||||
}
|
||||
}
|
||||
20
web/projects/setup-wizard/src/app/pages/loading.page.ts
Normal file
20
web/projects/setup-wizard/src/app/pages/loading.page.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { InitializingComponent } from '@start9labs/shared'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<app-initializing
|
||||
[setupType]="stateService.setupType"
|
||||
(finished)="router.navigate(['success'])"
|
||||
/>
|
||||
`,
|
||||
imports: [InitializingComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export default class LoadingPage {
|
||||
readonly stateService = inject(StateService)
|
||||
readonly router = inject(Router)
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { InitializingModule } from '@start9labs/shared'
|
||||
import { LoadingPage } from './loading.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: LoadingPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [InitializingModule, RouterModule.forChild(routes)],
|
||||
declarations: [LoadingPage],
|
||||
})
|
||||
export class LoadingPageModule {}
|
||||
@@ -1,5 +0,0 @@
|
||||
<app-initializing
|
||||
class="ion-page"
|
||||
[setupType]="stateService.setupType"
|
||||
(finished)="navCtrl.navigateForward('/success')"
|
||||
></app-initializing>
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
templateUrl: 'loading.page.html',
|
||||
})
|
||||
export class LoadingPage {
|
||||
constructor(
|
||||
readonly stateService: StateService,
|
||||
readonly navCtrl: NavController,
|
||||
) {}
|
||||
}
|
||||
163
web/projects/setup-wizard/src/app/pages/recover.page.ts
Normal file
163
web/projects/setup-wizard/src/app/pages/recover.page.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { DriveComponent, ErrorService } from '@start9labs/shared'
|
||||
import { TuiDialogService, TuiLoaderModule } from '@taiga-ui/core'
|
||||
import {
|
||||
TuiButtonModule,
|
||||
TuiCardModule,
|
||||
TuiCellModule,
|
||||
TuiIconModule,
|
||||
TuiTitleModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { filter } from 'rxjs'
|
||||
import { CifsComponent } from 'src/app/components/cifs.component'
|
||||
import { PASSWORD } from 'src/app/components/password.component'
|
||||
import {
|
||||
ApiService,
|
||||
CifsRecoverySource,
|
||||
DiskBackupTarget,
|
||||
} from 'src/app/services/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<section tuiCardLarge>
|
||||
<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 (click)="onCifs()">
|
||||
<tui-icon icon="tuiIconFolder" />
|
||||
<span tuiTitle>Open</span>
|
||||
</button>
|
||||
|
||||
<h2>Physical Drive</h2>
|
||||
Restore StartOS data from a physical drive that is plugged directly into
|
||||
your server.
|
||||
<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 (d of drives; track d) {
|
||||
<button tuiCell [drive]="d" [disabled]="empty(d)" (click)="select(d)">
|
||||
<span tuiSubtitle>
|
||||
@if (empty(d)) {
|
||||
<tui-icon icon="tuiIconCloudOff" class="g-error" />
|
||||
<strong>No StartOS backup</strong>
|
||||
} @else {
|
||||
<tui-icon icon="tuiIconCloud" class="g-success" />
|
||||
<strong>StartOS backup detected</strong>
|
||||
}
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<button tuiButton iconLeft="tuiIconRotateCwLarge" (click)="refresh()">
|
||||
Refresh
|
||||
</button>
|
||||
}
|
||||
</section>
|
||||
`,
|
||||
imports: [
|
||||
TuiCardModule,
|
||||
TuiLoaderModule,
|
||||
TuiButtonModule,
|
||||
TuiCellModule,
|
||||
TuiIconModule,
|
||||
TuiTitleModule,
|
||||
DriveComponent,
|
||||
],
|
||||
})
|
||||
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
|
||||
drives: DiskBackupTarget[] = []
|
||||
|
||||
async ngOnInit() {
|
||||
this.stateService.setupType = 'restore'
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.loading = true
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
empty(drive: DiskBackupTarget) {
|
||||
return !drive['embassy-os']?.full
|
||||
}
|
||||
|
||||
async getDrives() {
|
||||
this.drives = []
|
||||
try {
|
||||
await this.api.getDrives().then(disks =>
|
||||
disks
|
||||
.filter(d => d.partitions.length)
|
||||
.forEach(d => {
|
||||
d.partitions.forEach(p => {
|
||||
this.drives.push({ ...d, ...p })
|
||||
})
|
||||
}),
|
||||
)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
select(target: DiskBackupTarget) {
|
||||
const { logicalname } = target
|
||||
|
||||
if (!logicalname) return
|
||||
|
||||
this.dialogs
|
||||
.open<string>(PASSWORD, {
|
||||
label: 'Unlock Drive',
|
||||
size: 's',
|
||||
data: { target },
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(password => {
|
||||
this.onSource(logicalname, password)
|
||||
})
|
||||
}
|
||||
|
||||
onCifs() {
|
||||
this.dialogs
|
||||
.open<{
|
||||
cifs: CifsRecoverySource
|
||||
recoveryPassword: string
|
||||
}>(new PolymorpheusComponent(CifsComponent), {
|
||||
label: 'Connect Network Folder',
|
||||
})
|
||||
.subscribe(({ cifs, recoveryPassword }) => {
|
||||
this.stateService.recoverySource = { type: 'backup', target: cifs }
|
||||
this.stateService.recoveryPassword = recoveryPassword
|
||||
this.router.navigate(['storage'])
|
||||
})
|
||||
}
|
||||
|
||||
private onSource(logicalname: string, password?: string) {
|
||||
this.stateService.recoverySource = {
|
||||
type: 'backup',
|
||||
target: { type: 'disk', logicalname },
|
||||
}
|
||||
this.stateService.recoveryPassword = password
|
||||
this.router.navigate(['storage'])
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
<div class="inline">
|
||||
<!-- has backup -->
|
||||
<h2 *ngIf="hasValidBackup; else noBackup">
|
||||
<ion-icon name="cloud-done" color="success"></ion-icon>
|
||||
StartOS backup detected
|
||||
</h2>
|
||||
<!-- no backup -->
|
||||
<ng-template #noBackup>
|
||||
<h2>
|
||||
<ion-icon name="cloud-offline" color="danger"></ion-icon>
|
||||
No StartOS backup
|
||||
</h2>
|
||||
</ng-template>
|
||||
</div>
|
||||
@@ -1,16 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { RecoverPage } from './recover.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: RecoverPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class RecoverPageRoutingModule { }
|
||||
@@ -1,23 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { UnitConversionPipesModule } from '@start9labs/shared'
|
||||
import { DriveStatusComponent, RecoverPage } from './recover.page'
|
||||
import { PasswordPageModule } from '../../modals/password/password.module'
|
||||
import { RecoverPageRoutingModule } from './recover-routing.module'
|
||||
import { CifsModalModule } from 'src/app/modals/cifs-modal/cifs-modal.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [RecoverPage, DriveStatusComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
RecoverPageRoutingModule,
|
||||
PasswordPageModule,
|
||||
UnitConversionPipesModule,
|
||||
CifsModalModule,
|
||||
],
|
||||
})
|
||||
export class RecoverPageModule {}
|
||||
@@ -1,97 +0,0 @@
|
||||
<ion-content>
|
||||
<ion-grid>
|
||||
<ion-row class="ion-align-items-center">
|
||||
<ion-col>
|
||||
<ion-card color="dark">
|
||||
<ion-card-header class="ion-text-center">
|
||||
<ion-card-title>Restore from Backup</ion-card-title>
|
||||
</ion-card-header>
|
||||
|
||||
<ion-card-content class="ion-margin">
|
||||
<ion-spinner
|
||||
*ngIf="loading"
|
||||
class="center-spinner"
|
||||
name="lines"
|
||||
></ion-spinner>
|
||||
|
||||
<!-- loaded -->
|
||||
<ion-item-group *ngIf="!loading" class="ion-text-center">
|
||||
<!-- cifs -->
|
||||
<h2 class="target-label">Network Folder</h2>
|
||||
<p class="ion-padding-bottom ion-text-left">
|
||||
Restore StartOS data from a folder on another computer that is
|
||||
connected to the same network as your server.
|
||||
</p>
|
||||
|
||||
<!-- connect -->
|
||||
<ion-item button lines="none" (click)="presentModalCifs()">
|
||||
<ion-icon
|
||||
slot="start"
|
||||
name="folder-open-outline"
|
||||
size="large"
|
||||
></ion-icon>
|
||||
<ion-label>
|
||||
<b>Open</b>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<br />
|
||||
<br />
|
||||
|
||||
<!-- drives -->
|
||||
<h2 class="target-label">Physical Drive</h2>
|
||||
<div class="ion-text-left ion-padding-bottom">
|
||||
<p>
|
||||
Restore StartOS data from a physical drive that is plugged
|
||||
directly into your server.
|
||||
</p>
|
||||
<br />
|
||||
<b>
|
||||
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.
|
||||
</b>
|
||||
</div>
|
||||
|
||||
<ng-container *ngFor="let mapped of mappedDrives">
|
||||
<ion-item
|
||||
button
|
||||
*ngIf="mapped.drive as drive"
|
||||
[disabled]="!driveClickable(mapped)"
|
||||
(click)="select(drive)"
|
||||
lines="none"
|
||||
>
|
||||
<ion-icon
|
||||
slot="start"
|
||||
name="save-outline"
|
||||
size="large"
|
||||
></ion-icon>
|
||||
<ion-label>
|
||||
<h1>{{ drive.label || drive.logicalname }}</h1>
|
||||
<drive-status
|
||||
[hasValidBackup]="mapped.hasValidBackup"
|
||||
></drive-status>
|
||||
<p>
|
||||
{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model ||
|
||||
'Unknown Model' }}
|
||||
</p>
|
||||
<p>Capacity: {{ drive.capacity | convertBytes }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
<ion-button
|
||||
class="ion-margin-top"
|
||||
color="primary"
|
||||
(click)="refresh()"
|
||||
>
|
||||
<ion-icon slot="start" name="refresh"></ion-icon>
|
||||
Refresh
|
||||
</ion-button>
|
||||
</ion-item-group>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -1,5 +0,0 @@
|
||||
.target-label {
|
||||
font-weight: 500;
|
||||
padding-bottom: 6px;
|
||||
font-variant-caps: all-small-caps;
|
||||
}
|
||||
@@ -1,137 +0,0 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { CifsModal } from 'src/app/modals/cifs-modal/cifs-modal.page'
|
||||
import {
|
||||
ApiService,
|
||||
CifsRecoverySource,
|
||||
DiskBackupTarget,
|
||||
} from 'src/app/services/api/api.service'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { PASSWORD } from '../../modals/password/password.page'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { filter } from 'rxjs'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
|
||||
@Component({
|
||||
selector: 'app-recover',
|
||||
templateUrl: 'recover.page.html',
|
||||
styleUrls: ['recover.page.scss'],
|
||||
})
|
||||
export class RecoverPage {
|
||||
loading = true
|
||||
mappedDrives: MappedDisk[] = []
|
||||
|
||||
constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly stateService: StateService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.stateService.setupType = 'restore'
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.loading = true
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
driveClickable(mapped: MappedDisk) {
|
||||
return mapped.drive['embassy-os']?.full
|
||||
}
|
||||
|
||||
async getDrives() {
|
||||
this.mappedDrives = []
|
||||
try {
|
||||
const disks = await this.apiService.getDrives()
|
||||
disks
|
||||
.filter(d => d.partitions.length)
|
||||
.forEach(d => {
|
||||
d.partitions.forEach(p => {
|
||||
const drive: DiskBackupTarget = {
|
||||
vendor: d.vendor,
|
||||
model: d.model,
|
||||
logicalname: p.logicalname,
|
||||
label: p.label,
|
||||
capacity: p.capacity,
|
||||
used: p.used,
|
||||
'embassy-os': p['embassy-os'],
|
||||
}
|
||||
this.mappedDrives.push({
|
||||
hasValidBackup: !!p['embassy-os']?.full,
|
||||
drive,
|
||||
})
|
||||
})
|
||||
})
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
presentModalCifs() {
|
||||
this.dialogs
|
||||
.open<{ cifs: CifsRecoverySource; recoveryPassword: string }>(
|
||||
new PolymorpheusComponent(CifsModal),
|
||||
{
|
||||
label: 'Connect Network Folder',
|
||||
},
|
||||
)
|
||||
.subscribe(({ cifs, recoveryPassword }) => {
|
||||
this.stateService.recoverySource = {
|
||||
type: 'backup',
|
||||
target: cifs,
|
||||
}
|
||||
this.stateService.recoveryPassword = recoveryPassword
|
||||
this.navCtrl.navigateForward('/storage')
|
||||
})
|
||||
}
|
||||
|
||||
async select(target: DiskBackupTarget) {
|
||||
const { logicalname } = target
|
||||
|
||||
if (!logicalname) return
|
||||
|
||||
this.dialogs
|
||||
.open<string>(PASSWORD, {
|
||||
label: 'Unlock Drive',
|
||||
size: 's',
|
||||
data: { target },
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(password => {
|
||||
this.selectRecoverySource(logicalname, password)
|
||||
})
|
||||
}
|
||||
|
||||
private async selectRecoverySource(logicalname: string, password?: string) {
|
||||
this.stateService.recoverySource = {
|
||||
type: 'backup',
|
||||
target: {
|
||||
type: 'disk',
|
||||
logicalname,
|
||||
},
|
||||
}
|
||||
this.stateService.recoveryPassword = password
|
||||
this.navCtrl.navigateForward(`/storage`)
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'drive-status',
|
||||
templateUrl: './drive-status.component.html',
|
||||
styleUrls: ['./recover.page.scss'],
|
||||
})
|
||||
export class DriveStatusComponent {
|
||||
@Input({ required: true }) hasValidBackup!: boolean
|
||||
}
|
||||
|
||||
interface MappedDisk {
|
||||
hasValidBackup: boolean
|
||||
drive: DiskBackupTarget
|
||||
}
|
||||
@@ -1,49 +1,83 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import {
|
||||
DiskInfo,
|
||||
DriveComponent,
|
||||
ErrorService,
|
||||
GuidPipe,
|
||||
LoadingService,
|
||||
toGuid,
|
||||
} from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { TuiDialogService, TuiLoaderModule } from '@taiga-ui/core'
|
||||
import {
|
||||
TuiButtonModule,
|
||||
TuiCardModule,
|
||||
TuiCellModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { filter, of, switchMap } from 'rxjs'
|
||||
import { PASSWORD } from 'src/app/components/password.component'
|
||||
import {
|
||||
ApiService,
|
||||
BackupRecoverySource,
|
||||
DiskRecoverySource,
|
||||
DiskMigrateSource,
|
||||
} from 'src/app/services/api/api.service'
|
||||
DiskRecoverySource,
|
||||
} from 'src/app/services/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { PASSWORD, PasswordPage } from '../../modals/password/password.page'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { filter, of, switchMap } from 'rxjs'
|
||||
|
||||
@Component({
|
||||
selector: 'app-embassy',
|
||||
templateUrl: 'embassy.page.html',
|
||||
styleUrls: ['embassy.page.scss'],
|
||||
providers: [GuidPipe],
|
||||
})
|
||||
export class EmbassyPage {
|
||||
storageDrives: DiskInfo[] = []
|
||||
loading = true
|
||||
standalone: true,
|
||||
template: `
|
||||
<section tuiCardLarge>
|
||||
@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".
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly stateService: StateService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly guidPipe: GuidPipe,
|
||||
) {}
|
||||
@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-error">Drive capacity too small</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
|
||||
<button tuiButton iconLeft="tuiIconRotateCwLarge" (click)="getDrives()">
|
||||
Refresh
|
||||
</button>
|
||||
</section>
|
||||
`,
|
||||
imports: [
|
||||
TuiCardModule,
|
||||
TuiLoaderModule,
|
||||
TuiCellModule,
|
||||
TuiButtonModule,
|
||||
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()
|
||||
}
|
||||
|
||||
tooSmall(drive: DiskInfo) {
|
||||
return drive.capacity < 34359738368
|
||||
isSmall({ capacity }: DiskInfo) {
|
||||
return capacity < 34359738368
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
@@ -54,11 +88,11 @@ export class EmbassyPage {
|
||||
async getDrives() {
|
||||
this.loading = true
|
||||
try {
|
||||
const disks = await this.apiService.getDrives()
|
||||
const disks = await this.api.getDrives()
|
||||
if (this.stateService.setupType === 'fresh') {
|
||||
this.storageDrives = disks
|
||||
this.drives = disks
|
||||
} else if (this.stateService.setupType === 'restore') {
|
||||
this.storageDrives = disks.filter(
|
||||
this.drives = disks.filter(
|
||||
d =>
|
||||
!d.partitions
|
||||
.map(p => p.logicalname)
|
||||
@@ -72,7 +106,7 @@ export class EmbassyPage {
|
||||
} else if (this.stateService.setupType === 'transfer') {
|
||||
const guid = (this.stateService.recoverySource as DiskMigrateSource)
|
||||
.guid
|
||||
this.storageDrives = disks.filter(d => {
|
||||
this.drives = disks.filter(d => {
|
||||
return (
|
||||
d.guid !== guid && !d.partitions.map(p => p.guid).includes(guid)
|
||||
)
|
||||
@@ -85,8 +119,8 @@ export class EmbassyPage {
|
||||
}
|
||||
}
|
||||
|
||||
chooseDrive(drive: DiskInfo) {
|
||||
of(!this.guidPipe.transform(drive) && !drive.partitions.some(p => p.used))
|
||||
select(drive: DiskInfo) {
|
||||
of(!toGuid(drive) && !drive.partitions.some(p => p.used))
|
||||
.pipe(
|
||||
switchMap(unused =>
|
||||
unused
|
||||
@@ -138,7 +172,7 @@ export class EmbassyPage {
|
||||
|
||||
try {
|
||||
await this.stateService.setupEmbassy(logicalname, password)
|
||||
await this.navCtrl.navigateForward(`/loading`)
|
||||
await this.router.navigate([`loading`])
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
175
web/projects/setup-wizard/src/app/pages/success.page.ts
Normal file
175
web/projects/setup-wizard/src/app/pages/success.page.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
ElementRef,
|
||||
inject,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import { DownloadHTMLService, ErrorService } from '@start9labs/shared'
|
||||
import {
|
||||
TuiButtonModule,
|
||||
TuiCardModule,
|
||||
TuiIconModule,
|
||||
TuiSurfaceModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
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'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<canvas matrix></canvas>
|
||||
@if (isKiosk) {
|
||||
<section tuiCardLarge>
|
||||
<h1 class="heading">
|
||||
<tui-icon icon="tuiIconCheckSquare" class="g-success" />
|
||||
Setup Complete!
|
||||
</h1>
|
||||
<button tuiButton (click)="exitKiosk()" iconRight="tuiIconLogInLarge">
|
||||
Continue to Login
|
||||
</button>
|
||||
</section>
|
||||
} @else if (lanAddress) {
|
||||
<section tuiCardLarge>
|
||||
<h1 class="heading">
|
||||
<tui-icon icon="tuiIconCheckSquare" class="g-success" />
|
||||
Setup Complete!
|
||||
</h1>
|
||||
@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>
|
||||
}
|
||||
|
||||
<button tuiCardLarge tuiSurface="elevated" (click)="download()">
|
||||
<strong class="caps">Download address info</strong>
|
||||
<span>
|
||||
start.local was for setup purposes only. It will no longer work.
|
||||
</span>
|
||||
<strong class="caps">
|
||||
Download
|
||||
<tui-icon icon="tuiIconDownload" />
|
||||
</strong>
|
||||
</button>
|
||||
|
||||
<a
|
||||
tuiCardLarge
|
||||
tuiSurface="elevated"
|
||||
target="_blank"
|
||||
[attr.href]="disableLogin ? null : lanAddress"
|
||||
>
|
||||
<strong class="caps">Trust your Root CA</strong>
|
||||
<span>
|
||||
In the new tab, follow instructions to trust your server's Root CA
|
||||
and log in.
|
||||
</span>
|
||||
<strong class="caps">
|
||||
Open
|
||||
<tui-icon icon="tuiIconExternalLink" />
|
||||
</strong>
|
||||
</a>
|
||||
<app-documentation hidden [lanAddress]="lanAddress" />
|
||||
</section>
|
||||
}
|
||||
`,
|
||||
styles: `
|
||||
.heading {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
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-01);
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
a[tuiCardLarge]:not([href]) {
|
||||
opacity: var(--tui-disabled-opacity);
|
||||
pointer-events: none;
|
||||
}
|
||||
`,
|
||||
imports: [
|
||||
TuiCardModule,
|
||||
TuiIconModule,
|
||||
TuiButtonModule,
|
||||
TuiSurfaceModule,
|
||||
MatrixComponent,
|
||||
DocumentationComponent,
|
||||
],
|
||||
})
|
||||
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)
|
||||
|
||||
readonly stateService = inject(StateService)
|
||||
readonly isKiosk = ['localhost', '127.0.0.1'].includes(
|
||||
this.document.location.hostname,
|
||||
)
|
||||
|
||||
torAddress?: string
|
||||
lanAddress?: string
|
||||
cert?: string
|
||||
disableLogin = this.stateService.setupType === 'fresh'
|
||||
|
||||
ngAfterViewInit() {
|
||||
setTimeout(() => this.complete(), 1000)
|
||||
}
|
||||
|
||||
download() {
|
||||
const torAddress = this.document.getElementById('tor-addr')
|
||||
const lanAddress = this.document.getElementById('lan-addr')
|
||||
const html = this.documentation?.nativeElement.innerHTML || ''
|
||||
|
||||
if (torAddress) torAddress.innerHTML = this.torAddress!
|
||||
if (lanAddress) lanAddress.innerHTML = this.lanAddress!
|
||||
|
||||
this.document
|
||||
.getElementById('cert')
|
||||
?.setAttribute(
|
||||
'href',
|
||||
`data:application/x-x509-ca-cert;base64,${encodeURIComponent(this.cert!)}`,
|
||||
)
|
||||
this.downloadHtml.download('StartOS-info.html', html).then(_ => {
|
||||
this.disableLogin = false
|
||||
})
|
||||
}
|
||||
|
||||
exitKiosk() {
|
||||
this.api.exit()
|
||||
}
|
||||
|
||||
private async complete() {
|
||||
try {
|
||||
const ret = await this.api.complete()
|
||||
if (!this.isKiosk) {
|
||||
this.torAddress = ret['tor-address'].replace(/^https:/, 'http:')
|
||||
this.lanAddress = ret['lan-address'].replace(/^https:/, 'http:')
|
||||
this.cert = ret['root-ca']
|
||||
|
||||
await this.api.exit()
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>StartOS Address Info</title>
|
||||
</head>
|
||||
<body>
|
||||
<div
|
||||
style="
|
||||
font-family: Montserrat, sans-serif;
|
||||
color: #333333;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: auto;
|
||||
width: clamp(900px, 35vw, 600px);
|
||||
"
|
||||
>
|
||||
<h1
|
||||
style="
|
||||
font-variant-caps: all-small-caps;
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
"
|
||||
>
|
||||
StartOS Address Info
|
||||
</h1>
|
||||
|
||||
<section
|
||||
style="
|
||||
padding: 1rem 3rem 2rem 3rem;
|
||||
margin-bottom: 24px;
|
||||
border: solid #c4c4c5 3px;
|
||||
border-radius: 20px;
|
||||
"
|
||||
>
|
||||
<div>
|
||||
<h3 style="color: #f8546a; font-weight: bold">Important!</h3>
|
||||
<p>
|
||||
Download your server's Root CA and
|
||||
<a
|
||||
href="https://docs.start9.com/0.3.5.x/user-manual/connecting-lan"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style="color: #6866cc; font-weight: bold; text-decoration: none"
|
||||
>
|
||||
follow the instructions
|
||||
</a>
|
||||
to establish a secure connection with your server.
|
||||
</p>
|
||||
</div>
|
||||
<div style="text-align: center">
|
||||
<a
|
||||
id="cert"
|
||||
[download]="crtName"
|
||||
style="
|
||||
display: inline-block;
|
||||
padding: 1em 1.2em;
|
||||
box-sizing: border-box;
|
||||
font-size: 1rem;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
border-radius: clamp(2rem, 3rem, 4rem);
|
||||
cursor: pointer;
|
||||
box-shadow: rgba(0, 0, 0, 0.1) 0px 4px 6px -1px,
|
||||
rgba(0, 0, 0, 0.06) 0px 2px 4px -1px;
|
||||
background: #6866cc;
|
||||
color: #f4f4f5;
|
||||
"
|
||||
>
|
||||
Download certificate
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
style="
|
||||
padding: 1rem 3rem 2rem 3rem;
|
||||
border: solid #c4c4c5 3px;
|
||||
border-radius: 20px;
|
||||
margin-bottom: 24px;
|
||||
"
|
||||
>
|
||||
<h2 style="font-variant-caps: all-small-caps">
|
||||
Access from home (LAN)
|
||||
</h2>
|
||||
<p>
|
||||
Visit the address below when you are connected to the same WiFi or
|
||||
Local Area Network (LAN) as your server.
|
||||
</p>
|
||||
<p
|
||||
style="
|
||||
padding: 16px;
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
overflow: auto;
|
||||
"
|
||||
>
|
||||
<code id="lan-addr"></code>
|
||||
</p>
|
||||
|
||||
<h2 style="font-variant-caps: all-small-caps">
|
||||
Access on the go (Tor)
|
||||
</h2>
|
||||
<p>Visit the address below when you are away from home.</p>
|
||||
<p>
|
||||
<span style="font-weight: bold">Note:</span>
|
||||
This address will only work from a Tor-enabled browser.
|
||||
<a
|
||||
href="https://docs.start9.com/0.3.5.x/user-manual/connecting-tor"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
style="color: #6866cc; font-weight: bold; text-decoration: none"
|
||||
>
|
||||
Follow the instructions
|
||||
</a>
|
||||
to get setup.
|
||||
</p>
|
||||
<p
|
||||
style="
|
||||
padding: 16px;
|
||||
font-weight: bold;
|
||||
font-size: 1.1rem;
|
||||
overflow: auto;
|
||||
"
|
||||
>
|
||||
<code id="tor-addr"></code>
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,14 +0,0 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
|
||||
@Component({
|
||||
selector: 'download-doc',
|
||||
templateUrl: 'download-doc.component.html',
|
||||
})
|
||||
export class DownloadDocComponent {
|
||||
@Input({ required: true }) lanAddress!: string
|
||||
|
||||
get crtName(): string {
|
||||
const hostname = new URL(this.lanAddress).hostname
|
||||
return `${hostname}.crt`
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { SuccessPage } from './success.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: SuccessPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class SuccessPageRoutingModule {}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { ResponsiveColDirective } from '@start9labs/shared'
|
||||
|
||||
import { SuccessPage } from './success.page'
|
||||
import { PasswordPageModule } from '../../modals/password/password.module'
|
||||
import { SuccessPageRoutingModule } from './success-routing.module'
|
||||
import { DownloadDocComponent } from './download-doc/download-doc.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
PasswordPageModule,
|
||||
SuccessPageRoutingModule,
|
||||
ResponsiveColDirective,
|
||||
],
|
||||
declarations: [SuccessPage, DownloadDocComponent],
|
||||
exports: [SuccessPage],
|
||||
})
|
||||
export class SuccessPageModule {}
|
||||
@@ -1,102 +0,0 @@
|
||||
<canvas #canvas>Your browser does not support the canvas element.</canvas>
|
||||
<ion-content>
|
||||
<ion-grid>
|
||||
<ion-row class="ion-align-items-center">
|
||||
<ion-col>
|
||||
<!-- kiosk mode -->
|
||||
<ng-container *ngIf="isKiosk; else notKiosk">
|
||||
<ion-card>
|
||||
<ion-row class="ion-align-items-center">
|
||||
<ion-col responsiveCol sizeXs="12" class="ion-text-center">
|
||||
<div class="inline mb-12">
|
||||
<ion-icon
|
||||
name="checkmark-circle-outline"
|
||||
color="success"
|
||||
></ion-icon>
|
||||
<h1>Setup Complete!</h1>
|
||||
</div>
|
||||
<ion-button
|
||||
shape="round"
|
||||
class="login-button mb-12"
|
||||
(click)="exitKiosk()"
|
||||
>
|
||||
Continue to Login
|
||||
<ion-icon name="log-in-outline" slot="end"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-card>
|
||||
</ng-container>
|
||||
<!-- not kiosk -->
|
||||
<ng-template #notKiosk>
|
||||
<ion-card *ngIf="lanAddress">
|
||||
<ion-row class="ion-align-items-center">
|
||||
<ion-col responsiveCol sizeXs="12" class="ion-text-center">
|
||||
<div class="mb-12">
|
||||
<div class="inline-container setup">
|
||||
<ion-icon
|
||||
name="checkmark-circle-outline"
|
||||
color="success"
|
||||
></ion-icon>
|
||||
<h1>Setup Complete!</h1>
|
||||
</div>
|
||||
<h3 *ngIf="setupType === 'restore'">
|
||||
You can now safely unplug your backup drive
|
||||
</h3>
|
||||
<h3 *ngIf="setupType === 'transfer'">
|
||||
You can now safely unplug your old StartOS data drive
|
||||
</h3>
|
||||
</div>
|
||||
<div class="card-container">
|
||||
<ion-card id="information" (click)="download()">
|
||||
<ion-card-content>
|
||||
<ion-card-title>Download address info</ion-card-title>
|
||||
<p>
|
||||
start.local was for setup purposes only. It will no
|
||||
longer work.
|
||||
</p>
|
||||
</ion-card-content>
|
||||
<ion-footer>
|
||||
<div class="inline-container">
|
||||
<p class="action-text">Download</p>
|
||||
<ion-icon slot="end" name="download-outline"></ion-icon>
|
||||
</div>
|
||||
</ion-footer>
|
||||
</ion-card>
|
||||
<ion-card
|
||||
id="launch"
|
||||
[disabled]="disableLogin"
|
||||
href="{{ lanAddress }}"
|
||||
target="_blank"
|
||||
>
|
||||
<ion-card-content>
|
||||
<ion-card-title>Trust your Root CA</ion-card-title>
|
||||
<p>
|
||||
In the new tab, follow instructions to trust your
|
||||
server's Root CA and log in.
|
||||
</p>
|
||||
</ion-card-content>
|
||||
<ion-footer>
|
||||
<div class="container">
|
||||
<div class="inline-container">
|
||||
<p class="action-text">Open</p>
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</div>
|
||||
</div>
|
||||
</ion-footer>
|
||||
</ion-card>
|
||||
</div>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
<!-- download elem -->
|
||||
<download-doc
|
||||
hidden
|
||||
id="downloadable"
|
||||
[lanAddress]="lanAddress"
|
||||
></download-doc>
|
||||
</ion-card>
|
||||
</ng-template>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -1,183 +0,0 @@
|
||||
canvas {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-variant: all-small-caps;
|
||||
margin: unset;
|
||||
}
|
||||
|
||||
ion-content {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
--background: transparent;
|
||||
}
|
||||
|
||||
ion-grid {
|
||||
max-width: 760px;
|
||||
}
|
||||
|
||||
.inline-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
ion-card {
|
||||
padding: 2.4rem;
|
||||
|
||||
h1 {
|
||||
color: var(--ion-color-success);
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
ion-icon {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
li {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
// download info card
|
||||
ion-card {
|
||||
min-height: 260px;
|
||||
width: 80%;
|
||||
background: #615F5F;
|
||||
color: var(--ion-text-color);
|
||||
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
|
||||
border-radius: 44px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
padding: 1rem 2rem;
|
||||
transition: all 350ms ease;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
transition-property: transform;
|
||||
transform: scale(1.05);
|
||||
transition-delay: 40ms;
|
||||
}
|
||||
|
||||
ion-card-title {
|
||||
color: var(--ion-text-color);
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
ion-footer {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 0;
|
||||
color: var(--ion-text-color);
|
||||
|
||||
p {
|
||||
font-size: 1.1rem;
|
||||
font-weight: bold;
|
||||
margin: unset;
|
||||
}
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
}
|
||||
|
||||
.footer-md::before {
|
||||
background-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
.login-button {
|
||||
--background: var(--color-accent);
|
||||
--padding-bottom: 2.5rem;
|
||||
--padding-top: 2.5rem;
|
||||
--padding-start: 2.5rem;
|
||||
--padding-end: 2.5rem;
|
||||
--border-radius: 44px;
|
||||
font-size: 1.4rem !important;
|
||||
font-weight: bold;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
transition: all 350ms ease;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
transition-property: transform;
|
||||
transform: scale(1.05);
|
||||
transition-delay: 40ms;
|
||||
}
|
||||
|
||||
ion-icon {
|
||||
font-size: 1.7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.launch-button {
|
||||
--background: var(--alt-blue);
|
||||
}
|
||||
|
||||
#information:after, #launch:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 79%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: var(--color-accent);
|
||||
}
|
||||
|
||||
#launch:after {
|
||||
background: var(--alt-blue);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.mb-12 {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
.pb-2 {
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.pt-1 {
|
||||
padding-top: 0.25rem;
|
||||
}
|
||||
|
||||
|
||||
.action-text {
|
||||
font-variant-caps: all-small-caps;
|
||||
padding-right: 0.5rem;
|
||||
font-size: 1.5rem !important;
|
||||
letter-spacing: 0.03rem;
|
||||
padding-bottom: 0.1rem;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.setup {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
ion-card {
|
||||
ion-card {
|
||||
width: 100%;
|
||||
padding-bottom: unset;
|
||||
}
|
||||
#information:after {
|
||||
top: 84%;
|
||||
}
|
||||
#launch:after {
|
||||
top: 85%;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
import { Component, ElementRef, Inject, NgZone, ViewChild } from '@angular/core'
|
||||
import { DownloadHTMLService, ErrorService } from '@start9labs/shared'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
selector: 'success',
|
||||
templateUrl: 'success.page.html',
|
||||
styleUrls: ['success.page.scss'],
|
||||
})
|
||||
export class SuccessPage {
|
||||
@ViewChild('canvas', { static: true })
|
||||
private canvas: ElementRef<HTMLCanvasElement> =
|
||||
{} as ElementRef<HTMLCanvasElement>
|
||||
private ctx: CanvasRenderingContext2D = {} as CanvasRenderingContext2D
|
||||
|
||||
torAddress?: string
|
||||
lanAddress?: string
|
||||
cert?: string
|
||||
|
||||
tileSize = 16
|
||||
// a higher fade factor will make the characters fade quicker
|
||||
fadeFactor = 0.07
|
||||
columns: any[] = []
|
||||
maxStackHeight: any
|
||||
disableLogin = this.stateService.setupType === 'fresh'
|
||||
|
||||
constructor(
|
||||
@Inject(DOCUMENT) private readonly document: Document,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly stateService: StateService,
|
||||
private readonly api: ApiService,
|
||||
private readonly downloadHtml: DownloadHTMLService,
|
||||
private readonly ngZone: NgZone,
|
||||
) {}
|
||||
|
||||
get setupType() {
|
||||
return this.stateService.setupType
|
||||
}
|
||||
|
||||
get isKiosk() {
|
||||
return ['localhost', '127.0.0.1'].includes(this.document.location.hostname)
|
||||
}
|
||||
|
||||
async ngAfterViewInit() {
|
||||
this.ngZone.runOutsideAngular(() => this.initMatrix())
|
||||
setTimeout(() => this.complete(), 1000)
|
||||
}
|
||||
|
||||
download() {
|
||||
const torAddress = this.document.getElementById('tor-addr')
|
||||
const lanAddress = this.document.getElementById('lan-addr')
|
||||
|
||||
if (torAddress) torAddress.innerHTML = this.torAddress!
|
||||
if (lanAddress) lanAddress.innerHTML = this.lanAddress!
|
||||
|
||||
this.document
|
||||
.getElementById('cert')
|
||||
?.setAttribute(
|
||||
'href',
|
||||
'data:application/x-x509-ca-cert;base64,' +
|
||||
encodeURIComponent(this.cert!),
|
||||
)
|
||||
let html = this.document.getElementById('downloadable')?.innerHTML || ''
|
||||
this.downloadHtml.download('StartOS-info.html', html).then(_ => {
|
||||
this.disableLogin = false
|
||||
})
|
||||
}
|
||||
|
||||
exitKiosk() {
|
||||
this.api.exit()
|
||||
}
|
||||
|
||||
private async complete() {
|
||||
try {
|
||||
const ret = await this.api.complete()
|
||||
if (!this.isKiosk) {
|
||||
this.torAddress = ret['tor-address'].replace(/^https:/, 'http:')
|
||||
this.lanAddress = ret['lan-address'].replace(/^https:/, 'http:')
|
||||
this.cert = ret['root-ca']
|
||||
|
||||
await this.api.exit()
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
}
|
||||
}
|
||||
|
||||
private initMatrix() {
|
||||
this.ctx = this.canvas.nativeElement.getContext('2d')!
|
||||
this.canvas.nativeElement.width = window.innerWidth
|
||||
this.canvas.nativeElement.height = window.innerHeight
|
||||
this.setupMatrixGrid()
|
||||
this.tick()
|
||||
}
|
||||
|
||||
private setupMatrixGrid() {
|
||||
this.maxStackHeight = Math.ceil(this.ctx.canvas.height / this.tileSize)
|
||||
// divide the canvas into columns
|
||||
for (let i = 0; i < this.ctx.canvas.width / this.tileSize; ++i) {
|
||||
const column = {} as any
|
||||
// save the x position of the column
|
||||
column.x = i * this.tileSize
|
||||
// create a random stack height for the column
|
||||
column.stackHeight = 10 + Math.random() * this.maxStackHeight
|
||||
// add a counter to count the stack height
|
||||
column.stackCounter = 0
|
||||
// add the column to the list
|
||||
this.columns.push(column)
|
||||
}
|
||||
}
|
||||
|
||||
private draw() {
|
||||
// draw a semi transparent black rectangle on top of the scene to slowly fade older characters
|
||||
this.ctx.fillStyle = 'rgba( 0 , 0 , 0 , ' + this.fadeFactor + ' )'
|
||||
this.ctx.fillRect(0, 0, this.ctx.canvas.width, this.ctx.canvas.height)
|
||||
// pick a font slightly smaller than the tile size
|
||||
this.ctx.font = this.tileSize - 2 + 'px monospace'
|
||||
this.ctx.fillStyle = '#ff4961'
|
||||
for (let i = 0; i < this.columns.length; ++i) {
|
||||
// pick a random ascii character (change the 94 to a higher number to include more characters)
|
||||
const randomCharacter = String.fromCharCode(
|
||||
33 + Math.floor(Math.random() * 94),
|
||||
)
|
||||
this.ctx.fillText(
|
||||
randomCharacter,
|
||||
this.columns[i].x,
|
||||
this.columns[i].stackCounter * this.tileSize + this.tileSize,
|
||||
)
|
||||
// if the stack is at its height limit, pick a new random height and reset the counter
|
||||
if (++this.columns[i].stackCounter >= this.columns[i].stackHeight) {
|
||||
this.columns[i].stackHeight = 10 + Math.random() * this.maxStackHeight
|
||||
this.columns[i].stackCounter = 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private tick() {
|
||||
this.draw()
|
||||
setTimeout(this.tick.bind(this), 50)
|
||||
}
|
||||
}
|
||||
105
web/projects/setup-wizard/src/app/pages/transfer.page.ts
Normal file
105
web/projects/setup-wizard/src/app/pages/transfer.page.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import {
|
||||
DiskInfo,
|
||||
DriveComponent,
|
||||
ErrorService,
|
||||
toGuid,
|
||||
} from '@start9labs/shared'
|
||||
import {
|
||||
TuiDialogOptions,
|
||||
TuiDialogService,
|
||||
TuiLoaderModule,
|
||||
} from '@taiga-ui/core'
|
||||
import {
|
||||
TuiButtonModule,
|
||||
TuiCardModule,
|
||||
TuiCellModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
|
||||
import { filter } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api.service'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<section tuiCardLarge>
|
||||
<header>Transfer</header>
|
||||
Select the physical drive containing your StartOS data
|
||||
@if (loading) {
|
||||
<tui-loader />
|
||||
}
|
||||
@for (drive of drives; track drive) {
|
||||
<button tuiCell [drive]="drive" (click)="select(drive)"></button>
|
||||
}
|
||||
<button tuiButton iconLeft="tuiIconRotateCwLarge" (click)="refresh()">
|
||||
Refresh
|
||||
</button>
|
||||
</section>
|
||||
`,
|
||||
imports: [
|
||||
TuiCardModule,
|
||||
TuiCellModule,
|
||||
TuiButtonModule,
|
||||
TuiLoaderModule,
|
||||
DriveComponent,
|
||||
],
|
||||
})
|
||||
export default class TransferPage {
|
||||
private readonly apiService = inject(ApiService)
|
||||
private readonly router = inject(Router)
|
||||
private readonly dialogs = inject(TuiDialogService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
private readonly stateService = inject(StateService)
|
||||
|
||||
loading = true
|
||||
drives: DiskInfo[] = []
|
||||
|
||||
async ngOnInit() {
|
||||
this.stateService.setupType = 'transfer'
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
async getDrives() {
|
||||
this.loading = true
|
||||
|
||||
try {
|
||||
this.drives = await this.apiService
|
||||
.getDrives()
|
||||
.then(drives => drives.filter(toGuid))
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
select(drive: DiskInfo) {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, OPTIONS)
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => {
|
||||
this.stateService.recoverySource = {
|
||||
type: 'migrate',
|
||||
guid: toGuid(drive) || '',
|
||||
}
|
||||
this.router.navigate([`storage`])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const OPTIONS: Partial<TuiDialogOptions<TuiPromptData>> = {
|
||||
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,16 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { TransferPage } from './transfer.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: TransferPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class TransferPageRoutingModule {}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import {
|
||||
GuidPipePipesModule,
|
||||
UnitConversionPipesModule,
|
||||
} from '@start9labs/shared'
|
||||
import { TransferPage } from './transfer.page'
|
||||
import { TransferPageRoutingModule } from './transfer-routing.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [TransferPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
TransferPageRoutingModule,
|
||||
UnitConversionPipesModule,
|
||||
GuidPipePipesModule,
|
||||
],
|
||||
})
|
||||
export class TransferPageModule {}
|
||||
@@ -1,61 +0,0 @@
|
||||
<ion-content>
|
||||
<ion-grid>
|
||||
<ion-row class="ion-align-items-center">
|
||||
<ion-col>
|
||||
<ion-card color="dark">
|
||||
<ion-card-header class="ion-text-center">
|
||||
<ion-card-title>Transfer</ion-card-title>
|
||||
<div class="center-wrapper">
|
||||
<ion-card-subtitle>
|
||||
Select the physical drive containing your StartOS data
|
||||
</ion-card-subtitle>
|
||||
</div>
|
||||
</ion-card-header>
|
||||
|
||||
<ion-card-content class="ion-margin">
|
||||
<ion-spinner
|
||||
*ngIf="loading"
|
||||
class="center-spinner"
|
||||
name="lines"
|
||||
></ion-spinner>
|
||||
|
||||
<!-- loaded -->
|
||||
<ion-item-group *ngIf="!loading" class="ion-text-center">
|
||||
<!-- drives -->
|
||||
<ng-container *ngFor="let drive of drives">
|
||||
<ion-item
|
||||
*ngIf="drive | guid as guid"
|
||||
button
|
||||
(click)="select(guid)"
|
||||
lines="none"
|
||||
>
|
||||
<ion-icon
|
||||
slot="start"
|
||||
name="save-outline"
|
||||
size="large"
|
||||
></ion-icon>
|
||||
<ion-label>
|
||||
<h1>{{ drive.logicalname }}</h1>
|
||||
<p>
|
||||
{{ drive.vendor || 'Unknown Vendor' }} - {{ drive.model ||
|
||||
'Unknown Model' }}
|
||||
</p>
|
||||
<p>Capacity: {{ drive.capacity | convertBytes }}</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
<ion-button
|
||||
class="ion-margin-top"
|
||||
color="primary"
|
||||
(click)="refresh()"
|
||||
>
|
||||
<ion-icon slot="start" name="refresh"></ion-icon>
|
||||
Refresh
|
||||
</ion-button>
|
||||
</ion-item-group>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -1,68 +0,0 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/api.service'
|
||||
import { DiskInfo, ErrorService } from '@start9labs/shared'
|
||||
import { StateService } from 'src/app/services/state.service'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { filter } from 'rxjs'
|
||||
|
||||
@Component({
|
||||
selector: 'app-transfer',
|
||||
templateUrl: 'transfer.page.html',
|
||||
styleUrls: ['transfer.page.scss'],
|
||||
})
|
||||
export class TransferPage {
|
||||
loading = true
|
||||
drives: DiskInfo[] = []
|
||||
|
||||
constructor(
|
||||
private readonly apiService: ApiService,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly stateService: StateService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.stateService.setupType = 'transfer'
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.loading = true
|
||||
await this.getDrives()
|
||||
}
|
||||
|
||||
async getDrives() {
|
||||
try {
|
||||
this.drives = await this.apiService.getDrives()
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
select(guid: string) {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, {
|
||||
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',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => {
|
||||
this.stateService.recoverySource = {
|
||||
type: 'migrate',
|
||||
guid,
|
||||
}
|
||||
this.navCtrl.navigateForward(`/storage`)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import {
|
||||
DiskListResponse,
|
||||
StartOSDiskInfo,
|
||||
@@ -28,9 +28,7 @@ import { Observable } from 'rxjs'
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class LiveApiService extends ApiService {
|
||||
constructor(private readonly http: HttpService) {
|
||||
super()
|
||||
}
|
||||
private readonly http = inject(HttpService)
|
||||
|
||||
async getSetupStatus() {
|
||||
return this.rpcRequest<SetupStatus | null>({
|
||||
@@ -111,7 +111,7 @@ export class MockApiService extends ApiService {
|
||||
guid: 'guid-guid-guid-guid',
|
||||
},
|
||||
],
|
||||
capacity: 1000190509056,
|
||||
capacity: 1000190509,
|
||||
guid: null,
|
||||
},
|
||||
]
|
||||
@@ -1,17 +1,16 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ApiService, RecoverySource } from './api/api.service'
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { ApiService, RecoverySource } from './api.service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class StateService {
|
||||
setupType?: 'fresh' | 'restore' | 'attach' | 'transfer'
|
||||
private readonly api = inject(ApiService)
|
||||
|
||||
setupType?: 'fresh' | 'restore' | 'attach' | 'transfer'
|
||||
recoverySource?: RecoverySource
|
||||
recoveryPassword?: string
|
||||
|
||||
constructor(private readonly api: ApiService) {}
|
||||
|
||||
async importDrive(guid: string, password: string): Promise<void> {
|
||||
await this.api.attach({
|
||||
guid,
|
||||
|
||||
@@ -1,345 +1,69 @@
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
src: url('/assets/fonts/Montserrat/Montserrat-Regular.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: bold;
|
||||
src: url('/assets/fonts/Montserrat/Montserrat-Bold.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url('/assets/fonts/Montserrat/Montserrat-SemiBold.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: bold;
|
||||
src: url('/assets/fonts/Montserrat/Montserrat-Bold.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
src: url('/assets/fonts/Montserrat/Montserrat-Medium.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Montserrat';
|
||||
font-style: normal;
|
||||
font-weight: thin;
|
||||
src: url('/assets/fonts/Montserrat/Montserrat-Light.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Benton Sans';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
src: url('/assets/fonts/Benton_Sans/BentonSans-Regular.otf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: normal;
|
||||
src: url('/assets/fonts/Open_Sans/OpenSans-Regular.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: bold;
|
||||
src: url('/assets/fonts/Open_Sans/OpenSans-Bold.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 700;
|
||||
src: url('/assets/fonts/Open_Sans/OpenSans-Bold.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: 600;
|
||||
src: url('/assets/fonts/Open_Sans/OpenSans-SemiBold.ttf');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Open Sans';
|
||||
font-style: normal;
|
||||
font-weight: thin;
|
||||
src: url('/assets/fonts/Open_Sans/OpenSans-Light.ttf');
|
||||
}
|
||||
|
||||
/** Ionic CSS Variables overrides **/
|
||||
:root {
|
||||
--ion-font-family: 'Montserrat', sans-serif;
|
||||
|
||||
--ion-background-color: #333333;
|
||||
--ion-background-color-rgb: 51, 51, 51;
|
||||
|
||||
--ion-text-color: #F4F4F5;
|
||||
--ion-text-color-rgb: 244, 244, 245;
|
||||
|
||||
--ion-color-step-50: #3d3d3d;
|
||||
--ion-color-step-100: #464646;
|
||||
--ion-color-step-150: #505050;
|
||||
--ion-color-step-200: #5a5a5a;
|
||||
--ion-color-step-250: #636364;
|
||||
--ion-color-step-300: #6d6d6d;
|
||||
--ion-color-step-350: #777777;
|
||||
--ion-color-step-400: #808081;
|
||||
--ion-color-step-450: #8a8a8a;
|
||||
--ion-color-step-500: #949494;
|
||||
--ion-color-step-550: #9d9d9e;
|
||||
--ion-color-step-600: #a7a7a7;
|
||||
--ion-color-step-650: #b0b0b1;
|
||||
--ion-color-step-700: #bababb;
|
||||
--ion-color-step-750: #c4c4c5;
|
||||
--ion-color-step-800: #cdcdce;
|
||||
--ion-color-step-850: #d7d7d8;
|
||||
--ion-color-step-900: #e1e1e2;
|
||||
--ion-color-step-950: #eaeaeb;
|
||||
|
||||
|
||||
--ion-color-dark: var(--ion-color-step-50) !important;
|
||||
// --ion-color-base-rgb:
|
||||
--ion-color-dark-contrast: var(--ion-color-step-950) !important;
|
||||
// --ion-color-dark-contrast-rgb:
|
||||
--ion-color-dark-shade: var(--ion-color-step-100) !important;
|
||||
--ion-color-dark-tint: var(--ion-color-step-250) !important;
|
||||
|
||||
--color-accent: #6866cc;
|
||||
--color-dark-black: #121212;
|
||||
|
||||
--alt-red: #FF4961;
|
||||
--alt-orange: #F89248;
|
||||
--alt-yellow: #E5D53E;
|
||||
--alt-green: #3DCF6F;
|
||||
--alt-blue: #00A8A8;
|
||||
--alt-purple: #9747FF;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
h4 {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 42px;
|
||||
}
|
||||
|
||||
ion-card-title {
|
||||
margin: 16px 0;
|
||||
font-family: 'Montserrat', sans-serif;
|
||||
font-size: x-large;
|
||||
--color: var(--ion-color-light);
|
||||
}
|
||||
|
||||
ion-card-subtitle {
|
||||
font-size: 20px;
|
||||
font-weight: 200;
|
||||
max-width: 400px;
|
||||
padding: 0.7rem;
|
||||
color: var(--ion-color-step-900) !important;
|
||||
}
|
||||
|
||||
ion-label ion-text {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
p {
|
||||
color: var(--ion-color-dark-contrast) !important;
|
||||
font-size: 1.12rem !important;
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
ion-icon {
|
||||
color: var(--ion-color-dark-contrast) !important;
|
||||
}
|
||||
|
||||
.small-caps {
|
||||
font-variant-caps: all-small-caps;
|
||||
}
|
||||
|
||||
ion-grid {
|
||||
padding-top: 32px;
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
max-width: 695px;
|
||||
}
|
||||
|
||||
ion-row {
|
||||
height: 90%;
|
||||
app-root {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
ion-card {
|
||||
border-radius: 31px;
|
||||
tui-root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
ion-item {
|
||||
--highlight-color-valid: transparent;
|
||||
--highlight-color-invalid: transparent;
|
||||
}
|
||||
|
||||
ion-avatar {
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
}
|
||||
|
||||
ion-toast {
|
||||
--background: var(--ion-color-light);
|
||||
--button-color: var(--ion-color-dark);
|
||||
--border-style: solid;
|
||||
--border-width: 1px;
|
||||
--color: white;
|
||||
}
|
||||
|
||||
.center-spinner {
|
||||
height: 6vh;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.inline {
|
||||
* {
|
||||
display: inline-block;
|
||||
vertical-align: middle;
|
||||
padding-left: 0px 0.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.claim-button {
|
||||
margin-inline-start: 0;
|
||||
margin-inline-end: 0;
|
||||
margin-top: 24px;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.error-toast {
|
||||
--border-color: var(--ion-color-danger);
|
||||
width: 40%;
|
||||
min-width: 400px;
|
||||
--end: 8px;
|
||||
right: 8px;
|
||||
left: unset;
|
||||
top: 64px;
|
||||
}
|
||||
|
||||
.error-border {
|
||||
border: 2px solid var(--ion-color-danger);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.success-border {
|
||||
border: 2px solid var(--ion-color-success);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.sc-ion-label-md-s p {
|
||||
line-height: 23px;
|
||||
}
|
||||
|
||||
ion-button {
|
||||
--padding-top: 1.3rem;
|
||||
--padding-bottom: 1.3rem;
|
||||
}
|
||||
|
||||
ion-item {
|
||||
border: var(--ion-color-step-750) 1px solid;
|
||||
margin: 2rem;
|
||||
--background: transparent;
|
||||
--border-color: var(--ion-item-border-color, var(--ion-border-color, var(--ion-color-step-150, rgba(0, 0, 0, 0.13))));
|
||||
transition: all 350ms ease;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
transition-property: transform;
|
||||
transform: scale(1.05);
|
||||
transition-delay: 40ms;
|
||||
}
|
||||
|
||||
ion-button {
|
||||
--padding-top: unset;
|
||||
--padding-bottom: unset;
|
||||
}
|
||||
}
|
||||
|
||||
.item.sc-ion-label-md-h,
|
||||
.item .sc-ion-label-md-h {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.center-wrapper {
|
||||
router-outlet + * {
|
||||
height: 100%;
|
||||
max-width: min(35rem, 100vw);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
box-sizing: border-box;
|
||||
padding: 2rem;
|
||||
margin: 0 auto;
|
||||
|
||||
|
||||
.loader {
|
||||
--spinner-color: var(--ion-color-tertiary) !important;
|
||||
}
|
||||
|
||||
.toolbar-background {
|
||||
background: #2a2a2a !important;
|
||||
}
|
||||
|
||||
.toolbar-container {
|
||||
padding-right: 2rem !important;
|
||||
}
|
||||
|
||||
ion-header {
|
||||
ion-toolbar {
|
||||
--border-color: var(--ion-color-step-950);
|
||||
--border-width: 0 0 1px 0;
|
||||
|
||||
--min-height: 80px;
|
||||
--padding-top: 20px;
|
||||
--padding-bottom: 20px;
|
||||
--padding-end: 2rem;
|
||||
[tuiCardLarge] {
|
||||
width: 100%;
|
||||
background: var(--tui-base-02);
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
ion-footer {
|
||||
ion-toolbar {
|
||||
--border-width: 0;
|
||||
--padding-end: 2.3rem;
|
||||
--padding-bottom: 2rem;
|
||||
button:disabled {
|
||||
opacity: var(--tui-disabled-opacity);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
font: var(--tui-font-heading-4);
|
||||
|
||||
p {
|
||||
font: var(--tui-font-text-m);
|
||||
color: var(--tui-text-02);
|
||||
}
|
||||
}
|
||||
|
||||
.footer-md::before {
|
||||
content: none;
|
||||
h2 {
|
||||
margin: 0;
|
||||
font: var(--tui-font-heading-6);
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
h1 {
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
ion-item {
|
||||
margin: 0 0.5rem 2rem 0.5rem;
|
||||
}
|
||||
.g-success {
|
||||
color: var(--tui-success-fill);
|
||||
}
|
||||
|
||||
p a {
|
||||
color: var(--ion-text-color);
|
||||
// text-decoration: none;
|
||||
font-weight: 600;
|
||||
text-underline-offset: 0.4rem;
|
||||
.g-warning {
|
||||
color: var(--tui-warning-fill);
|
||||
}
|
||||
|
||||
.g-error {
|
||||
color: var(--tui-error-fill);
|
||||
}
|
||||
|
||||
.g-info {
|
||||
color: var(--tui-info-fill);
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
"@angular/common": "^17.0.6",
|
||||
"@angular/core": "^17.0.6",
|
||||
"@angular/router": "^17.0.6",
|
||||
"@ionic/angular": ">=6.0.0",
|
||||
"@ng-web-apis/mutation-observer": ">=2.0.0",
|
||||
"@ng-web-apis/resize-observer": ">=2.0.0",
|
||||
"@start9labs/emver": "^0.1.5",
|
||||
"@taiga-ui/cdk": ">=3.0.0",
|
||||
"@taiga-ui/core": ">=3.0.0",
|
||||
"@taiga-ui/experimental": ">=3.0.0",
|
||||
"@tinkoff/ng-dompurify": ">=4.0.0",
|
||||
"ansi-to-html": "^0.7.2"
|
||||
},
|
||||
|
||||
29
web/projects/shared/src/components/drive.component.ts
Normal file
29
web/projects/shared/src/components/drive.component.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { TuiIconModule, TuiTitleModule } from '@taiga-ui/experimental'
|
||||
import { UnitConversionPipesModule } from '../pipes/unit-conversion/unit-conversion.module'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'button[drive]',
|
||||
template: `
|
||||
<tui-icon icon="tuiIconSave" />
|
||||
<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: [TuiIconModule, TuiTitleModule, UnitConversionPipesModule],
|
||||
})
|
||||
export class DriveComponent {
|
||||
@Input() drive!: {
|
||||
logicalname: string | null
|
||||
vendor: string | null
|
||||
model: string | null
|
||||
capacity: number
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
<div *tuiLet="progress$ | async as progress" class="card">
|
||||
<h1 class="title">Initializing StartOS</h1>
|
||||
<div *ngIf="progress" class="center-wrapper">
|
||||
Progress: {{ (progress * 100).toFixed(0) }}%
|
||||
</div>
|
||||
|
||||
<progress
|
||||
tuiProgressBar
|
||||
class="progress"
|
||||
[attr.value]="progress && progress < 1 ? progress : null"
|
||||
></progress>
|
||||
<p>{{ getMessage(progress) }}</p>
|
||||
</div>
|
||||
|
||||
<logs-window class="logs" />
|
||||
@@ -1,33 +0,0 @@
|
||||
.card {
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem;
|
||||
margin: 1.5rem;
|
||||
text-align: center;
|
||||
// TODO: Theme
|
||||
background: #e0e0e0;
|
||||
color: #333;
|
||||
--tui-clear-inverse: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2.5rem;
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.progress {
|
||||
max-width: 40rem;
|
||||
margin: 1rem auto;
|
||||
}
|
||||
|
||||
.logs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 18rem;
|
||||
padding: 1rem;
|
||||
margin: 0 1.5rem auto;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
border-radius: 2rem;
|
||||
// TODO: Theme
|
||||
background: #181818;
|
||||
}
|
||||
@@ -1,11 +1,67 @@
|
||||
import { Component, inject, Input, Output } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
Output,
|
||||
} from '@angular/core'
|
||||
import { TuiLetModule } from '@taiga-ui/cdk'
|
||||
import { TuiProgressModule } from '@taiga-ui/kit'
|
||||
import { delay, filter } from 'rxjs'
|
||||
import { LogsWindowComponent } from './logs-window.component'
|
||||
import { SetupService } from '../../services/setup.service'
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
selector: 'app-initializing',
|
||||
templateUrl: 'initializing.component.html',
|
||||
styleUrls: ['initializing.component.scss'],
|
||||
template: `
|
||||
<section *tuiLet="progress$ | async as progress">
|
||||
<h1 [style.font-size.rem]="2.5" [style.margin.rem]="1">
|
||||
Initializing StartOS
|
||||
</h1>
|
||||
<div *ngIf="progress" class="center-wrapper">
|
||||
Progress: {{ (progress * 100).toFixed(0) }}%
|
||||
</div>
|
||||
|
||||
<progress
|
||||
tuiProgressBar
|
||||
class="progress"
|
||||
[style.max-width.rem]="40"
|
||||
[style.margin]="'1rem auto'"
|
||||
[attr.value]="progress && progress < 1 ? progress : null"
|
||||
></progress>
|
||||
<p>{{ getMessage(progress) }}</p>
|
||||
</section>
|
||||
<logs-window />
|
||||
`,
|
||||
styles: `
|
||||
section {
|
||||
border-radius: 0.25rem;
|
||||
padding: 1rem;
|
||||
margin: 1.5rem;
|
||||
text-align: center;
|
||||
// TODO: Theme
|
||||
background: #e0e0e0;
|
||||
color: #333;
|
||||
--tui-clear-inverse: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
logs-window {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 18rem;
|
||||
padding: 1rem;
|
||||
margin: 0 1.5rem auto;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
border-radius: 2rem;
|
||||
// TODO: Theme
|
||||
background: #181818;
|
||||
}
|
||||
`,
|
||||
imports: [CommonModule, LogsWindowComponent, TuiLetModule, TuiProgressModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class InitializingComponent {
|
||||
readonly progress$ = inject(SetupService)
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { TuiLetModule } from '@taiga-ui/cdk'
|
||||
import { TuiProgressModule } from '@taiga-ui/kit'
|
||||
|
||||
import { LogsWindowComponent } from './logs-window.component'
|
||||
import { InitializingComponent } from './initializing.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, TuiLetModule, LogsWindowComponent, TuiProgressModule],
|
||||
declarations: [InitializingComponent],
|
||||
exports: [InitializingComponent],
|
||||
})
|
||||
export class InitializingModule {}
|
||||
@@ -1,8 +0,0 @@
|
||||
<ion-grid class="full-height">
|
||||
<ion-row class="ion-align-items-center ion-text-center full-height">
|
||||
<ion-col>
|
||||
<ion-spinner color="tertiary"></ion-spinner>
|
||||
<p>{{ text }}</p>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
@@ -1,11 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { TextSpinnerComponent } from './text-spinner.component'
|
||||
|
||||
@NgModule({
|
||||
declarations: [TextSpinnerComponent],
|
||||
imports: [CommonModule, IonicModule],
|
||||
exports: [TextSpinnerComponent],
|
||||
})
|
||||
export class TextSpinnerComponentModule {}
|
||||
@@ -1,3 +0,0 @@
|
||||
.full-height {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
|
||||
@Component({
|
||||
selector: 'text-spinner',
|
||||
templateUrl: './text-spinner.component.html',
|
||||
styleUrls: ['./text-spinner.component.scss'],
|
||||
})
|
||||
export class TextSpinnerComponent {
|
||||
@Input() text = ''
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import {
|
||||
Directive,
|
||||
OnInit,
|
||||
Optional,
|
||||
ElementRef,
|
||||
Inject,
|
||||
InjectionToken,
|
||||
Input,
|
||||
NgZone,
|
||||
} from '@angular/core'
|
||||
import { ResizeObserverService } from '@ng-web-apis/resize-observer'
|
||||
import { distinctUntilChanged, map, Observable } from 'rxjs'
|
||||
import { tuiZonefree, TuiDestroyService } from '@taiga-ui/cdk'
|
||||
import { IonCol } from '@ionic/angular'
|
||||
import { takeUntil } from 'rxjs'
|
||||
|
||||
export type Step = 'xs' | 'sm' | 'md' | 'lg' | 'xl'
|
||||
|
||||
const SIZE: readonly Step[] = ['xl', 'lg', 'md', 'sm', 'xs']
|
||||
|
||||
/**
|
||||
* Not exported:
|
||||
* https://github.com/ionic-team/ionic-framework/blob/main/core/src/utils/media.ts
|
||||
*
|
||||
* export const SIZE_TO_MEDIA: any = {
|
||||
* xs: '(min-width: 0px)',
|
||||
* sm: '(min-width: 576px)',
|
||||
* md: '(min-width: 768px)',
|
||||
* lg: '(min-width: 992px)',
|
||||
* xl: '(min-width: 1200px)',
|
||||
* };
|
||||
*/
|
||||
export const BREAKPOINTS = new InjectionToken<readonly [number, Step][]>(
|
||||
'BREAKPOINTS',
|
||||
{
|
||||
factory: () => [
|
||||
[1200, 'xl'],
|
||||
[992, 'lg'],
|
||||
[768, 'md'],
|
||||
[576, 'sm'],
|
||||
[0, 'xs'],
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
@Directive({
|
||||
selector: '[responsiveColViewport]',
|
||||
exportAs: 'viewport',
|
||||
providers: [ResizeObserverService],
|
||||
standalone: true,
|
||||
})
|
||||
export class ResponsiveColViewportDirective extends Observable<Step> {
|
||||
@Input()
|
||||
responsiveColViewport: Observable<Step> | '' = ''
|
||||
|
||||
constructor(
|
||||
@Inject(BREAKPOINTS)
|
||||
private readonly breakpoints: readonly [number, Step][],
|
||||
private readonly resize$: ResizeObserverService,
|
||||
private readonly elementRef: ElementRef<HTMLElement>,
|
||||
private readonly zone: NgZone,
|
||||
) {
|
||||
super(subscriber =>
|
||||
(this.responsiveColViewport || this.stream$).subscribe(subscriber),
|
||||
)
|
||||
}
|
||||
|
||||
private readonly stream$ = this.resize$.pipe(
|
||||
map(() => this.elementRef.nativeElement.clientWidth),
|
||||
map(width => this.breakpoints.find(([step]) => width >= step)?.[1] || 'xs'),
|
||||
distinctUntilChanged(),
|
||||
tuiZonefree(this.zone),
|
||||
)
|
||||
}
|
||||
|
||||
@Directive({
|
||||
selector: 'ion-col[responsiveCol]',
|
||||
providers: [TuiDestroyService],
|
||||
standalone: true,
|
||||
})
|
||||
export class ResponsiveColDirective implements OnInit {
|
||||
readonly size: Record<Step, string | undefined> = {
|
||||
xs: '12',
|
||||
sm: '6',
|
||||
md: '4',
|
||||
lg: '3',
|
||||
xl: '2',
|
||||
}
|
||||
|
||||
constructor(
|
||||
@Optional()
|
||||
viewport$: ResponsiveColViewportDirective | null,
|
||||
destroy$: TuiDestroyService,
|
||||
private readonly col: IonCol,
|
||||
) {
|
||||
viewport$?.pipe(takeUntil(destroy$)).subscribe(size => {
|
||||
const max = this.size[size] || this.findMax(size)
|
||||
|
||||
this.col.sizeLg = max
|
||||
this.col.sizeMd = max
|
||||
this.col.sizeSm = max
|
||||
this.col.sizeXl = max
|
||||
this.col.sizeXs = max
|
||||
})
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.size.lg = this.col.sizeLg
|
||||
this.size.md = this.col.sizeMd
|
||||
this.size.sm = this.col.sizeSm
|
||||
this.size.xl = this.col.sizeXl
|
||||
this.size.xs = this.col.sizeXs
|
||||
}
|
||||
|
||||
private findMax(current: Step): string | undefined {
|
||||
const start = SIZE.indexOf(current) - 1
|
||||
const max = SIZE.find((size, i) => i > start && this.size[size]) || current
|
||||
|
||||
return this.size[max]
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { GuidPipe } from './guid.pipe'
|
||||
|
||||
@NgModule({
|
||||
declarations: [GuidPipe],
|
||||
exports: [GuidPipe],
|
||||
})
|
||||
export class GuidPipePipesModule {}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { DiskInfo } from '../../types/api'
|
||||
|
||||
@Pipe({
|
||||
name: 'guid',
|
||||
})
|
||||
export class GuidPipe implements PipeTransform {
|
||||
transform(disk: DiskInfo): string | null {
|
||||
return disk.guid || disk.partitions.find(p => p.guid)?.guid || null
|
||||
}
|
||||
}
|
||||
@@ -6,20 +6,17 @@ export * from './classes/http-error'
|
||||
export * from './classes/rpc-error'
|
||||
|
||||
export * from './components/initializing/logs-window.component'
|
||||
export * from './components/initializing/initializing.module'
|
||||
export * from './components/initializing/initializing.component'
|
||||
export * from './components/loading/loading.component'
|
||||
export * from './components/loading/loading.module'
|
||||
export * from './components/loading/loading.service'
|
||||
export * from './components/markdown/markdown.component'
|
||||
export * from './components/markdown/markdown.component.module'
|
||||
export * from './components/text-spinner/text-spinner.component'
|
||||
export * from './components/text-spinner/text-spinner.component.module'
|
||||
export * from './components/ticker/ticker.component'
|
||||
export * from './components/ticker/ticker.module'
|
||||
export * from './components/drive.component'
|
||||
|
||||
export * from './directives/drag-scroller.directive'
|
||||
export * from './directives/responsive-col.directive'
|
||||
export * from './directives/safe-links.directive'
|
||||
export * from './directives/enter/enter.directive'
|
||||
export * from './directives/enter/enter.module'
|
||||
@@ -28,8 +25,6 @@ export * from './mocks/get-setup-status'
|
||||
|
||||
export * from './pipes/emver/emver.module'
|
||||
export * from './pipes/emver/emver.pipe'
|
||||
export * from './pipes/guid/guid.module'
|
||||
export * from './pipes/guid/guid.pipe'
|
||||
export * from './pipes/markdown/markdown.module'
|
||||
export * from './pipes/markdown/markdown.pipe'
|
||||
export * from './pipes/shared/shared.module'
|
||||
@@ -70,5 +65,6 @@ export * from './util/get-pkg-id'
|
||||
export * from './util/invert'
|
||||
export * from './util/misc.util'
|
||||
export * from './util/rpc.util'
|
||||
export * from './util/to-guid'
|
||||
export * from './util/to-local-iso-string'
|
||||
export * from './util/unused'
|
||||
|
||||
5
web/projects/shared/src/util/to-guid.ts
Normal file
5
web/projects/shared/src/util/to-guid.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { DiskInfo } from '../types/api'
|
||||
|
||||
export function toGuid(disk: DiskInfo | null): string | null {
|
||||
return disk?.guid || disk?.partitions.find(p => p.guid)?.guid || null
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user