mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-04-04 22:39:46 +00:00
chore: refactor system settings routes (#2853)
* chore: refactor system settings routes * switch mock to null to see backup page --------- Co-authored-by: Matt Hill <mattnine@protonmail.com>
This commit is contained in:
19
web/package-lock.json
generated
19
web/package-lock.json
generated
@@ -46,6 +46,7 @@
|
|||||||
"core-js": "^3.21.1",
|
"core-js": "^3.21.1",
|
||||||
"cron": "^2.2.0",
|
"cron": "^2.2.0",
|
||||||
"cronstrue": "^2.21.0",
|
"cronstrue": "^2.21.0",
|
||||||
|
"deep-equality-data-structures": "1.5.1",
|
||||||
"dompurify": "^2.3.6",
|
"dompurify": "^2.3.6",
|
||||||
"fast-json-patch": "^3.1.1",
|
"fast-json-patch": "^3.1.1",
|
||||||
"fuse.js": "^6.4.6",
|
"fuse.js": "^6.4.6",
|
||||||
@@ -6931,6 +6932,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/deep-equality-data-structures": {
|
||||||
|
"version": "1.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/deep-equality-data-structures/-/deep-equality-data-structures-1.5.1.tgz",
|
||||||
|
"integrity": "sha512-P7zsL2/AbZIGHDxbo/LLEhCp11AttRp8GvzXOXudqMT/qiGCLo/pyI4lAZvjUZyQnlIbPna3fv8DMsuRvLt4ww==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"object-hash": "^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/deepmerge": {
|
"node_modules/deepmerge": {
|
||||||
"version": "4.3.1",
|
"version": "4.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
|
||||||
@@ -11314,6 +11324,15 @@
|
|||||||
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
"url": "https://github.com/fb55/nth-check?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-hash": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-inspect": {
|
"node_modules/object-inspect": {
|
||||||
"version": "1.13.4",
|
"version": "1.13.4",
|
||||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||||
|
|||||||
@@ -69,6 +69,7 @@
|
|||||||
"cron": "^2.2.0",
|
"cron": "^2.2.0",
|
||||||
"cronstrue": "^2.21.0",
|
"cronstrue": "^2.21.0",
|
||||||
"dompurify": "^2.3.6",
|
"dompurify": "^2.3.6",
|
||||||
|
"deep-equality-data-structures": "1.5.1",
|
||||||
"fast-json-patch": "^3.1.1",
|
"fast-json-patch": "^3.1.1",
|
||||||
"fuse.js": "^6.4.6",
|
"fuse.js": "^6.4.6",
|
||||||
"jose": "^4.9.0",
|
"jose": "^4.9.0",
|
||||||
|
|||||||
@@ -162,3 +162,20 @@ tui-badge-notification {
|
|||||||
align-self: center !important;
|
align-self: center !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO Remove after Taiga UI update
|
||||||
|
[tuiTitle] {
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
h4,
|
||||||
|
h5,
|
||||||
|
h6 {
|
||||||
|
font: inherit;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[tuiSubtitle] {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ export interface FormContext<T> {
|
|||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
})
|
})
|
||||||
export class FormComponent<T extends Record<string, any>> implements OnInit {
|
export class FormComponent<T extends Record<string, any>> implements OnInit {
|
||||||
private readonly confirmService = inject(TuiConfirmService)
|
private readonly confirm = inject(TuiConfirmService, { optional: true })
|
||||||
private readonly formService = inject(FormService)
|
private readonly formService = inject(FormService)
|
||||||
private readonly invalidService = inject(InvalidService)
|
private readonly invalidService = inject(InvalidService)
|
||||||
private readonly context = inject<TuiDialogContext<void, FormContext<T>>>(
|
private readonly context = inject<TuiDialogContext<void, FormContext<T>>>(
|
||||||
@@ -115,7 +115,7 @@ export class FormComponent<T extends Record<string, any>> implements OnInit {
|
|||||||
form = new FormGroup({})
|
form = new FormGroup({})
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.confirmService.markAsPristine()
|
this.confirm?.markAsPristine()
|
||||||
this.form = this.formService.createForm(this.spec, this.value)
|
this.form = this.formService.createForm(this.spec, this.value)
|
||||||
this.process(this.operations)
|
this.process(this.operations)
|
||||||
}
|
}
|
||||||
@@ -136,7 +136,7 @@ export class FormComponent<T extends Record<string, any>> implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
markAsDirty() {
|
markAsDirty() {
|
||||||
this.confirmService.markAsDirty()
|
this.confirm?.markAsDirty()
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close() {
|
||||||
|
|||||||
@@ -19,10 +19,11 @@ export class UptimeComponent implements OnChanges, OnDestroy {
|
|||||||
appUptime = ''
|
appUptime = ''
|
||||||
|
|
||||||
ngOnChanges() {
|
ngOnChanges() {
|
||||||
|
clearInterval(this.interval)
|
||||||
|
|
||||||
if (!this.appUptime) {
|
if (!this.appUptime) {
|
||||||
this.el.textContent = '-'
|
this.el.textContent = '-'
|
||||||
} else {
|
} else {
|
||||||
clearInterval(this.interval)
|
|
||||||
this.el.textContent = uptime(new Date(this.appUptime))
|
this.el.textContent = uptime(new Date(this.appUptime))
|
||||||
this.interval = setInterval(() => {
|
this.interval = setInterval(() => {
|
||||||
this.el.textContent = uptime(new Date(this.appUptime))
|
this.el.textContent = uptime(new Date(this.appUptime))
|
||||||
@@ -8,11 +8,11 @@ import {
|
|||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { RouterLink } from '@angular/router'
|
import { RouterLink } from '@angular/router'
|
||||||
import { tuiPure } from '@taiga-ui/cdk'
|
import { tuiPure } from '@taiga-ui/cdk'
|
||||||
|
import { UptimeComponent } from 'src/app/routes/portal/components/uptime.component'
|
||||||
import { ConnectionService } from 'src/app/services/connection.service'
|
import { ConnectionService } from 'src/app/services/connection.service'
|
||||||
import { PkgDependencyErrors } from 'src/app/services/dep-error.service'
|
import { PkgDependencyErrors } from 'src/app/services/dep-error.service'
|
||||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||||
import { getManifest } from 'src/app/utils/get-package-data'
|
import { getManifest } from 'src/app/utils/get-package-data'
|
||||||
import { UptimeComponent } from './uptime.component'
|
|
||||||
import { ControlsComponent } from './controls.component'
|
import { ControlsComponent } from './controls.component'
|
||||||
import { StatusComponent } from './status.component'
|
import { StatusComponent } from './status.component'
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ const ICONS = {
|
|||||||
</tui-avatar>
|
</tui-avatar>
|
||||||
<span tuiFade>{{ manifest()?.title }}</span>
|
<span tuiFade>{{ manifest()?.title }}</span>
|
||||||
</div>
|
</div>
|
||||||
<aside>
|
<aside class="g-aside">
|
||||||
<header tuiCell>
|
<header tuiCell>
|
||||||
<tui-avatar><img alt="" [src]="service()?.icon" /></tui-avatar>
|
<tui-avatar><img alt="" [src]="service()?.icon" /></tui-avatar>
|
||||||
<span tuiTitle>
|
<span tuiTitle>
|
||||||
@@ -76,23 +76,6 @@ const ICONS = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
aside {
|
|
||||||
position: sticky;
|
|
||||||
top: 1px;
|
|
||||||
left: 1px;
|
|
||||||
margin: 1px;
|
|
||||||
width: 16rem;
|
|
||||||
padding: 0.5rem;
|
|
||||||
text-transform: capitalize;
|
|
||||||
box-shadow: 1px 0 var(--tui-border-normal);
|
|
||||||
backdrop-filter: blur(1rem);
|
|
||||||
background-color: color-mix(
|
|
||||||
in hsl,
|
|
||||||
var(--tui-background-base) 90%,
|
|
||||||
transparent
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
header {
|
header {
|
||||||
margin: 0 -0.5rem;
|
margin: 0 -0.5rem;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { isEmptyObject } from '@start9labs/shared'
|
|||||||
import { T } from '@start9labs/start-sdk'
|
import { T } from '@start9labs/start-sdk'
|
||||||
import { PatchDB } from 'patch-db-client'
|
import { PatchDB } from 'patch-db-client'
|
||||||
import { map } from 'rxjs'
|
import { map } from 'rxjs'
|
||||||
|
import { UptimeComponent } from 'src/app/routes/portal/components/uptime.component'
|
||||||
import { ConnectionService } from 'src/app/services/connection.service'
|
import { ConnectionService } from 'src/app/services/connection.service'
|
||||||
import {
|
import {
|
||||||
DataModel,
|
DataModel,
|
||||||
@@ -33,6 +34,9 @@ import { ServiceStatusComponent } from '../components/status.component'
|
|||||||
[installingInfo]="pkg().stateInfo.installingInfo"
|
[installingInfo]="pkg().stateInfo.installingInfo"
|
||||||
[status]="status()"
|
[status]="status()"
|
||||||
>
|
>
|
||||||
|
@if ($any(pkg().status).started; as started) {
|
||||||
|
<p class="g-secondary" [appUptime]="started"></p>
|
||||||
|
}
|
||||||
@if (installed() && connected()) {
|
@if (installed() && connected()) {
|
||||||
<service-actions [pkg]="pkg()" [status]="status()" />
|
<service-actions [pkg]="pkg()" [status]="status()" />
|
||||||
}
|
}
|
||||||
@@ -92,6 +96,7 @@ import { ServiceStatusComponent } from '../components/status.component'
|
|||||||
ServiceDependenciesComponent,
|
ServiceDependenciesComponent,
|
||||||
ServiceErrorComponent,
|
ServiceErrorComponent,
|
||||||
ServiceActionRequestsComponent,
|
ServiceActionRequestsComponent,
|
||||||
|
UptimeComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ServiceRoute {
|
export class ServiceRoute {
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
|
|
||||||
import { CommonModule } from '@angular/common'
|
|
||||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
|
||||||
import { RouterLink } from '@angular/router'
|
|
||||||
import { TuiCell } from '@taiga-ui/layout'
|
|
||||||
import { SettingBtn } from '../system.types'
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'system-button',
|
|
||||||
template: `
|
|
||||||
@if (button.action) {
|
|
||||||
<button tuiCell (click)="button.action()">
|
|
||||||
<ng-container *ngTemplateOutlet="template" />
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (button.routerLink) {
|
|
||||||
<a tuiCell [routerLink]="button.routerLink">
|
|
||||||
<ng-container *ngTemplateOutlet="template" />
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
|
|
||||||
<ng-template #template>
|
|
||||||
<tui-icon [icon]="button.icon" />
|
|
||||||
<div tuiTitle>
|
|
||||||
<strong>{{ button.title }}</strong>
|
|
||||||
<div tuiSubtitle>{{ button.description }}</div>
|
|
||||||
<ng-content />
|
|
||||||
</div>
|
|
||||||
@if (button.routerLink) {
|
|
||||||
<tui-icon icon="@tui.chevron-right" />
|
|
||||||
}
|
|
||||||
</ng-template>
|
|
||||||
`,
|
|
||||||
styles: `
|
|
||||||
:host {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
|
|
||||||
&:not(:last-child) {
|
|
||||||
box-shadow: 0 1px var(--tui-background-neutral-1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule, TuiIcon, TuiTitle, RouterLink, TuiCell],
|
|
||||||
})
|
|
||||||
export class SystemButtonComponent {
|
|
||||||
@Input({ required: true })
|
|
||||||
button!: SettingBtn
|
|
||||||
}
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import { CommonModule } from '@angular/common'
|
|
||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
|
||||||
import { toSignal } from '@angular/core/rxjs-interop'
|
|
||||||
import { TuiAlertService, TuiLoader, TuiButton } from '@taiga-ui/core'
|
|
||||||
import { PatchDB } from 'patch-db-client'
|
|
||||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
|
||||||
import { ClientStorageService } from 'src/app/services/client-storage.service'
|
|
||||||
import { SystemService } from '../system.service'
|
|
||||||
import { SystemSyncComponent } from './sync.component'
|
|
||||||
import { SystemButtonComponent } from './button.component'
|
|
||||||
import { SystemUpdateComponent } from './update.component'
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'system-menu',
|
|
||||||
template: `
|
|
||||||
@if (data(); as server) {
|
|
||||||
@if (!server.ntpSynced) {
|
|
||||||
<system-sync />
|
|
||||||
}
|
|
||||||
|
|
||||||
@for (cat of service.settings | keyvalue: asIsOrder; track $index) {
|
|
||||||
<section class="g-card">
|
|
||||||
<header (click)="addClick(cat.key)">{{ cat.key }}</header>
|
|
||||||
@if (cat.key === 'General') {
|
|
||||||
<system-update [updated]="server.statusInfo.updated" />
|
|
||||||
}
|
|
||||||
|
|
||||||
@for (btn of cat.value; track $index) {
|
|
||||||
<system-button [button]="btn">
|
|
||||||
<!-- // @TODO 041
|
|
||||||
<div
|
|
||||||
*ngIf="btn.title === 'Outbound Proxy'"
|
|
||||||
tuiSubtitle
|
|
||||||
[style.color]="
|
|
||||||
!server.network.outboundProxy
|
|
||||||
? 'var(--tui-status-warning)'
|
|
||||||
: 'var(--tui-status-positive)'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ server.network.outboundProxy || 'None' }}
|
|
||||||
</div> -->
|
|
||||||
</system-button>
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
<tui-loader
|
|
||||||
textContent="Connecting to server"
|
|
||||||
[style.margin-top.rem]="10"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
standalone: true,
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
imports: [
|
|
||||||
CommonModule,
|
|
||||||
TuiLoader,
|
|
||||||
SystemSyncComponent,
|
|
||||||
SystemButtonComponent,
|
|
||||||
SystemUpdateComponent,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export class SystemMenuComponent {
|
|
||||||
private readonly clientStorageService = inject(ClientStorageService)
|
|
||||||
private readonly alerts = inject(TuiAlertService)
|
|
||||||
|
|
||||||
readonly service = inject(SystemService)
|
|
||||||
readonly data = toSignal(
|
|
||||||
inject<PatchDB<DataModel>>(PatchDB).watch$('serverInfo'),
|
|
||||||
)
|
|
||||||
|
|
||||||
manageClicks = 0
|
|
||||||
powerClicks = 0
|
|
||||||
|
|
||||||
addClick(title: string) {
|
|
||||||
switch (title) {
|
|
||||||
case 'Security':
|
|
||||||
this.addSecurityClick()
|
|
||||||
break
|
|
||||||
case 'Power':
|
|
||||||
this.addPowerClick()
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
asIsOrder() {
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
private addSecurityClick() {
|
|
||||||
this.manageClicks++
|
|
||||||
|
|
||||||
if (this.manageClicks === 5) {
|
|
||||||
this.manageClicks = 0
|
|
||||||
this.alerts
|
|
||||||
.open(
|
|
||||||
this.clientStorageService.toggleShowDevTools()
|
|
||||||
? 'Dev tools unlocked'
|
|
||||||
: 'Dev tools hidden',
|
|
||||||
)
|
|
||||||
.subscribe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private addPowerClick() {
|
|
||||||
this.powerClicks++
|
|
||||||
if (this.powerClicks === 5) {
|
|
||||||
this.powerClicks = 0
|
|
||||||
this.clientStorageService.toggleShowDiskRepair()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import { TuiCell } from '@taiga-ui/layout'
|
|
||||||
import { TuiTitle, TuiButton, TuiNotification } from '@taiga-ui/core'
|
|
||||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'system-sync',
|
|
||||||
template: `
|
|
||||||
<tui-notification appearance="warning">
|
|
||||||
<div tuiCell [style.padding]="0">
|
|
||||||
<div tuiTitle>
|
|
||||||
Clock sync failure
|
|
||||||
<div tuiSubtitle>
|
|
||||||
This will cause connectivity issues. Refer to the StartOS docs to
|
|
||||||
resolve the issue.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<a
|
|
||||||
tuiButton
|
|
||||||
appearance="secondary-grayscale"
|
|
||||||
iconEnd="@tui.external-link"
|
|
||||||
href="https://docs.start9.com/0.3.5.x/support/common-issues#clock-sync-failure"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
>
|
|
||||||
Open Docs
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</tui-notification>
|
|
||||||
`,
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
standalone: true,
|
|
||||||
imports: [TuiButton, TuiCell, TuiNotification, TuiTitle],
|
|
||||||
})
|
|
||||||
export class SystemSyncComponent {}
|
|
||||||
@@ -1,109 +0,0 @@
|
|||||||
import {
|
|
||||||
ChangeDetectionStrategy,
|
|
||||||
Component,
|
|
||||||
inject,
|
|
||||||
Input,
|
|
||||||
} from '@angular/core'
|
|
||||||
import { CommonModule } from '@angular/common'
|
|
||||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
|
||||||
import { TuiDialogService, TuiIcon, TuiTitle } from '@taiga-ui/core'
|
|
||||||
import { TuiCell } from '@taiga-ui/layout'
|
|
||||||
import { EOSService } from 'src/app/services/eos.service'
|
|
||||||
import { UPDATE } from '../modals/update.component'
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'system-update',
|
|
||||||
template: `
|
|
||||||
<button
|
|
||||||
tuiCell
|
|
||||||
[disabled]="service.updatingOrBackingUp$ | async"
|
|
||||||
(click)="onClick()"
|
|
||||||
>
|
|
||||||
<tui-icon icon="@tui.cloud-download" />
|
|
||||||
<div tuiTitle>
|
|
||||||
<strong>Software Update</strong>
|
|
||||||
<div tuiSubtitle>Get the latest version of StartOS</div>
|
|
||||||
@if (updated) {
|
|
||||||
<div tuiSubtitle class="g-warning">
|
|
||||||
Update Complete. Restart to apply changes
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
@if (service.showUpdate$ | async) {
|
|
||||||
<div tuiSubtitle class="g-positive">
|
|
||||||
<tui-icon class="small" icon="@tui.zap" />
|
|
||||||
Update Available
|
|
||||||
</div>
|
|
||||||
} @else {
|
|
||||||
<div tuiSubtitle class="g-info">
|
|
||||||
<tui-icon class="small" icon="@tui.rotate-cw" />
|
|
||||||
Check for updates
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
`,
|
|
||||||
styles: `
|
|
||||||
:host {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
box-shadow: 0 1px var(--tui-background-neutral-1);
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.small {
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
`,
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
standalone: true,
|
|
||||||
imports: [CommonModule, TuiIcon, TuiTitle, TuiCell],
|
|
||||||
})
|
|
||||||
export class SystemUpdateComponent {
|
|
||||||
private readonly dialogs = inject(TuiDialogService)
|
|
||||||
private readonly loader = inject(LoadingService)
|
|
||||||
private readonly errorService = inject(ErrorService)
|
|
||||||
|
|
||||||
readonly service = inject(EOSService)
|
|
||||||
|
|
||||||
@Input()
|
|
||||||
updated = false
|
|
||||||
|
|
||||||
onClick() {
|
|
||||||
this.service.updateAvailable$.value ? this.update() : this.check()
|
|
||||||
}
|
|
||||||
|
|
||||||
private update() {
|
|
||||||
this.dialogs.open(UPDATE).subscribe()
|
|
||||||
}
|
|
||||||
|
|
||||||
private async check(): Promise<void> {
|
|
||||||
const loader = this.loader.open('Checking for updates').subscribe()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.service.loadEos()
|
|
||||||
|
|
||||||
if (this.service.updateAvailable$.value) {
|
|
||||||
this.update()
|
|
||||||
} else {
|
|
||||||
this.showLatest()
|
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
this.errorService.handleError(e)
|
|
||||||
} finally {
|
|
||||||
loader.unsubscribe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private showLatest() {
|
|
||||||
this.dialogs
|
|
||||||
.open('You are on the latest version of StartOS.', {
|
|
||||||
label: 'Up to date!',
|
|
||||||
size: 's',
|
|
||||||
})
|
|
||||||
.subscribe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -12,9 +12,9 @@ import { TuiLink, TuiNotification } from '@taiga-ui/core'
|
|||||||
href="https://docs.start9.com/latest/user-manual/acme"
|
href="https://docs.start9.com/latest/user-manual/acme"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
iconEnd="@tui.external-link"
|
||||||
View instructions
|
[textContent]="'View instructions'"
|
||||||
</a>
|
></a>
|
||||||
</tui-notification>
|
</tui-notification>
|
||||||
`,
|
`,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
import { Component, inject } from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import * as argon2 from '@start9labs/argon2'
|
||||||
|
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||||
|
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
|
||||||
|
import { TuiButton, TuiGroup, TuiLoader, TuiTitle } from '@taiga-ui/core'
|
||||||
|
import { TuiBlock, TuiCheckbox } from '@taiga-ui/kit'
|
||||||
|
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||||
|
import { PatchDB } from 'patch-db-client'
|
||||||
|
import { firstValueFrom, map } from 'rxjs'
|
||||||
|
import { PROMPT } from 'src/app/routes/portal/modals/prompt.component'
|
||||||
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
|
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||||
|
import { getManifest } from 'src/app/utils/get-package-data'
|
||||||
|
import { getServerInfo } from 'src/app/utils/get-server-info'
|
||||||
|
import { verifyPassword } from 'src/app/utils/verify-password'
|
||||||
|
import { PASSWORD_OPTIONS } from './backup.const'
|
||||||
|
import { BackupService } from './backup.service'
|
||||||
|
import { BackupContext } from './backup.types'
|
||||||
|
|
||||||
|
interface Package {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
icon: string
|
||||||
|
disabled: boolean
|
||||||
|
checked: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
<div tuiGroup orientation="vertical" [collapsed]="true">
|
||||||
|
@if (pkgs) {
|
||||||
|
@for (pkg of pkgs; track $index) {
|
||||||
|
<label tuiBlock="m">
|
||||||
|
<img alt="" [src]="pkg.icon" />
|
||||||
|
<span tuiTitle>{{ pkg.title }}</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
tuiCheckbox
|
||||||
|
[disabled]="pkg.disabled"
|
||||||
|
[(ngModel)]="pkg.checked"
|
||||||
|
(ngModelChange)="handleChange()"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
} @empty {
|
||||||
|
No services installed!
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<tui-loader />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<footer class="g-buttons">
|
||||||
|
<button tuiButton appearance="flat-grayscale" (click)="toggleSelectAll()">
|
||||||
|
Toggle all
|
||||||
|
</button>
|
||||||
|
<button tuiButton [disabled]="!hasSelection" (click)="done()">
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
`,
|
||||||
|
styles: [
|
||||||
|
`
|
||||||
|
[tuiGroup] {
|
||||||
|
width: 100%;
|
||||||
|
margin: 1.5rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
[tuiBlock] {
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 2.5rem;
|
||||||
|
border-radius: 100%;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
TuiButton,
|
||||||
|
TuiGroup,
|
||||||
|
TuiLoader,
|
||||||
|
TuiBlock,
|
||||||
|
TuiCheckbox,
|
||||||
|
TuiTitle,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class BackupsBackupComponent {
|
||||||
|
private readonly dialogs = inject(TuiResponsiveDialogService)
|
||||||
|
private readonly loader = inject(LoadingService)
|
||||||
|
private readonly errorService = inject(ErrorService)
|
||||||
|
private readonly api = inject(ApiService)
|
||||||
|
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||||
|
private readonly service = inject(BackupService)
|
||||||
|
|
||||||
|
readonly context = injectContext<BackupContext>()
|
||||||
|
|
||||||
|
hasSelection = false
|
||||||
|
pkgs: readonly Package[] | null = null
|
||||||
|
|
||||||
|
async ngOnInit() {
|
||||||
|
this.pkgs = await firstValueFrom(
|
||||||
|
this.patch.watch$('packageData').pipe(
|
||||||
|
map(pkgs =>
|
||||||
|
Object.values(pkgs)
|
||||||
|
.map(pkg => {
|
||||||
|
const { id, title } = getManifest(pkg)
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
title,
|
||||||
|
icon: pkg.icon,
|
||||||
|
disabled: pkg.stateInfo.state !== 'installed',
|
||||||
|
checked: false,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.sort((a, b) =>
|
||||||
|
b.title.toLowerCase() > a.title.toLowerCase() ? -1 : 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async done() {
|
||||||
|
const { passwordHash, id } = await getServerInfo(this.patch)
|
||||||
|
const { entry } = this.context.data
|
||||||
|
|
||||||
|
this.dialogs
|
||||||
|
.open<string>(PROMPT, PASSWORD_OPTIONS)
|
||||||
|
.pipe(verifyPassword(passwordHash, e => this.errorService.handleError(e)))
|
||||||
|
.subscribe(async password => {
|
||||||
|
// first time backup
|
||||||
|
if (!this.service.hasThisBackup(entry, id)) {
|
||||||
|
this.createBackup(password)
|
||||||
|
// existing backup
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
argon2.verify(entry.startOs[id].passwordHash!, password)
|
||||||
|
await this.createBackup(password)
|
||||||
|
} catch {
|
||||||
|
this.oldPassword(password)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
handleChange() {
|
||||||
|
this.hasSelection = !!this.pkgs?.some(p => p.checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleSelectAll() {
|
||||||
|
this.pkgs?.forEach(p => (p.checked = !this.hasSelection && !p.disabled))
|
||||||
|
this.hasSelection = !this.hasSelection
|
||||||
|
}
|
||||||
|
|
||||||
|
private async oldPassword(password: string) {
|
||||||
|
const { id } = await getServerInfo(this.patch)
|
||||||
|
const { passwordHash } = this.context.data.entry.startOs[id]
|
||||||
|
|
||||||
|
this.dialogs
|
||||||
|
.open<string>(PROMPT, PASSWORD_OPTIONS)
|
||||||
|
.pipe(verifyPassword(passwordHash, e => this.errorService.handleError(e)))
|
||||||
|
.subscribe(oldPassword => this.createBackup(password, oldPassword))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async createBackup(
|
||||||
|
password: string,
|
||||||
|
oldPassword: string | null = null,
|
||||||
|
) {
|
||||||
|
const loader = this.loader.open('Beginning backup...').subscribe()
|
||||||
|
const packageIds = this.pkgs?.filter(p => p.checked).map(p => p.id) || []
|
||||||
|
const params = {
|
||||||
|
targetId: this.context.data.id,
|
||||||
|
packageIds,
|
||||||
|
oldPassword,
|
||||||
|
password,
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.api.createBackup(params)
|
||||||
|
this.context.$implicit.complete()
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errorService.handleError(e)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BACKUP = new PolymorpheusComponent(BackupsBackupComponent)
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { TuiDialogOptions } from '@taiga-ui/core'
|
||||||
|
import { PromptOptions } from 'src/app/routes/portal/modals/prompt.component'
|
||||||
|
|
||||||
|
export const PASSWORD_OPTIONS: Partial<TuiDialogOptions<PromptOptions>> = {
|
||||||
|
label: 'Master Password Needed',
|
||||||
|
data: {
|
||||||
|
message: 'Enter your master password to encrypt this backup.',
|
||||||
|
label: 'Master Password',
|
||||||
|
placeholder: 'Enter master password',
|
||||||
|
useMask: true,
|
||||||
|
buttonText: 'Create Backup',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OLD_OPTIONS: Partial<TuiDialogOptions<PromptOptions>> = {
|
||||||
|
label: 'Original Password Needed',
|
||||||
|
data: {
|
||||||
|
message:
|
||||||
|
'This backup was created with a different password. Enter the ORIGINAL password that was used to encrypt this backup.',
|
||||||
|
label: 'Original Password',
|
||||||
|
placeholder: 'Enter original password',
|
||||||
|
useMask: true,
|
||||||
|
buttonText: 'Create Backup',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RESTORE_OPTIONS: Partial<TuiDialogOptions<PromptOptions>> = {
|
||||||
|
label: 'Password Required',
|
||||||
|
data: {
|
||||||
|
message: `Enter the master password that was used to encrypt this backup. On the next screen, you will select the individual services you want to restore.`,
|
||||||
|
label: 'Master Password',
|
||||||
|
placeholder: 'Enter master password',
|
||||||
|
useMask: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { inject, Injectable, signal } from '@angular/core'
|
||||||
|
import { ErrorService, getErrorMessage } from '@start9labs/shared'
|
||||||
|
import { Version } from '@start9labs/start-sdk'
|
||||||
|
import {
|
||||||
|
BackupTarget,
|
||||||
|
CifsBackupTarget,
|
||||||
|
DiskBackupTarget,
|
||||||
|
} from 'src/app/services/api/api.types'
|
||||||
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
|
|
||||||
|
export interface MappedBackupTarget<T> {
|
||||||
|
id: string
|
||||||
|
hasAnyBackup: boolean
|
||||||
|
entry: T
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable({
|
||||||
|
providedIn: 'root',
|
||||||
|
})
|
||||||
|
export class BackupService {
|
||||||
|
private readonly api = inject(ApiService)
|
||||||
|
private readonly errorService = inject(ErrorService)
|
||||||
|
|
||||||
|
readonly cifs = signal<MappedBackupTarget<CifsBackupTarget>[]>([])
|
||||||
|
readonly drives = signal<MappedBackupTarget<DiskBackupTarget>[]>([])
|
||||||
|
readonly loading = signal(true)
|
||||||
|
|
||||||
|
async getBackupTargets(): Promise<void> {
|
||||||
|
this.loading.set(true)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const targets = await this.api.getBackupTargets({})
|
||||||
|
|
||||||
|
this.cifs.set(
|
||||||
|
Object.entries(targets)
|
||||||
|
.filter(([_, target]) => target.type === 'cifs')
|
||||||
|
.map(([id, cifs]) => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
hasAnyBackup: this.hasAnyBackup(cifs),
|
||||||
|
entry: cifs as CifsBackupTarget,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
this.drives.set(
|
||||||
|
Object.entries(targets)
|
||||||
|
.filter(([_, target]) => target.type === 'disk')
|
||||||
|
.map(([id, drive]) => {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
hasAnyBackup: this.hasAnyBackup(drive),
|
||||||
|
entry: drive as DiskBackupTarget,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errorService.handleError(getErrorMessage(e))
|
||||||
|
} finally {
|
||||||
|
this.loading.set(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hasAnyBackup({ startOs }: BackupTarget): boolean {
|
||||||
|
return Object.values(startOs).some(
|
||||||
|
s => Version.parse(s.version).compare(Version.parse('0.3.6')) !== 'less',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasThisBackup({ startOs }: BackupTarget, id: string): boolean {
|
||||||
|
return (
|
||||||
|
startOs[id] &&
|
||||||
|
Version.parse(startOs[id].version).compare(Version.parse('0.3.6')) !==
|
||||||
|
'less'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { TuiDialogContext } from '@taiga-ui/core'
|
||||||
|
import {
|
||||||
|
BackupInfo,
|
||||||
|
CifsBackupTarget,
|
||||||
|
DiskBackupTarget,
|
||||||
|
PackageBackupInfo,
|
||||||
|
} from 'src/app/services/api/api.types'
|
||||||
|
import { MappedBackupTarget } from './backup.service'
|
||||||
|
|
||||||
|
export type BackupContext = TuiDialogContext<
|
||||||
|
void,
|
||||||
|
MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>
|
||||||
|
>
|
||||||
|
|
||||||
|
export interface RecoverOption extends PackageBackupInfo {
|
||||||
|
id: string
|
||||||
|
checked: boolean
|
||||||
|
installed: boolean
|
||||||
|
newerOs: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RecoverData {
|
||||||
|
targetId: string
|
||||||
|
serverId: string
|
||||||
|
backupInfo: BackupInfo
|
||||||
|
password: string
|
||||||
|
}
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import { AsyncPipe } from '@angular/common'
|
||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
OnInit,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { ActivatedRoute, RouterLink } from '@angular/router'
|
||||||
|
import { UnitConversionPipesModule } from '@start9labs/shared'
|
||||||
|
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
|
||||||
|
import { TuiButton, TuiLink, TuiLoader } from '@taiga-ui/core'
|
||||||
|
import { BACKUP } from 'src/app/routes/portal/routes/system/routes/backups/backup.component'
|
||||||
|
import {
|
||||||
|
CifsBackupTarget,
|
||||||
|
DiskBackupTarget,
|
||||||
|
} from 'src/app/services/api/api.types'
|
||||||
|
import { EOSService } from 'src/app/services/eos.service'
|
||||||
|
import { TitleDirective } from 'src/app/services/title.service'
|
||||||
|
import { BackupService, MappedBackupTarget } from './backup.service'
|
||||||
|
import { BackupNetworkComponent } from './network.component'
|
||||||
|
import { BackupPhysicalComponent } from './physical.component'
|
||||||
|
import { BackupProgressComponent } from './progress.component'
|
||||||
|
import { BACKUP_RESTORE } from './restore.component'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
<ng-container *title>
|
||||||
|
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">Back</a>
|
||||||
|
{{ type === 'create' ? 'Create Backup' : 'Restore Backup' }}
|
||||||
|
</ng-container>
|
||||||
|
|
||||||
|
@if (type === 'create' && (eos.backingUp$ | async)) {
|
||||||
|
<section backupProgress></section>
|
||||||
|
} @else {
|
||||||
|
@if (service.loading()) {
|
||||||
|
<tui-loader
|
||||||
|
textContent="Fetching backups"
|
||||||
|
size="l"
|
||||||
|
[style.height.rem]="20"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<section (networkFolders)="onTarget($event)">
|
||||||
|
{{ text }}
|
||||||
|
a folder on another computer that is connected to the same network as
|
||||||
|
your Start9 server. View the
|
||||||
|
<a
|
||||||
|
tuiLink
|
||||||
|
href="https://docs.start9.com/0.3.5.x/user-manual/backups/backup-create#network-folder"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
iconEnd="@tui.external-link"
|
||||||
|
[textContent]="'Instructions'"
|
||||||
|
></a>
|
||||||
|
</section>
|
||||||
|
<section (physicalFolders)="onTarget($event)">
|
||||||
|
{{ text }}
|
||||||
|
a physical drive that is plugged directly into your Start9 Server.
|
||||||
|
View the
|
||||||
|
<a
|
||||||
|
tuiLink
|
||||||
|
href="https://docs.start9.com/0.3.5.x/user-manual/backups/backup-create#physical-drive"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
iconEnd="@tui.external-link"
|
||||||
|
[textContent]="'Instructions'"
|
||||||
|
></a>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
RouterLink,
|
||||||
|
TuiButton,
|
||||||
|
TuiLoader,
|
||||||
|
TuiLink,
|
||||||
|
TitleDirective,
|
||||||
|
UnitConversionPipesModule,
|
||||||
|
BackupNetworkComponent,
|
||||||
|
BackupPhysicalComponent,
|
||||||
|
AsyncPipe,
|
||||||
|
BackupProgressComponent,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export default class SystemBackupComponent implements OnInit {
|
||||||
|
readonly dialogs = inject(TuiResponsiveDialogService)
|
||||||
|
readonly type = inject(ActivatedRoute).snapshot.data['type']
|
||||||
|
readonly service = inject(BackupService)
|
||||||
|
readonly eos = inject(EOSService)
|
||||||
|
|
||||||
|
get text() {
|
||||||
|
return this.type === 'create'
|
||||||
|
? 'Backup server to'
|
||||||
|
: 'Restore your services from'
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.service.getBackupTargets()
|
||||||
|
}
|
||||||
|
|
||||||
|
onTarget(target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>) {
|
||||||
|
const component = this.type === 'create' ? BACKUP : BACKUP_RESTORE
|
||||||
|
const label =
|
||||||
|
this.type === 'create'
|
||||||
|
? 'Select Services to Back Up'
|
||||||
|
: 'Select server backup'
|
||||||
|
|
||||||
|
this.dialogs.open(component, { label, data: target }).subscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
output,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { ActivatedRoute } from '@angular/router'
|
||||||
|
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||||
|
import { ISB } from '@start9labs/start-sdk'
|
||||||
|
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
|
||||||
|
import { TuiAlertService, TuiButton, TuiIcon, TuiTitle } from '@taiga-ui/core'
|
||||||
|
import { TUI_CONFIRM, TuiTooltip } from '@taiga-ui/kit'
|
||||||
|
import { TuiCell } from '@taiga-ui/layout'
|
||||||
|
import { filter } from 'rxjs'
|
||||||
|
import { FormComponent } from 'src/app/routes/portal/components/form.component'
|
||||||
|
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
|
||||||
|
import { CifsBackupTarget, RR } from 'src/app/services/api/api.types'
|
||||||
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
|
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||||
|
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||||
|
import { BackupService, MappedBackupTarget } from './backup.service'
|
||||||
|
import { BackupStatusComponent } from './status.component'
|
||||||
|
|
||||||
|
const ERROR =
|
||||||
|
'Ensure (1) target computer is connected to the same LAN as your Start9 Server, (2) target folder is being shared, and (3) hostname, path, and credentials are accurate.'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
selector: '[networkFolders]',
|
||||||
|
template: `
|
||||||
|
<header>
|
||||||
|
Network Folders
|
||||||
|
<tui-icon [tuiTooltip]="cifs" />
|
||||||
|
<ng-template #cifs><ng-content /></ng-template>
|
||||||
|
<button tuiButton size="s" iconStart="@tui.plus" (click)="add()">
|
||||||
|
Open New
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
@for (target of service.cifs(); track $index) {
|
||||||
|
<button tuiCell (click)="select(target)">
|
||||||
|
<tui-icon icon="@tui.folder-open" />
|
||||||
|
<span tuiTitle>
|
||||||
|
<strong>{{ target.entry.path.split('/').pop() }}</strong>
|
||||||
|
@if (target.entry.mountable) {
|
||||||
|
<span tuiSubtitle [backupStatus]="target.hasAnyBackup"></span>
|
||||||
|
} @else {
|
||||||
|
<span tuiSubtitle>
|
||||||
|
<tui-icon
|
||||||
|
icon="@tui.signal-high"
|
||||||
|
class="g-negative"
|
||||||
|
[style.font-size.rem]="1"
|
||||||
|
/>
|
||||||
|
Unable to connect
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
<span tuiSubtitle>
|
||||||
|
<b>Hostname:</b>
|
||||||
|
{{ target.entry.hostname }}
|
||||||
|
</span>
|
||||||
|
<span tuiSubtitle>
|
||||||
|
<b>Path:</b>
|
||||||
|
{{ target.entry.path }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
tuiIconButton
|
||||||
|
appearance="action-destructive"
|
||||||
|
iconStart="@tui.trash"
|
||||||
|
(click.stop)="forget(target, $index)"
|
||||||
|
>
|
||||||
|
Forget
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
tuiIconButton
|
||||||
|
appearance="icon"
|
||||||
|
size="xs"
|
||||||
|
iconStart="@tui.pencil"
|
||||||
|
(click.stop)="edit(target)"
|
||||||
|
>
|
||||||
|
Edit
|
||||||
|
</button>
|
||||||
|
</button>
|
||||||
|
} @empty {
|
||||||
|
<app-placeholder icon="@tui.folder-x">No network folders</app-placeholder>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
[tuiButton] {
|
||||||
|
margin-inline-start: auto;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
host: { class: 'g-card' },
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [
|
||||||
|
TuiButton,
|
||||||
|
TuiCell,
|
||||||
|
TuiIcon,
|
||||||
|
TuiTitle,
|
||||||
|
TuiTooltip,
|
||||||
|
PlaceholderComponent,
|
||||||
|
BackupStatusComponent,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class BackupNetworkComponent {
|
||||||
|
private readonly dialogs = inject(TuiResponsiveDialogService)
|
||||||
|
private readonly alerts = inject(TuiAlertService)
|
||||||
|
private readonly formDialog = inject(FormDialogService)
|
||||||
|
private readonly api = inject(ApiService)
|
||||||
|
private readonly loader = inject(LoadingService)
|
||||||
|
private readonly errorService = inject(ErrorService)
|
||||||
|
private readonly type = inject(ActivatedRoute).snapshot.data['type']
|
||||||
|
|
||||||
|
readonly service = inject(BackupService)
|
||||||
|
readonly networkFolders = output<MappedBackupTarget<CifsBackupTarget>>()
|
||||||
|
|
||||||
|
select(target: MappedBackupTarget<CifsBackupTarget>) {
|
||||||
|
if (!target.entry.mountable) {
|
||||||
|
this.alerts
|
||||||
|
.open(ERROR, {
|
||||||
|
appearance: 'negative',
|
||||||
|
label: 'Unable to connect',
|
||||||
|
autoClose: 0,
|
||||||
|
})
|
||||||
|
.subscribe()
|
||||||
|
} else if (this.type === 'restore' && !target.hasAnyBackup) {
|
||||||
|
this.alerts
|
||||||
|
.open('Network Folder does not contain a valid backup', {
|
||||||
|
appearance: 'negative',
|
||||||
|
})
|
||||||
|
.subscribe()
|
||||||
|
} else {
|
||||||
|
this.networkFolders.emit(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async add() {
|
||||||
|
this.formDialog.open(FormComponent, {
|
||||||
|
label: 'New Network Folder',
|
||||||
|
data: {
|
||||||
|
spec: await configBuilderToSpec(cifsSpec),
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: 'Execute',
|
||||||
|
handler: (value: RR.AddBackupTargetReq) => this.addTarget(value),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async edit(target: MappedBackupTarget<CifsBackupTarget>) {
|
||||||
|
this.formDialog.open(FormComponent, {
|
||||||
|
label: 'Update Network Folder',
|
||||||
|
data: {
|
||||||
|
spec: await configBuilderToSpec(cifsSpec),
|
||||||
|
buttons: [
|
||||||
|
{
|
||||||
|
text: 'Execute',
|
||||||
|
handler: async (value: RR.AddBackupTargetReq) => {
|
||||||
|
const loader = this.loader
|
||||||
|
.open('Testing connectivity to shared folder...')
|
||||||
|
.subscribe()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await this.api.updateBackupTarget({
|
||||||
|
id: target.id,
|
||||||
|
...value,
|
||||||
|
})
|
||||||
|
|
||||||
|
target.entry = Object.values(res)[0]
|
||||||
|
this.service.cifs.update(cifs => [...cifs])
|
||||||
|
return true
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errorService.handleError(e)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
value: { ...target.entry },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
forget({ id }: MappedBackupTarget<CifsBackupTarget>, index: number) {
|
||||||
|
this.dialogs
|
||||||
|
.open(TUI_CONFIRM, { label: 'Are you sure?', size: 's' })
|
||||||
|
.pipe(filter(Boolean))
|
||||||
|
.subscribe(async () => {
|
||||||
|
const loader = this.loader.open('Removing...').subscribe()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.api.removeBackupTarget({ id })
|
||||||
|
this.service.cifs.update(cifs => cifs.filter((_, i) => i !== index))
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errorService.handleError(e)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async addTarget(v: RR.AddBackupTargetReq): Promise<boolean> {
|
||||||
|
const loader = this.loader
|
||||||
|
.open('Testing connectivity to shared folder...')
|
||||||
|
.subscribe()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [[id, entry]] = Object.entries(await this.api.addBackupTarget(v))
|
||||||
|
const hasAnyBackup = this.service.hasAnyBackup(entry)
|
||||||
|
const added = { id, entry, hasAnyBackup }
|
||||||
|
this.service.cifs.update(cifs => [added, ...cifs])
|
||||||
|
return true
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errorService.handleError(e)
|
||||||
|
return false
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cifsSpec = ISB.InputSpec.of({
|
||||||
|
hostname: ISB.Value.text({
|
||||||
|
name: 'Hostname',
|
||||||
|
description:
|
||||||
|
'The hostname of your target device on the Local Area Network.',
|
||||||
|
warning: null,
|
||||||
|
placeholder: `e.g. 'My Computer' OR 'my-computer.local'`,
|
||||||
|
required: true,
|
||||||
|
default: null,
|
||||||
|
patterns: [],
|
||||||
|
}),
|
||||||
|
path: ISB.Value.text({
|
||||||
|
name: 'Path',
|
||||||
|
description: `On Windows, this is the fully qualified path to the shared folder, (e.g. /Desktop/my-folder).\n\n On Linux and Mac, this is the literal name of the shared folder (e.g. my-shared-folder).`,
|
||||||
|
placeholder: 'e.g. my-shared-folder or /Desktop/my-folder',
|
||||||
|
required: true,
|
||||||
|
default: null,
|
||||||
|
}),
|
||||||
|
username: ISB.Value.text({
|
||||||
|
name: 'Username',
|
||||||
|
description: `On Linux, this is the samba username you created when sharing the folder.\n\n On Mac and Windows, this is the username of the user who is sharing the folder.`,
|
||||||
|
required: true,
|
||||||
|
default: null,
|
||||||
|
placeholder: 'My Network Folder',
|
||||||
|
}),
|
||||||
|
password: ISB.Value.text({
|
||||||
|
name: 'Password',
|
||||||
|
description: `On Linux, this is the samba password you created when sharing the folder.\n\n On Mac and Windows, this is the password of the user who is sharing the folder.`,
|
||||||
|
required: false,
|
||||||
|
default: null,
|
||||||
|
masked: true,
|
||||||
|
placeholder: 'My Network Folder',
|
||||||
|
}),
|
||||||
|
})
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
output,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { ActivatedRoute } from '@angular/router'
|
||||||
|
import { UnitConversionPipesModule } from '@start9labs/shared'
|
||||||
|
import {
|
||||||
|
TuiAlertService,
|
||||||
|
TuiButton,
|
||||||
|
TuiIcon,
|
||||||
|
TuiNotification,
|
||||||
|
TuiTitle,
|
||||||
|
} from '@taiga-ui/core'
|
||||||
|
import { TuiTooltip } from '@taiga-ui/kit'
|
||||||
|
import { TuiCell } from '@taiga-ui/layout'
|
||||||
|
import { PlaceholderComponent } from 'src/app/routes/portal/components/placeholder.component'
|
||||||
|
import { DiskBackupTarget } from 'src/app/services/api/api.types'
|
||||||
|
import { BackupService, MappedBackupTarget } from './backup.service'
|
||||||
|
import { BackupStatusComponent } from './status.component'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
selector: '[physicalFolders]',
|
||||||
|
template: `
|
||||||
|
<header>
|
||||||
|
Physical Drives
|
||||||
|
<tui-icon [tuiTooltip]="drives" />
|
||||||
|
<ng-template #drives><ng-content /></ng-template>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<tui-notification appearance="warning">
|
||||||
|
Warning. Do not use this option if you are using a Raspberry Pi with an
|
||||||
|
external SSD. The Raspberry Pi does not support more than one external
|
||||||
|
drive without additional power and can cause data corruption.
|
||||||
|
</tui-notification>
|
||||||
|
|
||||||
|
@for (target of service.drives(); track $index) {
|
||||||
|
<button tuiCell (click)="select(target)">
|
||||||
|
<tui-icon icon="@tui.save" />
|
||||||
|
<span tuiTitle>
|
||||||
|
<strong>{{ target.entry.label || target.entry.logicalname }}</strong>
|
||||||
|
<span tuiSubtitle [backupStatus]="target.hasAnyBackup"></span>
|
||||||
|
<span tuiSubtitle>
|
||||||
|
{{ target.entry.vendor || 'Unknown Vendor' }} -
|
||||||
|
{{ target.entry.model || 'Unknown Model' }}
|
||||||
|
</span>
|
||||||
|
<span tuiSubtitle>
|
||||||
|
<b>Capacity:</b>
|
||||||
|
{{ target.entry.capacity | convertBytes }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
} @empty {
|
||||||
|
<app-placeholder icon="@tui.save-off">
|
||||||
|
No drives detected
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
iconStart="@tui.refresh-cw"
|
||||||
|
(click)="service.getBackupTargets()"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</app-placeholder>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
tui-notification {
|
||||||
|
margin: 0.5rem 0 0.75rem;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
host: { class: 'g-card' },
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [
|
||||||
|
TuiButton,
|
||||||
|
TuiCell,
|
||||||
|
TuiIcon,
|
||||||
|
TuiTitle,
|
||||||
|
TuiTooltip,
|
||||||
|
TuiNotification,
|
||||||
|
UnitConversionPipesModule,
|
||||||
|
PlaceholderComponent,
|
||||||
|
BackupStatusComponent,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class BackupPhysicalComponent {
|
||||||
|
private readonly alerts = inject(TuiAlertService)
|
||||||
|
private readonly type = inject(ActivatedRoute).snapshot.data['type']
|
||||||
|
|
||||||
|
readonly service = inject(BackupService)
|
||||||
|
readonly physicalFolders = output<MappedBackupTarget<DiskBackupTarget>>()
|
||||||
|
|
||||||
|
select(target: MappedBackupTarget<DiskBackupTarget>) {
|
||||||
|
if (this.type === 'restore' && !target.hasAnyBackup) {
|
||||||
|
this.alerts
|
||||||
|
.open('Drive partition does not contain a valid backup', {
|
||||||
|
appearance: 'negative',
|
||||||
|
})
|
||||||
|
.subscribe()
|
||||||
|
} else {
|
||||||
|
this.physicalFolders.emit(target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { AsyncPipe, KeyValuePipe } from '@angular/common'
|
||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop'
|
||||||
|
import { TuiMapperPipe } from '@taiga-ui/cdk'
|
||||||
|
import { TuiIcon, TuiLoader, TuiTitle } from '@taiga-ui/core'
|
||||||
|
import { TuiAvatar } from '@taiga-ui/kit'
|
||||||
|
import { TuiCell } from '@taiga-ui/layout'
|
||||||
|
import { PatchDB } from 'patch-db-client'
|
||||||
|
import { take } from 'rxjs'
|
||||||
|
import { ToManifestPipe } from 'src/app/routes/portal/pipes/to-manifest'
|
||||||
|
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
selector: '[backupProgress]',
|
||||||
|
template: `
|
||||||
|
<header>Backup Progress</header>
|
||||||
|
@for (pkg of pkgs() | keyvalue; track $index) {
|
||||||
|
@if (backupProgress()?.[pkg.key]; as progress) {
|
||||||
|
<div tuiCell>
|
||||||
|
<tui-avatar>
|
||||||
|
<img alt="" [src]="pkg.value.icon" />
|
||||||
|
</tui-avatar>
|
||||||
|
<span tuiTitle>
|
||||||
|
{{ (pkg.value | toManifest).title }}
|
||||||
|
<span tuiSubtitle>
|
||||||
|
@if (progress.complete) {
|
||||||
|
<tui-icon icon="@tui.check" class="g-positive" />
|
||||||
|
Complete
|
||||||
|
} @else {
|
||||||
|
@if ((pkg.key | tuiMapper: toStatus | async) === 'backingUp') {
|
||||||
|
<tui-loader size="s" />
|
||||||
|
Backing up
|
||||||
|
} @else {
|
||||||
|
Waiting...
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
host: { class: 'g-card' },
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [
|
||||||
|
KeyValuePipe,
|
||||||
|
AsyncPipe,
|
||||||
|
TuiCell,
|
||||||
|
TuiAvatar,
|
||||||
|
TuiTitle,
|
||||||
|
TuiIcon,
|
||||||
|
TuiLoader,
|
||||||
|
TuiMapperPipe,
|
||||||
|
ToManifestPipe,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class BackupProgressComponent {
|
||||||
|
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||||
|
|
||||||
|
readonly pkgs = toSignal(this.patch.watch$('packageData').pipe(take(1)))
|
||||||
|
readonly backupProgress = toSignal(
|
||||||
|
this.patch.watch$('serverInfo', 'statusInfo', 'backupProgress'),
|
||||||
|
)
|
||||||
|
|
||||||
|
readonly toStatus = (pkgId: string) =>
|
||||||
|
this.patch.watch$('packageData', pkgId, 'status', 'main')
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
import { CommonModule } from '@angular/common'
|
||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import { Router } from '@angular/router'
|
||||||
|
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||||
|
import { Version } from '@start9labs/start-sdk'
|
||||||
|
import { TuiMapperPipe } from '@taiga-ui/cdk'
|
||||||
|
import { TuiButton, TuiDialogContext, TuiGroup, TuiTitle } from '@taiga-ui/core'
|
||||||
|
import { TuiBlock, TuiCheckbox } from '@taiga-ui/kit'
|
||||||
|
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||||
|
import { PatchDB } from 'patch-db-client'
|
||||||
|
import { map, take } from 'rxjs'
|
||||||
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
|
import { ConfigService } from 'src/app/services/config.service'
|
||||||
|
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||||
|
import { RecoverData, RecoverOption } from './backup.types'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
@if (packageData(); as options) {
|
||||||
|
<div tuiGroup orientation="vertical" [collapsed]="true">
|
||||||
|
@for (option of options; track $index) {
|
||||||
|
<label tuiBlock>
|
||||||
|
<span tuiTitle>
|
||||||
|
<strong>{{ option.title }}</strong>
|
||||||
|
<span tuiSubtitle>Version {{ option.version }}</span>
|
||||||
|
<span tuiSubtitle>
|
||||||
|
Backup made: {{ option.timestamp | date: 'medium' }}
|
||||||
|
</span>
|
||||||
|
@if (option | tuiMapper: toMessage; as message) {
|
||||||
|
<span [style.color]="message.color">{{ message.text }}</span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
tuiCheckbox
|
||||||
|
[disabled]="option.installed || option.newerOs"
|
||||||
|
[(ngModel)]="option.checked"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="g-buttons">
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
[disabled]="isDisabled(options)"
|
||||||
|
(click)="restore(options)"
|
||||||
|
>
|
||||||
|
Restore Selected
|
||||||
|
</button>
|
||||||
|
</footer>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
[tuiGroup] {
|
||||||
|
width: 100%;
|
||||||
|
margin: 1.5rem 0 0;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
TuiButton,
|
||||||
|
TuiGroup,
|
||||||
|
TuiMapperPipe,
|
||||||
|
TuiCheckbox,
|
||||||
|
TuiBlock,
|
||||||
|
TuiTitle,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class BackupsRecoverComponent {
|
||||||
|
private readonly config = inject(ConfigService)
|
||||||
|
private readonly api = inject(ApiService)
|
||||||
|
private readonly loader = inject(LoadingService)
|
||||||
|
private readonly errorService = inject(ErrorService)
|
||||||
|
private readonly router = inject(Router)
|
||||||
|
private readonly context =
|
||||||
|
injectContext<TuiDialogContext<void, RecoverData>>()
|
||||||
|
|
||||||
|
readonly packageData = toSignal(
|
||||||
|
inject<PatchDB<DataModel>>(PatchDB)
|
||||||
|
.watch$('packageData')
|
||||||
|
.pipe(
|
||||||
|
take(1),
|
||||||
|
map(packageData => {
|
||||||
|
const backups = this.context.data.backupInfo.packageBackups
|
||||||
|
|
||||||
|
return Object.keys(backups)
|
||||||
|
.map(id => ({
|
||||||
|
...backups[id],
|
||||||
|
id,
|
||||||
|
installed: !!packageData[id],
|
||||||
|
checked: false,
|
||||||
|
newerOs:
|
||||||
|
Version.parse(backups[id].osVersion).compare(
|
||||||
|
Version.parse(this.config.version),
|
||||||
|
) === 'greater',
|
||||||
|
}))
|
||||||
|
.sort((a, b) =>
|
||||||
|
b.title.toLowerCase() > a.title.toLowerCase() ? -1 : 1,
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
readonly toMessage = ({ newerOs, installed, title }: RecoverOption) => {
|
||||||
|
if (newerOs) {
|
||||||
|
return {
|
||||||
|
text: `Unavailable. Backup was made on a newer version of StartOS.`,
|
||||||
|
color: 'var(--tui-status-negative)',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (installed) {
|
||||||
|
return {
|
||||||
|
text: `Unavailable. ${title} is already installed.`,
|
||||||
|
color: 'var(--tui-status-warning)',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
text: 'Ready to restore',
|
||||||
|
color: 'var(--tui-status-positive)',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isDisabled(options: RecoverOption[]): boolean {
|
||||||
|
return options.every(o => !o.checked)
|
||||||
|
}
|
||||||
|
|
||||||
|
async restore(options: RecoverOption[]): Promise<void> {
|
||||||
|
const ids = options.filter(({ checked }) => !!checked).map(({ id }) => id)
|
||||||
|
const { targetId, serverId, password } = this.context.data
|
||||||
|
const params = { ids, targetId, serverId, password }
|
||||||
|
const loader = this.loader.open('Initializing...').subscribe()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.api.restorePackages(params)
|
||||||
|
|
||||||
|
this.context.$implicit.complete()
|
||||||
|
this.router.navigate(['portal', 'services'])
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errorService.handleError(e)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const RECOVER = new PolymorpheusComponent(BackupsRecoverComponent)
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { DatePipe, KeyValuePipe } from '@angular/common'
|
||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||||
|
import {
|
||||||
|
ErrorService,
|
||||||
|
LoadingService,
|
||||||
|
StartOSDiskInfo,
|
||||||
|
} from '@start9labs/shared'
|
||||||
|
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
|
||||||
|
import { TuiTitle } from '@taiga-ui/core'
|
||||||
|
import { TuiCell } from '@taiga-ui/layout'
|
||||||
|
import { injectContext, PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||||
|
import { PROMPT } from 'src/app/routes/portal/modals/prompt.component'
|
||||||
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
|
import { verifyPassword } from 'src/app/utils/verify-password'
|
||||||
|
import { RESTORE_OPTIONS } from './backup.const'
|
||||||
|
import { BackupContext } from './backup.types'
|
||||||
|
import { RECOVER } from './recover.component'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
template: `
|
||||||
|
@for (server of target.entry.startOs | keyvalue; track $index) {
|
||||||
|
<button tuiCell (click)="onClick(server.key, server.value)">
|
||||||
|
<span tuiTitle>
|
||||||
|
<span tuiSubtitle>
|
||||||
|
<b>Local Hostname</b>
|
||||||
|
: {{ server.value.hostname }}.local
|
||||||
|
</span>
|
||||||
|
<span tuiSubtitle>
|
||||||
|
<b>StartOS Version</b>
|
||||||
|
: {{ server.value.version }}
|
||||||
|
</span>
|
||||||
|
<span tuiSubtitle>
|
||||||
|
<b>Created</b>
|
||||||
|
: {{ server.value.timestamp | date: 'medium' }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
[tuiCell] {
|
||||||
|
width: stretch;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [KeyValuePipe, DatePipe, TuiCell, TuiTitle],
|
||||||
|
})
|
||||||
|
export class BackupRestoreComponent {
|
||||||
|
private readonly dialogs = inject(TuiResponsiveDialogService)
|
||||||
|
private readonly loader = inject(LoadingService)
|
||||||
|
private readonly api = inject(ApiService)
|
||||||
|
private readonly errorService = inject(ErrorService)
|
||||||
|
private readonly context = injectContext<BackupContext>()
|
||||||
|
|
||||||
|
readonly target = this.context.data
|
||||||
|
|
||||||
|
onClick(serverId: string, { passwordHash }: StartOSDiskInfo) {
|
||||||
|
this.dialogs
|
||||||
|
.open<string>(PROMPT, RESTORE_OPTIONS)
|
||||||
|
.pipe(verifyPassword(passwordHash, e => this.errorService.handleError(e)))
|
||||||
|
.subscribe(async password => await this.restore(serverId, password))
|
||||||
|
}
|
||||||
|
|
||||||
|
private async restore(serverId: string, password: string): Promise<void> {
|
||||||
|
const loader = this.loader.open('Decrypting drive...').subscribe()
|
||||||
|
const params = { targetId: this.target.id, serverId, password }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const backupInfo = await this.api.getBackupInfo(params)
|
||||||
|
const data = {
|
||||||
|
targetId: this.target.id,
|
||||||
|
serverId,
|
||||||
|
backupInfo,
|
||||||
|
password,
|
||||||
|
}
|
||||||
|
|
||||||
|
this.context.$implicit.complete()
|
||||||
|
this.dialogs
|
||||||
|
.open(RECOVER, { label: 'Select Services to Restore', data })
|
||||||
|
.subscribe()
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BACKUP_RESTORE = new PolymorpheusComponent(BackupRestoreComponent)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { ActivatedRoute } from '@angular/router'
|
||||||
|
import { TuiIcon } from '@taiga-ui/core'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
selector: '[backupStatus]',
|
||||||
|
template: `
|
||||||
|
@if (type === 'create') {
|
||||||
|
<tui-icon icon="@tui.cloud" class="g-positive" />
|
||||||
|
Available for backup
|
||||||
|
} @else {
|
||||||
|
@if (backupStatus()) {
|
||||||
|
<tui-icon icon="@tui.cloud-upload" class="g-positive" />
|
||||||
|
StartOS backups detected
|
||||||
|
} @else {
|
||||||
|
<tui-icon icon="@tui.cloud-off" class="g-negative" />
|
||||||
|
No StartOS backups
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
:host {
|
||||||
|
color: var(--tui-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
tui-icon {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [TuiIcon],
|
||||||
|
})
|
||||||
|
export class BackupStatusComponent {
|
||||||
|
readonly type = inject(ActivatedRoute).snapshot.data['type']
|
||||||
|
readonly backupStatus = input(false)
|
||||||
|
}
|
||||||
@@ -71,6 +71,17 @@ import { EmailInfoComponent } from './info.component'
|
|||||||
</form>
|
</form>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
`,
|
`,
|
||||||
|
styles: `
|
||||||
|
:host {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(tui-root._mobile) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
`,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
|
|||||||
@@ -12,11 +12,20 @@ import { TuiLink, TuiNotification } from '@taiga-ui/core'
|
|||||||
href="https://docs.start9.com/latest/user-manual/smtp"
|
href="https://docs.start9.com/latest/user-manual/smtp"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
iconEnd="@tui.external-link"
|
||||||
View instructions
|
[textContent]="'View instructions'"
|
||||||
</a>
|
></a>
|
||||||
</tui-notification>
|
</tui-notification>
|
||||||
`,
|
`,
|
||||||
|
styles: `
|
||||||
|
:host {
|
||||||
|
grid-column: span 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(tui-root._mobile) {
|
||||||
|
grid-column: 1;
|
||||||
|
}
|
||||||
|
`,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [TuiNotification, TuiLink],
|
imports: [TuiNotification, TuiLink],
|
||||||
|
|||||||
@@ -0,0 +1,285 @@
|
|||||||
|
import { AsyncPipe } from '@angular/common'
|
||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
INJECTOR,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop'
|
||||||
|
import { RouterLink } from '@angular/router'
|
||||||
|
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||||
|
import { TuiResponsiveDialogService } from '@taiga-ui/addon-mobile'
|
||||||
|
import {
|
||||||
|
TuiAlertService,
|
||||||
|
TuiAppearance,
|
||||||
|
TuiButton,
|
||||||
|
tuiFadeIn,
|
||||||
|
TuiIcon,
|
||||||
|
tuiScaleIn,
|
||||||
|
TuiTitle,
|
||||||
|
} from '@taiga-ui/core'
|
||||||
|
import { TUI_CONFIRM } from '@taiga-ui/kit'
|
||||||
|
import { TuiCell, tuiCellOptionsProvider, TuiHeader } from '@taiga-ui/layout'
|
||||||
|
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
||||||
|
import { PatchDB } from 'patch-db-client'
|
||||||
|
import { filter } from 'rxjs'
|
||||||
|
import { PROMPT } from 'src/app/routes/portal/modals/prompt.component'
|
||||||
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
|
import { ConfigService } from 'src/app/services/config.service'
|
||||||
|
import { EOSService } from 'src/app/services/eos.service'
|
||||||
|
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||||
|
import { TitleDirective } from 'src/app/services/title.service'
|
||||||
|
import { SystemSyncComponent } from './sync.component'
|
||||||
|
import { UPDATE } from './update.component'
|
||||||
|
import { SystemWipeComponent } from './wipe.component'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
<ng-container *title>
|
||||||
|
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">Back</a>
|
||||||
|
General Settings
|
||||||
|
</ng-container>
|
||||||
|
<header tuiHeader>
|
||||||
|
<hgroup tuiTitle>
|
||||||
|
<h3>General</h3>
|
||||||
|
<p tuiSubtitle>Manage your overall setup and preferences</p>
|
||||||
|
</hgroup>
|
||||||
|
</header>
|
||||||
|
@if (server(); as server) {
|
||||||
|
@if (!server.ntpSynced) {
|
||||||
|
<system-sync />
|
||||||
|
}
|
||||||
|
<div tuiCell tuiAppearance="outline-grayscale">
|
||||||
|
<tui-icon icon="@tui.zap" />
|
||||||
|
<span tuiTitle>
|
||||||
|
<strong>Software Update</strong>
|
||||||
|
<span tuiSubtitle>{{ server.version }}</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
tuiButton
|
||||||
|
appearance="accent"
|
||||||
|
[disabled]="eos.updatingOrBackingUp$ | async"
|
||||||
|
(click)="onUpdate()"
|
||||||
|
>
|
||||||
|
@if (server.statusInfo.updated) {
|
||||||
|
Restart to apply
|
||||||
|
} @else {
|
||||||
|
@if (eos.showUpdate$ | async) {
|
||||||
|
Update
|
||||||
|
} @else {
|
||||||
|
Check for updates
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div tuiCell tuiAppearance="outline-grayscale">
|
||||||
|
<tui-icon icon="@tui.app-window" />
|
||||||
|
<span tuiTitle>
|
||||||
|
<strong>Browser Tab Title</strong>
|
||||||
|
<span tuiSubtitle>{{ name() }}</span>
|
||||||
|
</span>
|
||||||
|
<button tuiButton (click)="onTitle()">Change</button>
|
||||||
|
</div>
|
||||||
|
<div tuiCell tuiAppearance="outline-grayscale">
|
||||||
|
<tui-icon icon="@tui.languages" />
|
||||||
|
<span tuiTitle>
|
||||||
|
<strong>Language</strong>
|
||||||
|
<span tuiSubtitle>English</span>
|
||||||
|
</span>
|
||||||
|
<button tuiButton>Change</button>
|
||||||
|
</div>
|
||||||
|
<div tuiCell tuiAppearance="outline-grayscale">
|
||||||
|
<tui-icon icon="@tui.circle-power" (click)="count = count + 1" />
|
||||||
|
<span tuiTitle>
|
||||||
|
<strong>Reset Tor</strong>
|
||||||
|
<span tuiSubtitle>Restart the Tor daemon on your server</span>
|
||||||
|
</span>
|
||||||
|
<button tuiButton appearance="glass" (click)="onReset()">Reset</button>
|
||||||
|
</div>
|
||||||
|
@if (count > 4) {
|
||||||
|
<div tuiCell tuiAppearance="outline-grayscale" @tuiScaleIn @tuiFadeIn>
|
||||||
|
<tui-icon icon="@tui.briefcase-medical" />
|
||||||
|
<span tuiTitle>
|
||||||
|
<strong>Disk Repair</strong>
|
||||||
|
<span tuiSubtitle>Attempt automatic repair</span>
|
||||||
|
</span>
|
||||||
|
<button tuiButton appearance="glass" (click)="onRepair()">
|
||||||
|
Repair
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
:host {
|
||||||
|
max-inline-size: 40rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
line-height: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
[tuiCell] {
|
||||||
|
background: var(--tui-background-neutral-1);
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
providers: [tuiCellOptionsProvider({ height: 'spacious' })],
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
animations: [tuiScaleIn, tuiFadeIn],
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
AsyncPipe,
|
||||||
|
RouterLink,
|
||||||
|
TuiTitle,
|
||||||
|
TuiHeader,
|
||||||
|
TuiCell,
|
||||||
|
TuiAppearance,
|
||||||
|
TuiButton,
|
||||||
|
TitleDirective,
|
||||||
|
SystemSyncComponent,
|
||||||
|
TuiIcon,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export default class SystemGeneralComponent {
|
||||||
|
private readonly alerts = inject(TuiAlertService)
|
||||||
|
private readonly dialogs = inject(TuiResponsiveDialogService)
|
||||||
|
private readonly loader = inject(LoadingService)
|
||||||
|
private readonly errorService = inject(ErrorService)
|
||||||
|
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||||
|
private readonly api = inject(ApiService)
|
||||||
|
private readonly isTor = inject(ConfigService).isTor()
|
||||||
|
private readonly reset = new PolymorpheusComponent(
|
||||||
|
SystemWipeComponent,
|
||||||
|
inject(INJECTOR),
|
||||||
|
)
|
||||||
|
|
||||||
|
wipe = false
|
||||||
|
count = 0
|
||||||
|
|
||||||
|
readonly server = toSignal(this.patch.watch$('serverInfo'))
|
||||||
|
readonly name = toSignal(this.patch.watch$('ui', 'name'))
|
||||||
|
readonly eos = inject(EOSService)
|
||||||
|
|
||||||
|
onUpdate() {
|
||||||
|
if (this.server()?.statusInfo.updated) {
|
||||||
|
this.restart()
|
||||||
|
} else if (this.eos.updateAvailable$.value) {
|
||||||
|
this.update()
|
||||||
|
} else {
|
||||||
|
this.check()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onTitle() {
|
||||||
|
this.dialogs
|
||||||
|
.open<string>(PROMPT, {
|
||||||
|
label: 'Browser Tab Title',
|
||||||
|
data: {
|
||||||
|
message: `This value will be displayed as the title of your browser tab.`,
|
||||||
|
label: 'Device Name',
|
||||||
|
placeholder: 'StartOS',
|
||||||
|
required: false,
|
||||||
|
buttonText: 'Save',
|
||||||
|
initialValue: this.name(),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.subscribe(async name => {
|
||||||
|
const loader = this.loader.open('Saving...').subscribe()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.api.setDbValue(['name'], name || null)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onReset() {
|
||||||
|
this.wipe = false
|
||||||
|
this.dialogs
|
||||||
|
.open(TUI_CONFIRM, {
|
||||||
|
label: this.isTor ? 'Warning' : 'Confirm',
|
||||||
|
data: {
|
||||||
|
content: this.reset,
|
||||||
|
yes: 'Reset',
|
||||||
|
no: 'Cancel',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.pipe(filter(Boolean))
|
||||||
|
.subscribe(() => this.resetTor(this.wipe))
|
||||||
|
}
|
||||||
|
|
||||||
|
async onRepair() {
|
||||||
|
this.dialogs
|
||||||
|
.open(TUI_CONFIRM, {
|
||||||
|
label: 'Warning',
|
||||||
|
data: {
|
||||||
|
content: `<p>This action should only be executed if directed by a Start9 support specialist. We recommend backing up your device before preforming this action.</p><p>If anything happens to the device during the reboot, such as losing power or unplugging the drive, the filesystem <i>will</i> be in an unrecoverable state. Please proceed with caution.</p>`,
|
||||||
|
yes: 'Repair',
|
||||||
|
no: 'Cancel',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.pipe(filter(Boolean))
|
||||||
|
.subscribe(async () => {
|
||||||
|
try {
|
||||||
|
await this.api.repairDisk({})
|
||||||
|
this.restart()
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errorService.handleError(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resetTor(wipeState: boolean) {
|
||||||
|
const loader = this.loader.open('Resetting Tor...').subscribe()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.api.resetTor({ wipeState, reason: 'User triggered' })
|
||||||
|
|
||||||
|
this.alerts.open('Tor reset in progress').subscribe()
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errorService.handleError(e)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private update() {
|
||||||
|
this.dialogs.open(UPDATE).subscribe()
|
||||||
|
}
|
||||||
|
|
||||||
|
private async check(): Promise<void> {
|
||||||
|
const loader = this.loader.open('Checking for updates').subscribe()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.eos.loadEos()
|
||||||
|
|
||||||
|
if (this.eos.updateAvailable$.value) {
|
||||||
|
this.update()
|
||||||
|
} else {
|
||||||
|
this.dialogs
|
||||||
|
.open('You are on the latest version of StartOS.', {
|
||||||
|
label: 'Up to date!',
|
||||||
|
size: 's',
|
||||||
|
})
|
||||||
|
.subscribe()
|
||||||
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errorService.handleError(e)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async restart() {
|
||||||
|
const loader = this.loader.open(`Beginning restart...`).subscribe()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.api.restartServer({})
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errorService.handleError(e)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||||
|
import { TuiLink, TuiNotification, TuiTitle } from '@taiga-ui/core'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'system-sync',
|
||||||
|
template: `
|
||||||
|
<tui-notification appearance="warning">
|
||||||
|
<div tuiTitle>
|
||||||
|
Clock sync failure
|
||||||
|
<div tuiSubtitle>
|
||||||
|
This will cause connectivity issues. Refer to the
|
||||||
|
<a
|
||||||
|
tuiLink
|
||||||
|
iconEnd="@tui.external-link"
|
||||||
|
href="https://docs.start9.com/0.3.5.x/support/common-issues#clock-sync-failure"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
[textContent]="'StartOS docs'"
|
||||||
|
></a>
|
||||||
|
to resolve it
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</tui-notification>
|
||||||
|
`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
standalone: true,
|
||||||
|
imports: [TuiNotification, TuiTitle, TuiLink],
|
||||||
|
})
|
||||||
|
export class SystemSyncComponent {}
|
||||||
@@ -23,12 +23,10 @@ import { EOSService } from 'src/app/services/eos.service'
|
|||||||
Release Notes
|
Release Notes
|
||||||
</h3>
|
</h3>
|
||||||
<tui-scrollbar style="margin-bottom: 24px; max-height: 50vh;">
|
<tui-scrollbar style="margin-bottom: 24px; max-height: 50vh;">
|
||||||
<ng-container *ngFor="let v of versions">
|
@for (v of versions; track $index) {
|
||||||
<h4 class="g-title">
|
<h4 class="g-title">{{ v.version }}</h4>
|
||||||
{{ v.version }}
|
|
||||||
</h4>
|
|
||||||
<div safeLinks [innerHTML]="v.notes | markdown | dompurify"></div>
|
<div safeLinks [innerHTML]="v.notes | markdown | dompurify"></div>
|
||||||
</ng-container>
|
}
|
||||||
</tui-scrollbar>
|
</tui-scrollbar>
|
||||||
<button tuiButton tuiAutoFocus style="float: right;" (click)="update()">
|
<button tuiButton tuiAutoFocus style="float: right;" (click)="update()">
|
||||||
Begin Update
|
Begin Update
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import { TuiLabel } from '@taiga-ui/core'
|
||||||
|
import { TuiCheckbox } from '@taiga-ui/kit'
|
||||||
|
import { ConfigService } from 'src/app/services/config.service'
|
||||||
|
import SystemGeneralComponent from './general.component'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
standalone: true,
|
||||||
|
template: `
|
||||||
|
<p>
|
||||||
|
@if (isTor) {
|
||||||
|
You are currently connected over Tor. If you reset the Tor daemon, you
|
||||||
|
will lose connectivity until it comes back online.
|
||||||
|
} @else {
|
||||||
|
Reset Tor?
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
Optionally wipe state to forcibly acquire new guard nodes. It is
|
||||||
|
recommended to try without wiping state first.
|
||||||
|
</p>
|
||||||
|
<label tuiLabel>
|
||||||
|
<input type="checkbox" tuiCheckbox [(ngModel)]="component.wipe" />
|
||||||
|
Wipe state
|
||||||
|
</label>
|
||||||
|
`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [TuiLabel, FormsModule, TuiCheckbox],
|
||||||
|
})
|
||||||
|
export class SystemWipeComponent {
|
||||||
|
readonly isTor = inject(ConfigService).isTor()
|
||||||
|
readonly component = inject(SystemGeneralComponent)
|
||||||
|
}
|
||||||
@@ -1,15 +1,12 @@
|
|||||||
import { CommonModule } from '@angular/common'
|
|
||||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop'
|
||||||
import { RouterLink } from '@angular/router'
|
import { RouterLink } from '@angular/router'
|
||||||
import { T } from '@start9labs/start-sdk'
|
import { T } from '@start9labs/start-sdk'
|
||||||
import { TuiButton } from '@taiga-ui/core'
|
import { TuiButton } from '@taiga-ui/core'
|
||||||
import { PatchDB } from 'patch-db-client'
|
import { PatchDB } from 'patch-db-client'
|
||||||
import { Observable, map } from 'rxjs'
|
import { map } from 'rxjs'
|
||||||
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
|
import { InterfaceComponent } from 'src/app/routes/portal/components/interfaces/interface.component'
|
||||||
import {
|
import { getAddresses } from 'src/app/routes/portal/components/interfaces/interface.utils'
|
||||||
getAddresses,
|
|
||||||
MappedServiceInterface,
|
|
||||||
} from 'src/app/routes/portal/components/interfaces/interface.utils'
|
|
||||||
import { ConfigService } from 'src/app/services/config.service'
|
import { ConfigService } from 'src/app/services/config.service'
|
||||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||||
import { TitleDirective } from 'src/app/services/title.service'
|
import { TitleDirective } from 'src/app/services/title.service'
|
||||||
@@ -37,34 +34,26 @@ const iface: T.ServiceInterface = {
|
|||||||
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">Back</a>
|
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">Back</a>
|
||||||
Web Addresses
|
Web Addresses
|
||||||
</ng-container>
|
</ng-container>
|
||||||
<app-interface
|
@if (ui(); as ui) {
|
||||||
*ngIf="ui$ | async as ui"
|
<app-interface [serviceInterface]="ui" />
|
||||||
[style.max-width.rem]="50"
|
}
|
||||||
[serviceInterface]="ui"
|
|
||||||
/>
|
|
||||||
`,
|
`,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [InterfaceComponent, RouterLink, TuiButton, TitleDirective],
|
||||||
CommonModule,
|
|
||||||
InterfaceComponent,
|
|
||||||
RouterLink,
|
|
||||||
TuiButton,
|
|
||||||
TitleDirective,
|
|
||||||
],
|
|
||||||
})
|
})
|
||||||
export default class StartOsUiComponent {
|
export default class StartOsUiComponent {
|
||||||
private readonly config = inject(ConfigService)
|
private readonly config = inject(ConfigService)
|
||||||
|
|
||||||
readonly ui$: Observable<MappedServiceInterface> = inject<PatchDB<DataModel>>(
|
readonly ui = toSignal(
|
||||||
PatchDB,
|
inject<PatchDB<DataModel>>(PatchDB)
|
||||||
|
.watch$('serverInfo', 'network', 'host')
|
||||||
|
.pipe(
|
||||||
|
map(host => ({
|
||||||
|
...iface,
|
||||||
|
public: host.bindings[iface.addressInfo.internalPort].net.public,
|
||||||
|
addresses: getAddresses(iface, host, this.config),
|
||||||
|
})),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.watch$('serverInfo', 'network', 'host')
|
|
||||||
.pipe(
|
|
||||||
map(host => ({
|
|
||||||
...iface,
|
|
||||||
public: host.bindings[iface.addressInfo.internalPort].net.public,
|
|
||||||
addresses: getAddresses(iface, host, this.config),
|
|
||||||
})),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop'
|
||||||
|
import { RouterLink } from '@angular/router'
|
||||||
|
import * as argon2 from '@start9labs/argon2'
|
||||||
|
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||||
|
import { ISB } from '@start9labs/start-sdk'
|
||||||
|
import {
|
||||||
|
TuiAlertService,
|
||||||
|
TuiButton,
|
||||||
|
TuiNotification,
|
||||||
|
TuiTitle,
|
||||||
|
} from '@taiga-ui/core'
|
||||||
|
import { PatchDB } from 'patch-db-client'
|
||||||
|
import { from } from 'rxjs'
|
||||||
|
import { FormComponent } from 'src/app/routes/portal/components/form.component'
|
||||||
|
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||||
|
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||||
|
import { TitleDirective } from 'src/app/services/title.service'
|
||||||
|
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
||||||
|
import { getServerInfo } from 'src/app/utils/get-server-info'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
template: `
|
||||||
|
<ng-container *title>
|
||||||
|
<a routerLink=".." tuiIconButton iconStart="@tui.arrow-left">Back</a>
|
||||||
|
Password Reset
|
||||||
|
</ng-container>
|
||||||
|
<tui-notification appearance="warning">
|
||||||
|
<div tuiTitle>
|
||||||
|
<strong>Warning</strong>
|
||||||
|
<div tuiSubtitle>
|
||||||
|
You will still need your current password to decrypt existing backups!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</tui-notification>
|
||||||
|
<section class="g-card">
|
||||||
|
<header>Change Master Password</header>
|
||||||
|
@if (spec(); as spec) {
|
||||||
|
<app-form [spec]="spec" [buttons]="buttons" />
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
:host {
|
||||||
|
max-inline-size: 40rem;
|
||||||
|
|
||||||
|
::ng-deep footer {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
padding: 4rem 1rem 0;
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
TuiNotification,
|
||||||
|
TuiTitle,
|
||||||
|
FormComponent,
|
||||||
|
RouterLink,
|
||||||
|
TuiButton,
|
||||||
|
TitleDirective,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export default class SystemPasswordComponent {
|
||||||
|
private readonly alerts = inject(TuiAlertService)
|
||||||
|
private readonly loader = inject(LoadingService)
|
||||||
|
private readonly errorService = inject(ErrorService)
|
||||||
|
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
||||||
|
private readonly api = inject(ApiService)
|
||||||
|
|
||||||
|
readonly spec = toSignal(from(configBuilderToSpec(passwordSpec)))
|
||||||
|
readonly buttons = [
|
||||||
|
{
|
||||||
|
text: 'Save',
|
||||||
|
handler: (value: PasswordSpec) => this.resetPassword(value),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
private async resetPassword({
|
||||||
|
newPassword,
|
||||||
|
newPasswordConfirm,
|
||||||
|
oldPassword,
|
||||||
|
}: PasswordSpec) {
|
||||||
|
let error = ''
|
||||||
|
|
||||||
|
if (newPassword !== newPasswordConfirm) {
|
||||||
|
error = 'New passwords do not match'
|
||||||
|
} else if (newPassword.length < 12) {
|
||||||
|
error = 'New password must be 12 characters or greater'
|
||||||
|
} else if (newPassword.length > 64) {
|
||||||
|
error = 'New password must be less than 65 characters'
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirm current password is correct
|
||||||
|
const { passwordHash } = await getServerInfo(this.patch)
|
||||||
|
|
||||||
|
try {
|
||||||
|
argon2.verify(passwordHash, oldPassword)
|
||||||
|
} catch (e) {
|
||||||
|
error = 'Current password is invalid'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
this.errorService.handleError(error)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const loader = this.loader.open('Saving...').subscribe()
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.api.resetPassword({ oldPassword, newPassword })
|
||||||
|
this.alerts.open('Password changed!').subscribe()
|
||||||
|
} catch (e: any) {
|
||||||
|
this.errorService.handleError(e)
|
||||||
|
} finally {
|
||||||
|
loader.unsubscribe()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordSpec = ISB.InputSpec.of({
|
||||||
|
oldPassword: ISB.Value.text({
|
||||||
|
name: 'Current Password',
|
||||||
|
required: true,
|
||||||
|
default: null,
|
||||||
|
masked: true,
|
||||||
|
}),
|
||||||
|
newPassword: ISB.Value.text({
|
||||||
|
name: 'New Password',
|
||||||
|
required: true,
|
||||||
|
default: null,
|
||||||
|
masked: true,
|
||||||
|
}),
|
||||||
|
newPasswordConfirm: ISB.Value.text({
|
||||||
|
name: 'Retype New Password',
|
||||||
|
required: true,
|
||||||
|
default: null,
|
||||||
|
masked: true,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type PasswordSpec = typeof passwordSpec.validator._TYPE
|
||||||
@@ -13,9 +13,9 @@ import { TuiLink, TuiNotification } from '@taiga-ui/core'
|
|||||||
href="https://docs.start9.com/latest/user-manual/wifi"
|
href="https://docs.start9.com/latest/user-manual/wifi"
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
>
|
iconEnd="@tui.external-link"
|
||||||
View instructions
|
[textContent]="'View instructions'"
|
||||||
</a>
|
></a>
|
||||||
</tui-notification>
|
</tui-notification>
|
||||||
`,
|
`,
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
|||||||
@@ -1,43 +1,114 @@
|
|||||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop'
|
||||||
import { RouterModule } from '@angular/router'
|
import { RouterModule } from '@angular/router'
|
||||||
|
import { TuiIcon, TuiTitle } from '@taiga-ui/core'
|
||||||
|
import { TuiBadgeNotification } from '@taiga-ui/kit'
|
||||||
|
import { TuiCell } from '@taiga-ui/layout'
|
||||||
|
import { BadgeService } from 'src/app/services/badge.service'
|
||||||
import { TitleDirective } from 'src/app/services/title.service'
|
import { TitleDirective } from 'src/app/services/title.service'
|
||||||
import { SystemMenuComponent } from './components/menu.component'
|
import { SYSTEM_MENU } from './system.const'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
template: `
|
template: `
|
||||||
<ng-container *title><span>System System</span></ng-container>
|
<span *title>System</span>
|
||||||
<system-menu />
|
<aside class="g-aside">
|
||||||
|
@for (cat of menu; track $index) {
|
||||||
|
@if ($index) {
|
||||||
|
<hr [style.margin.rem]="0.5" />
|
||||||
|
}
|
||||||
|
@for (page of cat; track $index) {
|
||||||
|
<a
|
||||||
|
tuiCell="s"
|
||||||
|
routerLinkActive="active"
|
||||||
|
[routerLink]="page.routerLink"
|
||||||
|
>
|
||||||
|
<tui-icon [icon]="page.icon" />
|
||||||
|
<span tuiTitle>
|
||||||
|
<span>
|
||||||
|
{{ page.title }}
|
||||||
|
@if (page.routerLink === 'general' && badge()) {
|
||||||
|
<tui-badge-notification>{{ badge() }}</tui-badge-notification>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</aside>
|
||||||
<router-outlet />
|
<router-outlet />
|
||||||
`,
|
`,
|
||||||
styles: [
|
styles: [
|
||||||
`
|
`
|
||||||
:host {
|
:host {
|
||||||
padding-top: 1rem;
|
display: flex;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
::ng-deep tui-notification {
|
[tuiCell] {
|
||||||
position: sticky;
|
color: var(--tui-text-secondary);
|
||||||
left: 0;
|
|
||||||
|
&.active {
|
||||||
|
color: var(--tui-text-primary);
|
||||||
|
|
||||||
|
[tuiTitle] {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
span:not(:last-child),
|
span:not(:last-child) {
|
||||||
system-menu:not(:nth-last-child(2)) {
|
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
system-menu,
|
|
||||||
router-outlet + ::ng-deep * {
|
router-outlet + ::ng-deep * {
|
||||||
|
height: fit-content;
|
||||||
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
margin: 0 auto;
|
padding: 1rem;
|
||||||
max-width: 45rem;
|
}
|
||||||
|
|
||||||
|
:host-context(tui-root._mobile) {
|
||||||
|
aside {
|
||||||
|
padding: 0;
|
||||||
|
width: 100%;
|
||||||
|
background: none;
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
&:not(:nth-last-child(2)) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[tuiCell] {
|
||||||
|
color: var(--tui-text-primary);
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
|
||||||
|
[tuiTitle] {
|
||||||
|
font: var(--tui-font-text-l);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
background: var(--tui-border-normal);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
],
|
],
|
||||||
host: { class: 'g-page' },
|
host: { class: 'g-page' },
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [RouterModule, SystemMenuComponent, TitleDirective],
|
imports: [
|
||||||
|
RouterModule,
|
||||||
|
TuiCell,
|
||||||
|
TuiIcon,
|
||||||
|
TuiTitle,
|
||||||
|
TitleDirective,
|
||||||
|
TuiBadgeNotification,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class SystemComponent {}
|
export class SystemComponent {
|
||||||
|
readonly menu = SYSTEM_MENU
|
||||||
|
readonly badge = toSignal(inject(BadgeService).getCount('/portal/system'))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
export const SYSTEM_MENU = [
|
||||||
|
[
|
||||||
|
{
|
||||||
|
title: 'General',
|
||||||
|
icon: '@tui.settings',
|
||||||
|
routerLink: 'general',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Email',
|
||||||
|
icon: '@tui.mail',
|
||||||
|
routerLink: 'email',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
title: 'Create Backup',
|
||||||
|
icon: '@tui.copy-plus',
|
||||||
|
routerLink: 'backup',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Restore Backup',
|
||||||
|
icon: '@tui.database-backup',
|
||||||
|
routerLink: 'restore',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
title: 'User Interface Addresses',
|
||||||
|
icon: '@tui.monitor',
|
||||||
|
routerLink: 'interfaces',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'ACME',
|
||||||
|
icon: '@tui.award',
|
||||||
|
routerLink: 'acme',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'WiFi',
|
||||||
|
icon: '@tui.wifi',
|
||||||
|
routerLink: 'wifi',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{
|
||||||
|
title: 'Active Sessions',
|
||||||
|
icon: '@tui.clock',
|
||||||
|
routerLink: 'sessions',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'SSH',
|
||||||
|
icon: '@tui.terminal',
|
||||||
|
routerLink: 'ssh',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Change Password',
|
||||||
|
icon: '@tui.key',
|
||||||
|
routerLink: 'password',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]
|
||||||
@@ -1,17 +1,65 @@
|
|||||||
|
import { inject } from '@angular/core'
|
||||||
|
import {
|
||||||
|
ActivatedRouteSnapshot,
|
||||||
|
Router,
|
||||||
|
RouterStateSnapshot,
|
||||||
|
Routes,
|
||||||
|
} from '@angular/router'
|
||||||
|
import { TUI_IS_MOBILE } from '@taiga-ui/cdk'
|
||||||
import { SystemComponent } from './system.component'
|
import { SystemComponent } from './system.component'
|
||||||
|
|
||||||
export default [
|
export default [
|
||||||
{
|
{
|
||||||
path: '',
|
path: '',
|
||||||
component: SystemComponent,
|
component: SystemComponent,
|
||||||
|
canActivate: [
|
||||||
|
({ firstChild }: ActivatedRouteSnapshot, state: RouterStateSnapshot) =>
|
||||||
|
!!firstChild ||
|
||||||
|
inject(TUI_IS_MOBILE) ||
|
||||||
|
inject(Router).parseUrl(`${state.url}/general`),
|
||||||
|
],
|
||||||
children: [
|
children: [
|
||||||
|
{
|
||||||
|
path: 'general',
|
||||||
|
loadComponent: () => import('./routes/general/general.component'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'email',
|
||||||
|
loadComponent: () => import('./routes/email/email.component'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'backup',
|
||||||
|
loadComponent: () => import('./routes/backups/backups.component'),
|
||||||
|
data: { type: 'create' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'restore',
|
||||||
|
loadComponent: () => import('./routes/backups/backups.component'),
|
||||||
|
data: { type: 'restore' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'interfaces',
|
||||||
|
loadComponent: () => import('./routes/interfaces/interfaces.component'),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'acme',
|
path: 'acme',
|
||||||
loadComponent: () => import('./routes/acme/acme.component'),
|
loadComponent: () => import('./routes/acme/acme.component'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'email',
|
path: 'wifi',
|
||||||
loadComponent: () => import('./routes/email/email.component'),
|
loadComponent: () => import('./routes/wifi/wifi.component'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'sessions',
|
||||||
|
loadComponent: () => import('./routes/sessions/sessions.component'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'ssh',
|
||||||
|
loadComponent: () => import('./routes/ssh/ssh.component'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'password',
|
||||||
|
loadComponent: () => import('./routes/password/password.component'),
|
||||||
},
|
},
|
||||||
// {
|
// {
|
||||||
// path: 'domains',
|
// path: 'domains',
|
||||||
@@ -25,22 +73,6 @@ export default [
|
|||||||
// path: 'router',
|
// path: 'router',
|
||||||
// loadComponent: () => import('./routes/router/router.component')
|
// loadComponent: () => import('./routes/router/router.component')
|
||||||
// },
|
// },
|
||||||
{
|
|
||||||
path: 'wifi',
|
|
||||||
loadComponent: () => import('./routes/wifi/wifi.component'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'ui',
|
|
||||||
loadComponent: () => import('./routes/interfaces/ui.component'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'ssh',
|
|
||||||
loadComponent: () => import('./routes/ssh/ssh.component'),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'sessions',
|
|
||||||
loadComponent: () => import('./routes/sessions/sessions.component'),
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
] satisfies Routes
|
||||||
|
|||||||
@@ -1,306 +0,0 @@
|
|||||||
import {
|
|
||||||
ChangeDetectionStrategy,
|
|
||||||
Component,
|
|
||||||
inject,
|
|
||||||
Injectable,
|
|
||||||
} from '@angular/core'
|
|
||||||
import { FormsModule } from '@angular/forms'
|
|
||||||
import * as argon2 from '@start9labs/argon2'
|
|
||||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
|
||||||
import {
|
|
||||||
TuiAlertService,
|
|
||||||
TuiDialogOptions,
|
|
||||||
TuiDialogService,
|
|
||||||
TuiLabel,
|
|
||||||
} from '@taiga-ui/core'
|
|
||||||
import { TUI_CONFIRM, TuiCheckbox, TuiConfirmData } from '@taiga-ui/kit'
|
|
||||||
import { PolymorpheusComponent } from '@taiga-ui/polymorpheus'
|
|
||||||
import { PatchDB } from 'patch-db-client'
|
|
||||||
import { filter, from, take } from 'rxjs'
|
|
||||||
import { switchMap } from 'rxjs/operators'
|
|
||||||
import { FormComponent } from 'src/app/routes/portal/components/form.component'
|
|
||||||
import { PROMPT } from 'src/app/routes/portal/modals/prompt.component'
|
|
||||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
|
||||||
import { ConfigService } from 'src/app/services/config.service'
|
|
||||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
|
||||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
|
||||||
import { configBuilderToSpec } from 'src/app/utils/configBuilderToSpec'
|
|
||||||
import { getServerInfo } from 'src/app/utils/get-server-info'
|
|
||||||
|
|
||||||
import { passwordSpec, PasswordSpec, SettingBtn } from './system.types'
|
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
|
||||||
export class SystemService {
|
|
||||||
private readonly alerts = inject(TuiAlertService)
|
|
||||||
private readonly dialogs = inject(TuiDialogService)
|
|
||||||
private readonly loader = inject(LoadingService)
|
|
||||||
private readonly errorService = inject(ErrorService)
|
|
||||||
private readonly formDialog = inject(FormDialogService)
|
|
||||||
private readonly patch = inject<PatchDB<DataModel>>(PatchDB)
|
|
||||||
private readonly api = inject(ApiService)
|
|
||||||
private readonly isTor = inject(ConfigService).isTor()
|
|
||||||
|
|
||||||
wipe = false
|
|
||||||
|
|
||||||
readonly settings: Record<string, readonly SettingBtn[]> = {
|
|
||||||
General: [
|
|
||||||
{
|
|
||||||
title: 'Email',
|
|
||||||
description: 'Connect to an external SMTP server for sending emails',
|
|
||||||
icon: '@tui.mail',
|
|
||||||
routerLink: 'email',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
Network: [
|
|
||||||
// {
|
|
||||||
// title: 'Domains',
|
|
||||||
// description: 'Manage domains for clearnet connectivity',
|
|
||||||
// icon: '@tui.globe',
|
|
||||||
// routerLink: 'domains',
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// title: 'Proxies',
|
|
||||||
// description: 'Manage proxies for inbound and outbound connections',
|
|
||||||
// icon: '@tui.shuffle',
|
|
||||||
// routerLink: 'proxies',
|
|
||||||
// },
|
|
||||||
// {
|
|
||||||
// title: 'Router Config',
|
|
||||||
// description: 'Connect or configure your router for clearnet',
|
|
||||||
// icon: '@tui.radio',
|
|
||||||
// routerLink: 'router',
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
title: 'User Interface Addresses',
|
|
||||||
description: 'View and manage your Start OS UI addresses',
|
|
||||||
icon: '@tui.monitor',
|
|
||||||
routerLink: 'ui',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'ACME',
|
|
||||||
description:
|
|
||||||
'Add ACME providers to create SSL certificates for clearnet access',
|
|
||||||
icon: '@tui.award',
|
|
||||||
routerLink: 'acme',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'WiFi',
|
|
||||||
description: 'Add or remove WiFi networks',
|
|
||||||
icon: '@tui.wifi',
|
|
||||||
routerLink: 'wifi',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Reset Tor',
|
|
||||||
description: `May help resolve Tor connectivity issues`,
|
|
||||||
icon: '@tui.refresh-cw',
|
|
||||||
action: () => this.promptResetTor(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
Customize: [
|
|
||||||
{
|
|
||||||
title: 'Browser Tab Title',
|
|
||||||
description: `Customize the display name of your browser tab`,
|
|
||||||
icon: '@tui.tag',
|
|
||||||
action: () => this.setBrowserTab(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
Security: [
|
|
||||||
// {
|
|
||||||
// title: 'Outbound Proxy',
|
|
||||||
// description: 'Proxy outbound traffic from the StartOS main process',
|
|
||||||
// icon: '@tui.shield',
|
|
||||||
// action: () => this.setOutboundProxy(),
|
|
||||||
// },
|
|
||||||
{
|
|
||||||
title: 'Active Sessions',
|
|
||||||
description: 'View and manage device access',
|
|
||||||
icon: '@tui.clock',
|
|
||||||
routerLink: 'sessions',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'SSH',
|
|
||||||
description:
|
|
||||||
'Manage your SSH keys to access your server from the command line',
|
|
||||||
icon: '@tui.terminal',
|
|
||||||
routerLink: 'ssh',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Change Password',
|
|
||||||
description: `Change your StartOS master password`,
|
|
||||||
icon: '@tui.key',
|
|
||||||
action: () => this.promptNewPassword(),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
// private async setOutboundProxy(): Promise<void> {
|
|
||||||
// const proxy = await firstValueFrom(
|
|
||||||
// this.patch.watch$('serverInfo', 'network', 'outboundProxy'),
|
|
||||||
// )
|
|
||||||
// await this.proxyService.presentModalSetOutboundProxy(proxy)
|
|
||||||
// }
|
|
||||||
|
|
||||||
private promptResetTor() {
|
|
||||||
this.wipe = false
|
|
||||||
this.dialogs
|
|
||||||
.open(TUI_CONFIRM, {
|
|
||||||
label: this.isTor ? 'Warning' : 'Confirm',
|
|
||||||
data: {
|
|
||||||
content: new PolymorpheusComponent(WipeComponent),
|
|
||||||
yes: 'Reset',
|
|
||||||
no: 'Cancel',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.pipe(filter(Boolean))
|
|
||||||
.subscribe(() => this.resetTor(this.wipe))
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resetTor(wipeState: boolean) {
|
|
||||||
const loader = this.loader.open('Resetting Tor...').subscribe()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.api.resetTor({
|
|
||||||
wipeState: wipeState,
|
|
||||||
reason: 'User triggered',
|
|
||||||
})
|
|
||||||
this.alerts.open('Tor reset in progress').subscribe()
|
|
||||||
} catch (e: any) {
|
|
||||||
this.errorService.handleError(e)
|
|
||||||
} finally {
|
|
||||||
loader.unsubscribe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async setBrowserTab(): Promise<void> {
|
|
||||||
this.patch
|
|
||||||
.watch$('ui', 'name')
|
|
||||||
.pipe(
|
|
||||||
switchMap(initialValue =>
|
|
||||||
this.dialogs.open<string>(PROMPT, {
|
|
||||||
label: 'Browser Tab Title',
|
|
||||||
data: {
|
|
||||||
message: `This value will be displayed as the title of your browser tab.`,
|
|
||||||
label: 'Device Name',
|
|
||||||
placeholder: 'StartOS',
|
|
||||||
required: false,
|
|
||||||
buttonText: 'Save',
|
|
||||||
initialValue,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
take(1),
|
|
||||||
)
|
|
||||||
.subscribe(async name => {
|
|
||||||
const loader = this.loader.open('Saving...').subscribe()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.api.setDbValue<string | null>(['name'], name || null)
|
|
||||||
} finally {
|
|
||||||
loader.unsubscribe()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private promptNewPassword() {
|
|
||||||
this.dialogs
|
|
||||||
.open(TUI_CONFIRM, {
|
|
||||||
label: 'Warning',
|
|
||||||
size: 's',
|
|
||||||
data: {
|
|
||||||
content:
|
|
||||||
'You will still need your current password to decrypt existing backups!',
|
|
||||||
yes: 'Continue',
|
|
||||||
no: 'Cancel',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
.pipe(
|
|
||||||
filter(Boolean),
|
|
||||||
switchMap(() => from(configBuilderToSpec(passwordSpec))),
|
|
||||||
)
|
|
||||||
.subscribe(spec => {
|
|
||||||
this.formDialog.open(FormComponent, {
|
|
||||||
label: 'Change Master Password',
|
|
||||||
data: {
|
|
||||||
spec,
|
|
||||||
buttons: [
|
|
||||||
{
|
|
||||||
text: 'Save',
|
|
||||||
handler: (value: PasswordSpec) => this.resetPassword(value),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private async resetPassword(value: PasswordSpec): Promise<boolean> {
|
|
||||||
let err = ''
|
|
||||||
|
|
||||||
if (value.newPassword1 !== value.newPassword2) {
|
|
||||||
err = 'New passwords do not match'
|
|
||||||
} else if (value.newPassword1.length < 12) {
|
|
||||||
err = 'New password must be 12 characters or greater'
|
|
||||||
} else if (value.newPassword1.length > 64) {
|
|
||||||
err = 'New password must be less than 65 characters'
|
|
||||||
}
|
|
||||||
|
|
||||||
// confirm current password is correct
|
|
||||||
const { passwordHash } = await getServerInfo(this.patch)
|
|
||||||
try {
|
|
||||||
argon2.verify(passwordHash, value.currentPassword)
|
|
||||||
} catch (e) {
|
|
||||||
err = 'Current password is invalid'
|
|
||||||
}
|
|
||||||
|
|
||||||
if (err) {
|
|
||||||
this.errorService.handleError(err)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
const loader = this.loader.open('Saving...').subscribe()
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.api.resetPassword({
|
|
||||||
oldPassword: value.currentPassword,
|
|
||||||
newPassword: value.newPassword1,
|
|
||||||
})
|
|
||||||
|
|
||||||
this.alerts.open('Password changed!').subscribe()
|
|
||||||
|
|
||||||
return true
|
|
||||||
} catch (e: any) {
|
|
||||||
this.errorService.handleError(e)
|
|
||||||
return false
|
|
||||||
} finally {
|
|
||||||
loader.unsubscribe()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
standalone: true,
|
|
||||||
template: `
|
|
||||||
<p>
|
|
||||||
@if (isTor) {
|
|
||||||
You are currently connected over Tor. If you reset the Tor daemon, you
|
|
||||||
will lose connectivity until it comes back online.
|
|
||||||
} @else {
|
|
||||||
Reset Tor?
|
|
||||||
}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Optionally wipe state to forcibly acquire new guard nodes. It is
|
|
||||||
recommended to try without wiping state first.
|
|
||||||
</p>
|
|
||||||
<label tuiLabel>
|
|
||||||
<input type="checkbox" tuiCheckbox [(ngModel)]="service.wipe" />
|
|
||||||
Wipe state
|
|
||||||
</label>
|
|
||||||
`,
|
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
||||||
imports: [TuiLabel, FormsModule, TuiCheckbox],
|
|
||||||
})
|
|
||||||
class WipeComponent {
|
|
||||||
readonly isTor = inject(ConfigService).isTor()
|
|
||||||
readonly service = inject(SystemService)
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
import { ISB } from '@start9labs/start-sdk'
|
|
||||||
|
|
||||||
export interface SettingBtn {
|
|
||||||
title: string
|
|
||||||
description: string
|
|
||||||
icon: string
|
|
||||||
action?: Function
|
|
||||||
routerLink?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export const passwordSpec = ISB.InputSpec.of({
|
|
||||||
currentPassword: ISB.Value.text({
|
|
||||||
name: 'Current Password',
|
|
||||||
required: true,
|
|
||||||
default: null,
|
|
||||||
masked: true,
|
|
||||||
}),
|
|
||||||
newPassword1: ISB.Value.text({
|
|
||||||
name: 'New Password',
|
|
||||||
required: true,
|
|
||||||
default: null,
|
|
||||||
masked: true,
|
|
||||||
}),
|
|
||||||
newPassword2: ISB.Value.text({
|
|
||||||
name: 'Retype New Password',
|
|
||||||
required: true,
|
|
||||||
default: null,
|
|
||||||
masked: true,
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
|
|
||||||
export type PasswordSpec = typeof passwordSpec.validator._TYPE
|
|
||||||
@@ -188,7 +188,7 @@ export const mockPatchData: DataModel = {
|
|||||||
updateProgress: null,
|
updateProgress: null,
|
||||||
restarting: false,
|
restarting: false,
|
||||||
shuttingDown: false,
|
shuttingDown: false,
|
||||||
backupProgress: {},
|
backupProgress: null,
|
||||||
},
|
},
|
||||||
hostname: 'random-words',
|
hostname: 'random-words',
|
||||||
pubkey: 'npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m',
|
pubkey: 'npub1sg6plzptd64u62a878hep2kev88swjh3tw00gjsfl8f237lmu63q0uf63m',
|
||||||
|
|||||||
31
web/projects/ui/src/app/utils/verify-password.ts
Normal file
31
web/projects/ui/src/app/utils/verify-password.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import * as argon2 from '@start9labs/argon2'
|
||||||
|
import {
|
||||||
|
EMPTY,
|
||||||
|
filter,
|
||||||
|
MonoTypeOperatorFunction,
|
||||||
|
of,
|
||||||
|
pipe,
|
||||||
|
switchMap,
|
||||||
|
take,
|
||||||
|
} from 'rxjs'
|
||||||
|
|
||||||
|
export function verifyPassword(
|
||||||
|
passwordHash: string | null,
|
||||||
|
handler: (e: any) => void,
|
||||||
|
): MonoTypeOperatorFunction<string> {
|
||||||
|
return pipe(
|
||||||
|
filter(Boolean),
|
||||||
|
switchMap(password => {
|
||||||
|
try {
|
||||||
|
argon2.verify(passwordHash || '', password)
|
||||||
|
|
||||||
|
return of(password)
|
||||||
|
} catch (e: any) {
|
||||||
|
handler(e)
|
||||||
|
|
||||||
|
return EMPTY
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
take(1),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -61,7 +61,6 @@ hr {
|
|||||||
tui-root._mobile & {
|
tui-root._mobile & {
|
||||||
// For tui-tab-bar
|
// For tui-tab-bar
|
||||||
height: calc(100vh - 3.875rem - var(--tui-height-l));
|
height: calc(100vh - 3.875rem - var(--tui-height-l));
|
||||||
padding: 1rem;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,11 +71,28 @@ hr {
|
|||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.g-aside {
|
||||||
|
position: sticky;
|
||||||
|
top: 1px;
|
||||||
|
left: 1px;
|
||||||
|
margin: 1px;
|
||||||
|
width: 16rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
text-transform: capitalize;
|
||||||
|
box-shadow: 1px 0 var(--tui-border-normal);
|
||||||
|
backdrop-filter: blur(1rem);
|
||||||
|
background-color: color-mix(
|
||||||
|
in hsl,
|
||||||
|
var(--tui-background-base) 90%,
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
.g-card {
|
.g-card {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
padding: 3.125rem 1rem 0.5rem;
|
padding: 3.25rem 1rem 0.375rem;
|
||||||
border-radius: 0.5rem;
|
border-radius: 0.5rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: color-mix(in hsl, var(--start9-base-1) 50%, transparent);
|
background-color: color-mix(in hsl, var(--start9-base-1) 50%, transparent);
|
||||||
@@ -102,7 +118,7 @@ hr {
|
|||||||
inset 0 0 1rem rgba(0, 0, 0, 0.25);
|
inset 0 0 1rem rgba(0, 0, 0, 0.25);
|
||||||
|
|
||||||
&:is(form) {
|
&:is(form) {
|
||||||
padding-top: 3.75rem;
|
padding-top: 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
[tuiCell] {
|
[tuiCell] {
|
||||||
|
|||||||
Reference in New Issue
Block a user