mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 12:11:56 +00:00
Merge branch 'next/minor' of github.com:Start9Labs/start-os into next/major
This commit is contained in:
87
web/projects/ui/src/app/app-routing.module.ts
Normal file
87
web/projects/ui/src/app/app-routing.module.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { PreloadAllModules, RouterModule, Routes } from '@angular/router'
|
||||
import { AuthGuard } from './guards/auth.guard'
|
||||
import { UnauthGuard } from './guards/unauth.guard'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
redirectTo: 'services',
|
||||
pathMatch: 'full',
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
canActivate: [UnauthGuard],
|
||||
loadChildren: () =>
|
||||
import('./pages/login/login.module').then(m => m.LoginPageModule),
|
||||
},
|
||||
{
|
||||
path: 'home',
|
||||
canActivate: [AuthGuard],
|
||||
loadChildren: () =>
|
||||
import('./pages/home/home.module').then(m => m.HomePageModule),
|
||||
},
|
||||
{
|
||||
path: 'system',
|
||||
canActivate: [AuthGuard],
|
||||
canActivateChild: [AuthGuard],
|
||||
loadChildren: () =>
|
||||
import('./pages/server-routes/server-routing.module').then(
|
||||
m => m.ServerRoutingModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'updates',
|
||||
canActivate: [AuthGuard],
|
||||
canActivateChild: [AuthGuard],
|
||||
loadChildren: () =>
|
||||
import('./pages/updates/updates.module').then(m => m.UpdatesPageModule),
|
||||
},
|
||||
{
|
||||
path: 'marketplace',
|
||||
canActivate: [AuthGuard],
|
||||
canActivateChild: [AuthGuard],
|
||||
loadChildren: () =>
|
||||
import('./pages/marketplace-routes/marketplace-routing.module').then(
|
||||
m => m.MarketplaceRoutingModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
canActivate: [AuthGuard],
|
||||
loadChildren: () =>
|
||||
import('./pages/notifications/notifications.module').then(
|
||||
m => m.NotificationsPageModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'services',
|
||||
canActivate: [AuthGuard],
|
||||
canActivateChild: [AuthGuard],
|
||||
loadChildren: () =>
|
||||
import('./pages/apps-routes/apps-routing.module').then(
|
||||
m => m.AppsRoutingModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'developer',
|
||||
canActivate: [AuthGuard],
|
||||
canActivateChild: [AuthGuard],
|
||||
loadChildren: () =>
|
||||
import('./pages/developer-routes/developer-routing.module').then(
|
||||
m => m.DeveloperRoutingModule,
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forRoot(routes, {
|
||||
scrollPositionRestoration: 'enabled',
|
||||
preloadingStrategy: PreloadAllModules,
|
||||
initialNavigation: 'disabled',
|
||||
}),
|
||||
],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class AppRoutingModule {}
|
||||
80
web/projects/ui/src/app/app.component.html
Normal file
80
web/projects/ui/src/app/app.component.html
Normal file
@@ -0,0 +1,80 @@
|
||||
<tui-root
|
||||
*ngIf="widgetDrawer$ | async as drawer"
|
||||
[tuiMode]="(theme$ | async) === 'Dark' ? 'onDark' : null"
|
||||
[style.--widgets-width.px]="drawer.open ? drawer.width : 0"
|
||||
>
|
||||
<ion-app appEnter>
|
||||
<ion-content>
|
||||
<ion-split-pane
|
||||
contentId="main-content"
|
||||
[disabled]="!(authService.isVerified$ | async)"
|
||||
(ionSplitPaneVisible)="splitPaneVisible($event)"
|
||||
>
|
||||
<ion-menu
|
||||
contentId="main-content"
|
||||
type="overlay"
|
||||
side="start"
|
||||
class="left-menu"
|
||||
>
|
||||
<ion-content color="light" scrollY="false" class="menu">
|
||||
<app-menu *ngIf="authService.isVerified$ | async"></app-menu>
|
||||
</ion-content>
|
||||
</ion-menu>
|
||||
|
||||
<ion-menu
|
||||
contentId="main-content"
|
||||
type="overlay"
|
||||
side="end"
|
||||
class="right-menu container"
|
||||
[class.container_offline]="offline$ | async"
|
||||
[class.right-menu_hidden]="!drawer.open"
|
||||
[style.--side-width.px]="drawer.width"
|
||||
>
|
||||
<div class="divider">
|
||||
<button
|
||||
class="widgets-button"
|
||||
[class.widgets-button_collapse]="drawer.width === 600"
|
||||
(click)="onResize(drawer)"
|
||||
></button>
|
||||
</div>
|
||||
<widgets *ngIf="drawer.open" [wide]="drawer.width === 600"></widgets>
|
||||
</ion-menu>
|
||||
|
||||
<ion-router-outlet
|
||||
[responsiveColViewport]="viewport"
|
||||
id="main-content"
|
||||
class="container"
|
||||
[class.container_offline]="offline$ | async"
|
||||
>
|
||||
<ion-content
|
||||
#viewport="viewport"
|
||||
responsiveColViewport
|
||||
class="ion-padding with-widgets"
|
||||
style="pointer-events: none; opacity: 0"
|
||||
></ion-content>
|
||||
</ion-router-outlet>
|
||||
</ion-split-pane>
|
||||
|
||||
<section appPreloader></section>
|
||||
</ion-content>
|
||||
<ion-footer>
|
||||
<footer appFooter></footer>
|
||||
</ion-footer>
|
||||
<ion-footer
|
||||
*ngIf="(authService.isVerified$ | async) && !(sidebarOpen$ | async)"
|
||||
>
|
||||
<connection-bar></connection-bar>
|
||||
</ion-footer>
|
||||
<toast-container></toast-container>
|
||||
</ion-app>
|
||||
</tui-root>
|
||||
<ng-container
|
||||
*ngIf="authService.isVerified$ | async"
|
||||
[ngSwitch]="theme$ | async"
|
||||
>
|
||||
<ng-container *ngSwitchCase="'Dark'">
|
||||
<tui-theme-night></tui-theme-night>
|
||||
<dark-theme></dark-theme>
|
||||
</ng-container>
|
||||
<light-theme *ngSwitchCase="'Light'"></light-theme>
|
||||
</ng-container>
|
||||
125
web/projects/ui/src/app/app.component.scss
Normal file
125
web/projects/ui/src/app/app.component.scss
Normal file
@@ -0,0 +1,125 @@
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
tui-root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.left-menu {
|
||||
--side-max-width: 280px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
:host-context(body[data-theme='Light']) & {
|
||||
--ion-color-base: #F4F4F5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
transition: filter 0.3s;
|
||||
|
||||
&_offline {
|
||||
filter: saturate(0.75) contrast(0.85);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 991.499px) {
|
||||
--widgets-width: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.right-menu {
|
||||
--side-max-width: 600px;
|
||||
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
right: 0;
|
||||
left: auto;
|
||||
top: 74px;
|
||||
|
||||
// For some reason *ngIf is broken upon first login
|
||||
&_hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 100%;
|
||||
width: 10px;
|
||||
pointer-events: none;
|
||||
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
|
||||
background: #e2e2e2;
|
||||
|
||||
z-index: 10;
|
||||
opacity: 0.2;
|
||||
transition: opacity 0.3s;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
margin-top: -78px;
|
||||
left: 10px;
|
||||
width: 60px;
|
||||
height: 50px;
|
||||
border-bottom-left-radius: 14px;
|
||||
box-shadow: -14px 0 0 -1px #e2e2e2;
|
||||
}
|
||||
|
||||
&:after {
|
||||
margin-top: 28px;
|
||||
border-radius: 0;
|
||||
border-top-left-radius: 14px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.widgets-button {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
font-size: 0;
|
||||
left: 100%;
|
||||
width: 16px;
|
||||
height: 60px;
|
||||
margin-top: -30px;
|
||||
border-top-right-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
background: inherit;
|
||||
pointer-events: auto;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 3px;
|
||||
width: 2px;
|
||||
height: 8px;
|
||||
background: black;
|
||||
transform: rotate(-45deg);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&:before {
|
||||
margin-top: -5px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
&_collapse:before {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
&_collapse:after {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
74
web/projects/ui/src/app/app.component.ts
Normal file
74
web/projects/ui/src/app/app.component.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Component, inject, OnDestroy } from '@angular/core'
|
||||
import { combineLatest, map, merge, startWith } from 'rxjs'
|
||||
import { AuthService } from './services/auth.service'
|
||||
import { SplitPaneTracker } from './services/split-pane.service'
|
||||
import { PatchDataService } from './services/patch-data.service'
|
||||
import { PatchMonitorService } from './services/patch-monitor.service'
|
||||
import { ConnectionService } from './services/connection.service'
|
||||
import { Title } from '@angular/platform-browser'
|
||||
import {
|
||||
ClientStorageService,
|
||||
WidgetDrawer,
|
||||
} from './services/client-storage.service'
|
||||
import { ThemeSwitcherService } from './services/theme-switcher.service'
|
||||
import { THEME } from '@start9labs/shared'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { DataModel } from './services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: 'app.component.html',
|
||||
styleUrls: ['app.component.scss'],
|
||||
})
|
||||
export class AppComponent implements OnDestroy {
|
||||
readonly subscription = merge(this.patchData, this.patchMonitor).subscribe()
|
||||
readonly sidebarOpen$ = this.splitPane.sidebarOpen$
|
||||
readonly widgetDrawer$ = this.clientStorageService.widgetDrawer$
|
||||
readonly theme$ = inject(THEME)
|
||||
readonly offline$ = combineLatest([
|
||||
this.authService.isVerified$,
|
||||
this.connection.connected$,
|
||||
this.patch
|
||||
.watch$('server-info', 'status-info')
|
||||
.pipe(startWith({ restarting: false, 'shutting-down': false })),
|
||||
]).pipe(
|
||||
map(
|
||||
([verified, connected, status]) =>
|
||||
verified &&
|
||||
(!connected || status.restarting || status['shutting-down']),
|
||||
),
|
||||
)
|
||||
|
||||
constructor(
|
||||
private readonly titleService: Title,
|
||||
private readonly patchData: PatchDataService,
|
||||
private readonly patchMonitor: PatchMonitorService,
|
||||
private readonly splitPane: SplitPaneTracker,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
readonly authService: AuthService,
|
||||
readonly connection: ConnectionService,
|
||||
readonly clientStorageService: ClientStorageService,
|
||||
readonly themeSwitcher: ThemeSwitcherService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.patch
|
||||
.watch$('ui', 'name')
|
||||
.subscribe(name => this.titleService.setTitle(name || 'StartOS'))
|
||||
}
|
||||
|
||||
splitPaneVisible({ detail }: any) {
|
||||
this.splitPane.sidebarOpen$.next(detail.visible)
|
||||
}
|
||||
|
||||
onResize(drawer: WidgetDrawer) {
|
||||
this.clientStorageService.updateWidgetDrawer({
|
||||
...drawer,
|
||||
width: drawer.width === 400 ? 600 : 400,
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.subscription.unsubscribe()
|
||||
}
|
||||
}
|
||||
77
web/projects/ui/src/app/app.module.ts
Normal file
77
web/projects/ui/src/app/app.module.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
TuiAlertModule,
|
||||
TuiDialogModule,
|
||||
TuiModeModule,
|
||||
TuiRootModule,
|
||||
TuiThemeNightModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { HttpClientModule } from '@angular/common/http'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'
|
||||
import {
|
||||
DarkThemeModule,
|
||||
EnterModule,
|
||||
LightThemeModule,
|
||||
MarkdownModule,
|
||||
ResponsiveColModule,
|
||||
SharedPipesModule,
|
||||
} from '@start9labs/shared'
|
||||
|
||||
import { AppComponent } from './app.component'
|
||||
import { RoutingModule } from './routing.module'
|
||||
import { OSWelcomePageModule } from './common/os-welcome/os-welcome.module'
|
||||
import { PreloaderModule } from './app/preloader/preloader.module'
|
||||
import { FooterModule } from './app/footer/footer.module'
|
||||
import { MenuModule } from './app/menu/menu.module'
|
||||
import { APP_PROVIDERS } from './app.providers'
|
||||
import { PatchDbModule } from './services/patch-db/patch-db.module'
|
||||
import { ToastContainerModule } from './common/toast-container/toast-container.module'
|
||||
import { ConnectionBarComponentModule } from './app/connection-bar/connection-bar.component.module'
|
||||
import { WidgetsPageModule } from 'src/app/apps/ui/pages/widgets/widgets.module'
|
||||
import { ServiceWorkerModule } from '@angular/service-worker'
|
||||
import { environment } from '../environments/environment'
|
||||
import { LoadingModule } from './common/loading/loading.module'
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent],
|
||||
imports: [
|
||||
HttpClientModule,
|
||||
BrowserAnimationsModule,
|
||||
IonicModule.forRoot({
|
||||
mode: 'md',
|
||||
}),
|
||||
RoutingModule,
|
||||
MenuModule,
|
||||
PreloaderModule,
|
||||
FooterModule,
|
||||
EnterModule,
|
||||
OSWelcomePageModule,
|
||||
MarkdownModule,
|
||||
MonacoEditorModule,
|
||||
SharedPipesModule,
|
||||
PatchDbModule,
|
||||
ToastContainerModule,
|
||||
ConnectionBarComponentModule,
|
||||
TuiRootModule,
|
||||
TuiDialogModule,
|
||||
TuiAlertModule,
|
||||
TuiModeModule,
|
||||
TuiThemeNightModule,
|
||||
WidgetsPageModule,
|
||||
ResponsiveColModule,
|
||||
DarkThemeModule,
|
||||
LightThemeModule,
|
||||
ServiceWorkerModule.register('ngsw-worker.js', {
|
||||
enabled: environment.useServiceWorker,
|
||||
// Register the ServiceWorker as soon as the application is stable
|
||||
// or after 30 seconds (whichever comes first).
|
||||
registrationStrategy: 'registerWhenStable:30000',
|
||||
}),
|
||||
LoadingModule,
|
||||
],
|
||||
providers: APP_PROVIDERS,
|
||||
bootstrap: [AppComponent],
|
||||
})
|
||||
export class AppModule {}
|
||||
94
web/projects/ui/src/app/app.providers.ts
Normal file
94
web/projects/ui/src/app/app.providers.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { APP_INITIALIZER, Provider } from '@angular/core'
|
||||
import { UntypedFormBuilder } from '@angular/forms'
|
||||
import { Router, RouteReuseStrategy } from '@angular/router'
|
||||
import { IonicRouteStrategy, IonNav } from '@ionic/angular'
|
||||
import { TUI_DATE_FORMAT, TUI_DATE_SEPARATOR } from '@taiga-ui/cdk'
|
||||
import {
|
||||
tuiButtonOptionsProvider,
|
||||
tuiNumberFormatProvider,
|
||||
tuiTextfieldOptionsProvider,
|
||||
} from '@taiga-ui/core'
|
||||
import {
|
||||
TUI_DATE_TIME_VALUE_TRANSFORMER,
|
||||
TUI_DATE_VALUE_TRANSFORMER,
|
||||
} from '@taiga-ui/kit'
|
||||
import { RELATIVE_URL, THEME, WorkspaceConfig } from '@start9labs/shared'
|
||||
import { AbstractMarketplaceService } from '@start9labs/marketplace'
|
||||
import { ApiService } from './services/api/embassy-api.service'
|
||||
import { MockApiService } from './services/api/embassy-mock-api.service'
|
||||
import { LiveApiService } from './services/api/embassy-live-api.service'
|
||||
import { AuthService } from './services/auth.service'
|
||||
import { ClientStorageService } from './services/client-storage.service'
|
||||
import { FilterPackagesPipe } from '../../../marketplace/src/pipes/filter-packages.pipe'
|
||||
import { ThemeSwitcherService } from './services/theme-switcher.service'
|
||||
import { DateTransformerService } from './services/date-transformer.service'
|
||||
import { DatetimeTransformerService } from './services/datetime-transformer.service'
|
||||
import { MarketplaceService } from './services/marketplace.service'
|
||||
|
||||
const {
|
||||
useMocks,
|
||||
ui: { api },
|
||||
} = require('../../../../config.json') as WorkspaceConfig
|
||||
|
||||
export const APP_PROVIDERS: Provider[] = [
|
||||
FilterPackagesPipe,
|
||||
UntypedFormBuilder,
|
||||
IonNav,
|
||||
tuiNumberFormatProvider({ decimalSeparator: '.', thousandSeparator: '' }),
|
||||
tuiButtonOptionsProvider({ size: 'm' }),
|
||||
tuiTextfieldOptionsProvider({ hintOnDisabled: true }),
|
||||
{
|
||||
provide: TUI_DATE_FORMAT,
|
||||
useValue: 'MDY',
|
||||
},
|
||||
{
|
||||
provide: TUI_DATE_SEPARATOR,
|
||||
useValue: '/',
|
||||
},
|
||||
{
|
||||
provide: TUI_DATE_VALUE_TRANSFORMER,
|
||||
useClass: DateTransformerService,
|
||||
},
|
||||
{
|
||||
provide: TUI_DATE_TIME_VALUE_TRANSFORMER,
|
||||
useClass: DatetimeTransformerService,
|
||||
},
|
||||
{
|
||||
provide: RouteReuseStrategy,
|
||||
useClass: IonicRouteStrategy,
|
||||
},
|
||||
{
|
||||
provide: ApiService,
|
||||
useClass: useMocks ? MockApiService : LiveApiService,
|
||||
},
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
deps: [AuthService, ClientStorageService, Router],
|
||||
useFactory: appInitializer,
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
provide: RELATIVE_URL,
|
||||
useValue: `/${api.url}/${api.version}`,
|
||||
},
|
||||
{
|
||||
provide: THEME,
|
||||
useExisting: ThemeSwitcherService,
|
||||
},
|
||||
{
|
||||
provide: AbstractMarketplaceService,
|
||||
useClass: MarketplaceService,
|
||||
},
|
||||
]
|
||||
|
||||
export function appInitializer(
|
||||
auth: AuthService,
|
||||
localStorage: ClientStorageService,
|
||||
router: Router,
|
||||
): () => void {
|
||||
return () => {
|
||||
auth.init()
|
||||
localStorage.init()
|
||||
router.initialNavigation()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<ion-toolbar
|
||||
*ngIf="connection$ | async as connection"
|
||||
class="connection-toolbar"
|
||||
[color]="connection.color"
|
||||
>
|
||||
<div class="inline" slot="start">
|
||||
<ion-icon [name]="connection.icon" class="icon"></ion-icon>
|
||||
<p style="margin: 8px 0; font-weight: 600">{{ connection.message }}</p>
|
||||
<ion-spinner
|
||||
*ngIf="connection.dots"
|
||||
name="dots"
|
||||
color="light"
|
||||
class="ion-margin-start"
|
||||
></ion-spinner>
|
||||
</div>
|
||||
</ion-toolbar>
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { ConnectionBarComponent } from './connection-bar.component'
|
||||
|
||||
@NgModule({
|
||||
declarations: [ConnectionBarComponent],
|
||||
imports: [CommonModule, IonicModule],
|
||||
exports: [ConnectionBarComponent],
|
||||
})
|
||||
export class ConnectionBarComponentModule {}
|
||||
@@ -0,0 +1,9 @@
|
||||
.connection-toolbar {
|
||||
padding: 0 24px;
|
||||
--min-height: 36px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 23px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { combineLatest, map, Observable, startWith } from 'rxjs'
|
||||
import { ConnectionService } from 'src/app/services/connection.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'connection-bar',
|
||||
templateUrl: './connection-bar.component.html',
|
||||
styleUrls: ['./connection-bar.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ConnectionBarComponent {
|
||||
private readonly websocket$ = this.connectionService.websocketConnected$
|
||||
|
||||
readonly connection$: Observable<{
|
||||
message: string
|
||||
color: string
|
||||
icon: string
|
||||
dots: boolean
|
||||
}> = combineLatest([
|
||||
this.connectionService.networkConnected$,
|
||||
this.websocket$.pipe(startWith(false)),
|
||||
this.patch
|
||||
.watch$('server-info', 'status-info')
|
||||
.pipe(startWith({ restarting: false, 'shutting-down': false })),
|
||||
]).pipe(
|
||||
map(([network, websocket, status]) => {
|
||||
if (!network)
|
||||
return {
|
||||
message: 'No Internet',
|
||||
color: 'danger',
|
||||
icon: 'cloud-offline-outline',
|
||||
dots: false,
|
||||
}
|
||||
if (!websocket)
|
||||
return {
|
||||
message: 'Connecting',
|
||||
color: 'warning',
|
||||
icon: 'cloud-offline-outline',
|
||||
dots: true,
|
||||
}
|
||||
if (status['shutting-down'])
|
||||
return {
|
||||
message: 'Shutting Down',
|
||||
color: 'dark',
|
||||
icon: 'power',
|
||||
dots: true,
|
||||
}
|
||||
if (status.restarting)
|
||||
return {
|
||||
message: 'Restarting',
|
||||
color: 'dark',
|
||||
icon: 'power',
|
||||
dots: true,
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'Connected',
|
||||
color: 'success',
|
||||
icon: 'cloud-done',
|
||||
dots: false,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
constructor(
|
||||
private readonly connectionService: ConnectionService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
}
|
||||
32
web/projects/ui/src/app/app/footer/footer.component.html
Normal file
32
web/projects/ui/src/app/app/footer/footer.component.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<ion-toolbar
|
||||
*ngIf="progress$ | async as progress"
|
||||
color="light"
|
||||
[@heightCollapse]="animation"
|
||||
>
|
||||
<ion-list class="list">
|
||||
<!-- show progress -->
|
||||
<ng-container *ngIf="progress.size !== null; else calculating">
|
||||
<ion-list-header>
|
||||
<ion-label>
|
||||
Downloading: {{ getProgress(progress.size, progress.downloaded) }}%
|
||||
</ion-label>
|
||||
</ion-list-header>
|
||||
<ion-progress-bar
|
||||
class="progress"
|
||||
color="secondary"
|
||||
[value]="getProgress(progress.size, progress.downloaded) / 100"
|
||||
></ion-progress-bar>
|
||||
</ng-container>
|
||||
<!-- show calculating -->
|
||||
<ng-template #calculating>
|
||||
<ion-list-header>
|
||||
<ion-label>Calculating download size</ion-label>
|
||||
</ion-list-header>
|
||||
<ion-progress-bar
|
||||
class="progress"
|
||||
color="secondary"
|
||||
type="indeterminate"
|
||||
></ion-progress-bar>
|
||||
</ng-template>
|
||||
</ion-list>
|
||||
</ion-toolbar>
|
||||
9
web/projects/ui/src/app/app/footer/footer.component.scss
Normal file
9
web/projects/ui/src/app/app/footer/footer.component.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
.list {
|
||||
box-shadow: inset 0 1px var(--ion-color-dark);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.progress {
|
||||
width: auto;
|
||||
margin: 0 16px 16px 16px;
|
||||
}
|
||||
32
web/projects/ui/src/app/app/footer/footer.component.ts
Normal file
32
web/projects/ui/src/app/app/footer/footer.component.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { map } from 'rxjs'
|
||||
import { heightCollapse } from 'src/app/util/animations'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'footer[appFooter]',
|
||||
templateUrl: 'footer.component.html',
|
||||
styleUrls: ['footer.component.scss'],
|
||||
animations: [heightCollapse],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FooterComponent {
|
||||
readonly progress$ = this.patch
|
||||
.watch$('server-info', 'status-info', 'update-progress')
|
||||
.pipe(map(a => a && { ...a }))
|
||||
|
||||
readonly animation = {
|
||||
value: '',
|
||||
params: {
|
||||
duration: 1000,
|
||||
delay: 50,
|
||||
},
|
||||
}
|
||||
|
||||
constructor(private readonly patch: PatchDB<DataModel>) {}
|
||||
|
||||
getProgress(size: number, downloaded: number): number {
|
||||
return Math.round((100 * downloaded) / (size || 1))
|
||||
}
|
||||
}
|
||||
12
web/projects/ui/src/app/app/footer/footer.module.ts
Normal file
12
web/projects/ui/src/app/app/footer/footer.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
|
||||
import { FooterComponent } from './footer.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, IonicModule],
|
||||
declarations: [FooterComponent],
|
||||
exports: [FooterComponent],
|
||||
})
|
||||
export class FooterModule {}
|
||||
64
web/projects/ui/src/app/app/menu/menu.component.html
Normal file
64
web/projects/ui/src/app/app/menu/menu.component.html
Normal file
@@ -0,0 +1,64 @@
|
||||
<a class="logo" routerLink="/home">
|
||||
<img alt="StartOS" src="assets/img/icon.png" />
|
||||
</a>
|
||||
<ion-item-group class="menu">
|
||||
<ion-menu-toggle *ngFor="let page of pages" auto-hide="false">
|
||||
<ion-item
|
||||
button
|
||||
class="link"
|
||||
routerLinkActive="link_selected"
|
||||
color="transparent"
|
||||
routerDirection="root"
|
||||
lines="none"
|
||||
detail="false"
|
||||
[routerLink]="page.url"
|
||||
>
|
||||
<ion-icon
|
||||
slot="start"
|
||||
class="icon label"
|
||||
routerLinkActive="label_selected"
|
||||
[name]="page.icon"
|
||||
></ion-icon>
|
||||
<ion-label class="label montserrat" routerLinkActive="label_selected">
|
||||
{{ page.title }}
|
||||
</ion-label>
|
||||
<ion-icon
|
||||
*ngIf="page.url === '/system' && (warning$ | async)"
|
||||
color="warning"
|
||||
size="small"
|
||||
name="warning"
|
||||
></ion-icon>
|
||||
<ion-icon
|
||||
*ngIf="page.url === '/system' && (showEOSUpdate$ | async)"
|
||||
color="success"
|
||||
size="small"
|
||||
name="rocket"
|
||||
></ion-icon>
|
||||
<ion-badge
|
||||
*ngIf="page.url === '/updates' && (updateCount$ | async) as updateCount"
|
||||
color="success"
|
||||
>
|
||||
{{ updateCount }}
|
||||
</ion-badge>
|
||||
<ion-badge
|
||||
*ngIf="
|
||||
page.url === '/notifications' &&
|
||||
(notificationCount$ | async) as notificaitonCount
|
||||
"
|
||||
color="danger"
|
||||
>
|
||||
{{ notificaitonCount }}
|
||||
</ion-badge>
|
||||
</ion-item>
|
||||
</ion-menu-toggle>
|
||||
</ion-item-group>
|
||||
<img
|
||||
appSnek
|
||||
class="snek"
|
||||
alt="Play Snek"
|
||||
src="assets/img/icons/snek.png"
|
||||
[appSnekHighScore]="snekScore$ | async"
|
||||
/>
|
||||
<ion-footer *ngIf="sidebarOpen$ | async" class="bottom">
|
||||
<connection-bar></connection-bar>
|
||||
</ion-footer>
|
||||
49
web/projects/ui/src/app/app/menu/menu.component.scss
Normal file
49
web/projects/ui/src/app/app/menu/menu.component.scss
Normal file
@@ -0,0 +1,49 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
width: 36%;
|
||||
margin: 0 auto;
|
||||
padding: 16px 16px 0 16px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
padding: 30px 0;
|
||||
}
|
||||
|
||||
.link {
|
||||
--border-radius: 0;
|
||||
|
||||
:host-context(body[data-theme='Light']) &_selected {
|
||||
--ion-color-base: #333;
|
||||
--ion-color-contrast: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--ion-color-dark-shade);
|
||||
|
||||
&_selected {
|
||||
color: var(--ion-color-dark);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.snek {
|
||||
position: absolute;
|
||||
bottom: 56px;
|
||||
right: 20px;
|
||||
width: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
}
|
||||
133
web/projects/ui/src/app/app/menu/menu.component.ts
Normal file
133
web/projects/ui/src/app/app/menu/menu.component.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Inject,
|
||||
} from '@angular/core'
|
||||
import { EOSService } from 'src/app/services/eos.service'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import {
|
||||
combineLatest,
|
||||
filter,
|
||||
first,
|
||||
map,
|
||||
merge,
|
||||
Observable,
|
||||
of,
|
||||
pairwise,
|
||||
startWith,
|
||||
switchMap,
|
||||
} from 'rxjs'
|
||||
import { AbstractMarketplaceService } from '@start9labs/marketplace'
|
||||
import { MarketplaceService } from 'src/app/services/marketplace.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { SplitPaneTracker } from 'src/app/services/split-pane.service'
|
||||
import { Emver, THEME } from '@start9labs/shared'
|
||||
import { ConnectionService } from 'src/app/services/connection.service'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-menu',
|
||||
templateUrl: 'menu.component.html',
|
||||
styleUrls: ['menu.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class MenuComponent {
|
||||
readonly pages = [
|
||||
{
|
||||
title: 'Services',
|
||||
url: '/services',
|
||||
icon: 'grid-outline',
|
||||
},
|
||||
{
|
||||
title: 'Marketplace',
|
||||
url: '/marketplace',
|
||||
icon: 'storefront-outline',
|
||||
},
|
||||
{
|
||||
title: 'Updates',
|
||||
url: '/updates',
|
||||
icon: 'globe-outline',
|
||||
},
|
||||
{
|
||||
title: 'Backups',
|
||||
url: '/backups',
|
||||
icon: 'save-outline',
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
url: '/notifications',
|
||||
icon: 'notifications-outline',
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
url: '/system',
|
||||
icon: 'construct-outline',
|
||||
},
|
||||
]
|
||||
|
||||
readonly notificationCount$ = this.patch.watch$(
|
||||
'server-info',
|
||||
'unread-notification-count',
|
||||
)
|
||||
|
||||
readonly snekScore$ = this.patch.watch$('ui', 'gaming', 'snake', 'high-score')
|
||||
|
||||
readonly showEOSUpdate$ = this.eosService.showUpdate$
|
||||
|
||||
private readonly local$ = this.connectionService.connected$.pipe(
|
||||
filter(Boolean),
|
||||
switchMap(() => this.patch.watch$('package-data').pipe(first())),
|
||||
switchMap(outer =>
|
||||
this.patch.watch$('package-data').pipe(
|
||||
pairwise(),
|
||||
filter(([prev, curr]) =>
|
||||
Object.values(prev).some(p => {
|
||||
const c = curr[p.manifest.id]
|
||||
return !c || (p['install-progress'] && !c['install-progress'])
|
||||
}),
|
||||
),
|
||||
map(([_, curr]) => curr),
|
||||
startWith(outer),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
readonly updateCount$: Observable<number> = combineLatest([
|
||||
this.marketplaceService.getMarketplace$(true),
|
||||
this.local$,
|
||||
]).pipe(
|
||||
map(([marketplace, local]) =>
|
||||
Object.entries(marketplace).reduce((list, [_, store]) => {
|
||||
store?.packages.forEach(({ manifest: { id, version } }) => {
|
||||
if (
|
||||
this.emver.compare(version, local[id]?.manifest.version || '') === 1
|
||||
)
|
||||
list.add(id)
|
||||
})
|
||||
return list
|
||||
}, new Set<string>()),
|
||||
),
|
||||
map(list => list.size),
|
||||
)
|
||||
|
||||
readonly sidebarOpen$ = this.splitPane.sidebarOpen$
|
||||
|
||||
readonly theme$ = inject(THEME)
|
||||
|
||||
readonly warning$ = merge(
|
||||
of(this.config.isTorHttp()),
|
||||
this.patch.watch$('server-info', 'ntp-synced').pipe(map(synced => !synced)),
|
||||
)
|
||||
|
||||
constructor(
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly eosService: EOSService,
|
||||
@Inject(AbstractMarketplaceService)
|
||||
private readonly marketplaceService: MarketplaceService,
|
||||
private readonly splitPane: SplitPaneTracker,
|
||||
private readonly emver: Emver,
|
||||
private readonly connectionService: ConnectionService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
}
|
||||
20
web/projects/ui/src/app/app/menu/menu.module.ts
Normal file
20
web/projects/ui/src/app/app/menu/menu.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { MenuComponent } from './menu.component'
|
||||
import { SnekModule } from '../snek/snek.module'
|
||||
import { ConnectionBarComponentModule } from '../connection-bar/connection-bar.component.module'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule,
|
||||
SnekModule,
|
||||
ConnectionBarComponentModule,
|
||||
],
|
||||
declarations: [MenuComponent],
|
||||
exports: [MenuComponent],
|
||||
})
|
||||
export class MenuModule {}
|
||||
@@ -0,0 +1,83 @@
|
||||
<div style="display: none">
|
||||
<!-- Ionicons -->
|
||||
<ion-icon *ngFor="let icon of icons" [name]="icon"></ion-icon>
|
||||
|
||||
<!-- 3rd party components -->
|
||||
<qr-code value="hello"></qr-code>
|
||||
|
||||
<!-- Ionic components -->
|
||||
<ion-accordion></ion-accordion>
|
||||
<ion-accordion-group></ion-accordion-group>
|
||||
<ion-action-sheet></ion-action-sheet>
|
||||
<ion-alert></ion-alert>
|
||||
<ion-avatar></ion-avatar>
|
||||
<ion-back-button></ion-back-button>
|
||||
<ion-badge></ion-badge>
|
||||
<ion-button></ion-button>
|
||||
<ion-buttons></ion-buttons>
|
||||
<ion-card></ion-card>
|
||||
<ion-card-content></ion-card-content>
|
||||
<ion-card-header></ion-card-header>
|
||||
<ion-checkbox></ion-checkbox>
|
||||
<ion-content></ion-content>
|
||||
<ion-footer></ion-footer>
|
||||
<ion-grid></ion-grid>
|
||||
<ion-header></ion-header>
|
||||
<ion-popover></ion-popover>
|
||||
<ion-content>
|
||||
<ion-refresher slot="fixed"></ion-refresher>
|
||||
<ion-refresher-content pullingContent="lines"></ion-refresher-content>
|
||||
<ion-infinite-scroll></ion-infinite-scroll>
|
||||
<ion-infinite-scroll-content
|
||||
loadingSpinner="lines"
|
||||
></ion-infinite-scroll-content>
|
||||
</ion-content>
|
||||
<ion-input></ion-input>
|
||||
<ion-item></ion-item>
|
||||
<ion-item-divider></ion-item-divider>
|
||||
<ion-item-group></ion-item-group>
|
||||
<ion-label></ion-label>
|
||||
<ion-label style="font-weight: bold"></ion-label>
|
||||
<ion-list></ion-list>
|
||||
<ion-loading></ion-loading>
|
||||
<ion-modal></ion-modal>
|
||||
<ion-menu-button></ion-menu-button>
|
||||
<ion-note></ion-note>
|
||||
<ion-progress-bar></ion-progress-bar>
|
||||
<ion-radio></ion-radio>
|
||||
<ion-row></ion-row>
|
||||
<ion-searchbar></ion-searchbar>
|
||||
<ion-segment></ion-segment>
|
||||
<ion-segment-button></ion-segment-button>
|
||||
<ion-select></ion-select>
|
||||
<ion-select-option></ion-select-option>
|
||||
<ion-spinner name="lines"></ion-spinner>
|
||||
<ion-text></ion-text>
|
||||
<ion-text><strong>load bold font</strong></ion-text>
|
||||
<ion-title></ion-title>
|
||||
<ion-toast></ion-toast>
|
||||
<ion-toggle></ion-toggle>
|
||||
<ion-toolbar></ion-toolbar>
|
||||
|
||||
<!-- images -->
|
||||
<img src="assets/img/icon.png" />
|
||||
<img src="assets/img/community-store.png" />
|
||||
<img src="assets/img/icons/snek.png" />
|
||||
<img src="assets/img/icons/wifi-1.png" />
|
||||
<img src="assets/img/icons/wifi-2.png" />
|
||||
<img src="assets/img/icons/wifi-3.png" />
|
||||
</div>
|
||||
|
||||
<div style="visibility: hidden; height: 0">
|
||||
<!-- fonts -->
|
||||
<p style="font-family: Courier New">a</p>
|
||||
<p style="font-family: Courier New; font-weight: bold">a</p>
|
||||
<p style="font-family: Montserrat">a</p>
|
||||
<p style="font-family: Montserrat; font-weight: bold">a</p>
|
||||
<p style="font-family: Montserrat; font-weight: 100">a</p>
|
||||
<p style="font-family: Open Sans">a</p>
|
||||
<p style="font-family: Open Sans; font-weight: bold">a</p>
|
||||
<p style="font-family: Open Sans; font-weight: 600">a</p>
|
||||
<p style="font-family: Open Sans; font-weight: 100">a</p>
|
||||
<p style="font-family: Redacted">a</p>
|
||||
</div>
|
||||
102
web/projects/ui/src/app/app/preloader/preloader.component.ts
Normal file
102
web/projects/ui/src/app/app/preloader/preloader.component.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
|
||||
// TODO: Turn into DI token if this is needed someplace else too
|
||||
const ICONS = [
|
||||
'add',
|
||||
'alarm-outline',
|
||||
'alert-outline',
|
||||
'alert-circle-outline',
|
||||
'aperture-outline',
|
||||
'archive-outline',
|
||||
'arrow-back',
|
||||
'arrow-forward',
|
||||
'arrow-up',
|
||||
'brush-outline',
|
||||
'bookmark-outline',
|
||||
'cellular-outline',
|
||||
'chatbubbles-outline',
|
||||
'checkmark',
|
||||
'chevron-down',
|
||||
'chevron-up',
|
||||
'chevron-forward',
|
||||
'close',
|
||||
'close-circle-outline',
|
||||
'cloud-outline',
|
||||
'cloud-done',
|
||||
'cloud-done-outline',
|
||||
'cloud-download-outline',
|
||||
'cloud-offline-outline',
|
||||
'cloud-upload-outline',
|
||||
'code-outline',
|
||||
'color-wand-outline',
|
||||
'construct-outline',
|
||||
'copy-outline',
|
||||
'desktop-outline',
|
||||
'download-outline',
|
||||
'duplicate-outline',
|
||||
'earth-outline',
|
||||
'ellipsis-horizontal',
|
||||
'eye-off-outline',
|
||||
'eye-outline',
|
||||
'file-tray-stacked-outline',
|
||||
'finger-print-outline',
|
||||
'flash-outline',
|
||||
'flask-outline',
|
||||
'flash-off-outline',
|
||||
'folder-open-outline',
|
||||
'globe-outline',
|
||||
'grid-outline',
|
||||
'hammer-outline',
|
||||
'help-circle-outline',
|
||||
'hammer-outline',
|
||||
'information-circle-outline',
|
||||
'key-outline',
|
||||
'list-outline',
|
||||
'log-out-outline',
|
||||
'logo-bitcoin',
|
||||
'mail-outline',
|
||||
'map-outline',
|
||||
'medkit-outline',
|
||||
'notifications-outline',
|
||||
'open-outline',
|
||||
'options-outline',
|
||||
'pencil',
|
||||
'phone-portrait-outline',
|
||||
'play-circle-outline',
|
||||
'play-outline',
|
||||
'power',
|
||||
'pricetag-outline',
|
||||
'pulse',
|
||||
'push-outline',
|
||||
'qr-code-outline',
|
||||
'receipt-outline',
|
||||
'refresh',
|
||||
'reload',
|
||||
'remove',
|
||||
'remove-circle-outline',
|
||||
'remove-outline',
|
||||
'repeat-outline',
|
||||
'ribbon-outline',
|
||||
'rocket-outline',
|
||||
'save-outline',
|
||||
'server-outline',
|
||||
'settings-outline',
|
||||
'shield-checkmark-outline',
|
||||
'stop-outline',
|
||||
'storefront-outline',
|
||||
'swap-vertical',
|
||||
'terminal-outline',
|
||||
'trash',
|
||||
'trash-outline',
|
||||
'warning-outline',
|
||||
'wifi',
|
||||
]
|
||||
|
||||
@Component({
|
||||
selector: 'section[appPreloader]',
|
||||
templateUrl: 'preloader.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class PreloaderComponent {
|
||||
readonly icons = ICONS
|
||||
}
|
||||
13
web/projects/ui/src/app/app/preloader/preloader.module.ts
Normal file
13
web/projects/ui/src/app/app/preloader/preloader.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { QrCodeModule } from 'ng-qrcode'
|
||||
import { PreloaderComponent } from './preloader.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, IonicModule, QrCodeModule],
|
||||
declarations: [PreloaderComponent],
|
||||
exports: [PreloaderComponent],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
})
|
||||
export class PreloaderModule {}
|
||||
28
web/projects/ui/src/app/app/snek/snake.page.html
Normal file
28
web/projects/ui/src/app/app/snek/snake.page.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Play Snek!</ion-title>
|
||||
<ion-title slot="end">Score: {{ score }}</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<div class="canvas-center" style="width: 100%; height: 100%">
|
||||
<canvas id="game"></canvas>
|
||||
</div>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-title slot="start">High Score: {{ highScore }}</ion-title>
|
||||
<ion-buttons slot="end" class="ion-padding-end">
|
||||
<ion-button
|
||||
fill="solid"
|
||||
color="primary"
|
||||
(click)="dismiss()"
|
||||
class="enter-click btn-128"
|
||||
>
|
||||
Save and Quit
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
6
web/projects/ui/src/app/app/snek/snake.page.scss
Normal file
6
web/projects/ui/src/app/app/snek/snake.page.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
.canvas-center {
|
||||
padding-top: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
255
web/projects/ui/src/app/app/snek/snake.page.ts
Normal file
255
web/projects/ui/src/app/app/snek/snake.page.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { Component, HostListener, Input } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { pauseFor } from '../../../../../shared/src/public-api'
|
||||
|
||||
@Component({
|
||||
selector: 'snake',
|
||||
templateUrl: './snake.page.html',
|
||||
styleUrls: ['./snake.page.scss'],
|
||||
})
|
||||
export class SnakePage {
|
||||
@Input()
|
||||
highScore = 0
|
||||
|
||||
score = 0
|
||||
|
||||
private readonly speed = 45
|
||||
private readonly width = 40
|
||||
private readonly height = 26
|
||||
private grid = NaN
|
||||
|
||||
private readonly startingLength = 4
|
||||
|
||||
private xDown?: number
|
||||
private yDown?: number
|
||||
private canvas!: HTMLCanvasElement
|
||||
private image!: HTMLImageElement
|
||||
private context!: CanvasRenderingContext2D
|
||||
|
||||
private snake: any
|
||||
private bitcoin: { x: number; y: number } = { x: NaN, y: NaN }
|
||||
|
||||
private moveQueue: String[] = []
|
||||
|
||||
constructor(private readonly modalCtrl: ModalController) {}
|
||||
|
||||
async dismiss() {
|
||||
return this.modalCtrl.dismiss({ highScore: this.highScore })
|
||||
}
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
keyEvent(e: KeyboardEvent) {
|
||||
this.moveQueue.push(e.key)
|
||||
}
|
||||
|
||||
@HostListener('touchstart', ['$event'])
|
||||
touchStart(e: TouchEvent) {
|
||||
this.handleTouchStart(e)
|
||||
}
|
||||
|
||||
@HostListener('touchmove', ['$event'])
|
||||
touchMove(e: TouchEvent) {
|
||||
this.handleTouchMove(e)
|
||||
}
|
||||
|
||||
@HostListener('window:resize')
|
||||
sizeChange() {
|
||||
this.init()
|
||||
}
|
||||
|
||||
ionViewDidEnter() {
|
||||
this.init()
|
||||
|
||||
this.image = new Image()
|
||||
this.image.onload = () => {
|
||||
requestAnimationFrame(async () => await this.loop())
|
||||
}
|
||||
this.image.src = '../../../../../../assets/img/icons/bitcoin.svg'
|
||||
}
|
||||
|
||||
init() {
|
||||
this.canvas = document.querySelector('canvas#game')!
|
||||
this.canvas.style.border = '1px solid #e0e0e0'
|
||||
this.context = this.canvas.getContext('2d')!
|
||||
const container = document.getElementsByClassName('canvas-center')[0]
|
||||
this.grid = Math.min(
|
||||
Math.floor(container.clientWidth / this.width),
|
||||
Math.floor(container.clientHeight / this.height),
|
||||
)
|
||||
this.snake = {
|
||||
x: this.grid * (Math.floor(this.width / 2) - this.startingLength),
|
||||
y: this.grid * Math.floor(this.height / 2),
|
||||
// snake velocity. moves one grid length every frame in either the x or y direction
|
||||
dx: this.grid,
|
||||
dy: 0,
|
||||
// keep track of all grids the snake body occupies
|
||||
cells: [],
|
||||
// length of the snake. grows when eating an bitcoin
|
||||
maxCells: this.startingLength,
|
||||
}
|
||||
this.bitcoin = {
|
||||
x: this.getRandomInt(0, this.width) * this.grid,
|
||||
y: this.getRandomInt(0, this.height) * this.grid,
|
||||
}
|
||||
|
||||
this.canvas.width = this.grid * this.width
|
||||
this.canvas.height = this.grid * this.height
|
||||
this.context.imageSmoothingEnabled = false
|
||||
}
|
||||
|
||||
getTouches(evt: TouchEvent) {
|
||||
return evt.touches
|
||||
}
|
||||
|
||||
handleTouchStart(evt: TouchEvent) {
|
||||
const firstTouch = this.getTouches(evt)[0]
|
||||
this.xDown = firstTouch.clientX
|
||||
this.yDown = firstTouch.clientY
|
||||
}
|
||||
|
||||
handleTouchMove(evt: TouchEvent) {
|
||||
if (!this.xDown || !this.yDown) {
|
||||
return
|
||||
}
|
||||
|
||||
var xUp = evt.touches[0].clientX
|
||||
var yUp = evt.touches[0].clientY
|
||||
|
||||
var xDiff = this.xDown - xUp
|
||||
var yDiff = this.yDown - yUp
|
||||
|
||||
if (Math.abs(xDiff) > Math.abs(yDiff)) {
|
||||
/*most significant*/
|
||||
if (xDiff > 0) {
|
||||
this.moveQueue.push('ArrowLeft')
|
||||
} else {
|
||||
this.moveQueue.push('ArrowRight')
|
||||
}
|
||||
} else {
|
||||
if (yDiff > 0) {
|
||||
this.moveQueue.push('ArrowUp')
|
||||
} else {
|
||||
this.moveQueue.push('ArrowDown')
|
||||
}
|
||||
}
|
||||
/* reset values */
|
||||
this.xDown = undefined
|
||||
this.yDown = undefined
|
||||
}
|
||||
|
||||
// game loop
|
||||
async loop() {
|
||||
await pauseFor(this.speed)
|
||||
|
||||
requestAnimationFrame(async () => await this.loop())
|
||||
|
||||
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height)
|
||||
|
||||
// move snake by it's velocity
|
||||
this.snake.x += this.snake.dx
|
||||
this.snake.y += this.snake.dy
|
||||
|
||||
if (this.moveQueue.length) {
|
||||
const move = this.moveQueue.shift()
|
||||
// left arrow key
|
||||
if (move === 'ArrowLeft' && this.snake.dx === 0) {
|
||||
this.snake.dx = -this.grid
|
||||
this.snake.dy = 0
|
||||
}
|
||||
// up arrow key
|
||||
else if (move === 'ArrowUp' && this.snake.dy === 0) {
|
||||
this.snake.dy = -this.grid
|
||||
this.snake.dx = 0
|
||||
}
|
||||
// right arrow key
|
||||
else if (move === 'ArrowRight' && this.snake.dx === 0) {
|
||||
this.snake.dx = this.grid
|
||||
this.snake.dy = 0
|
||||
}
|
||||
// down arrow key
|
||||
else if (move === 'ArrowDown' && this.snake.dy === 0) {
|
||||
this.snake.dy = this.grid
|
||||
this.snake.dx = 0
|
||||
}
|
||||
}
|
||||
|
||||
// edge death
|
||||
if (
|
||||
this.snake.x < 0 ||
|
||||
this.snake.y < 0 ||
|
||||
this.snake.x >= this.canvas.width ||
|
||||
this.snake.y >= this.canvas.height
|
||||
) {
|
||||
this.death()
|
||||
}
|
||||
|
||||
// keep track of where snake has been. front of the array is always the head
|
||||
this.snake.cells.unshift({ x: this.snake.x, y: this.snake.y })
|
||||
|
||||
// remove cells as we move away from them
|
||||
if (this.snake.cells.length > this.snake.maxCells) {
|
||||
this.snake.cells.pop()
|
||||
}
|
||||
|
||||
// draw bitcoin
|
||||
this.context.fillStyle = '#ff4961'
|
||||
this.context.drawImage(
|
||||
this.image,
|
||||
this.bitcoin.x - 1,
|
||||
this.bitcoin.y - 1,
|
||||
this.grid + 2,
|
||||
this.grid + 2,
|
||||
)
|
||||
|
||||
// draw snake one cell at a time
|
||||
this.context.fillStyle = '#2fdf75'
|
||||
|
||||
const firstCell = this.snake.cells[0]
|
||||
|
||||
for (let index = 0; index < this.snake.cells.length; index++) {
|
||||
const cell = this.snake.cells[index]
|
||||
|
||||
// drawing 1 px smaller than the grid creates a grid effect in the snake body so you can see how long it is
|
||||
this.context.fillRect(cell.x, cell.y, this.grid - 1, this.grid - 1)
|
||||
|
||||
// snake ate bitcoin
|
||||
if (cell.x === this.bitcoin.x && cell.y === this.bitcoin.y) {
|
||||
this.score++
|
||||
this.highScore = Math.max(this.score, this.highScore)
|
||||
this.snake.maxCells++
|
||||
|
||||
this.bitcoin.x = this.getRandomInt(0, this.width) * this.grid
|
||||
this.bitcoin.y = this.getRandomInt(0, this.height) * this.grid
|
||||
}
|
||||
|
||||
if (index > 0) {
|
||||
// check collision with all cells after this one (modified bubble sort)
|
||||
// snake occupies same space as a body part. reset game
|
||||
if (
|
||||
firstCell.x === this.snake.cells[index].x &&
|
||||
firstCell.y === this.snake.cells[index].y
|
||||
) {
|
||||
this.death()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
death() {
|
||||
this.snake.x =
|
||||
this.grid * (Math.floor(this.width / 2) - this.startingLength)
|
||||
this.snake.y = this.grid * Math.floor(this.height / 2)
|
||||
this.snake.cells = []
|
||||
this.snake.maxCells = this.startingLength
|
||||
this.snake.dx = this.grid
|
||||
this.snake.dy = 0
|
||||
|
||||
this.bitcoin.x = this.getRandomInt(0, 25) * this.grid
|
||||
this.bitcoin.y = this.getRandomInt(0, 25) * this.grid
|
||||
this.score = 0
|
||||
}
|
||||
|
||||
getRandomInt(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min)) + min
|
||||
}
|
||||
}
|
||||
53
web/projects/ui/src/app/app/snek/snek.directive.ts
Normal file
53
web/projects/ui/src/app/app/snek/snek.directive.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { Directive, HostListener, Input } from '@angular/core'
|
||||
import { LoadingController, ModalController } from '@ionic/angular'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { SnakePage } from './snake.page'
|
||||
|
||||
@Directive({
|
||||
selector: 'img[appSnek]',
|
||||
})
|
||||
export class SnekDirective {
|
||||
@Input()
|
||||
appSnekHighScore: number | null = null
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly embassyApi: ApiService,
|
||||
) {}
|
||||
|
||||
@HostListener('click')
|
||||
async onClick() {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: SnakePage,
|
||||
cssClass: 'snake-modal',
|
||||
backdropDismiss: false,
|
||||
componentProps: { highScore: this.appSnekHighScore || 0 },
|
||||
})
|
||||
|
||||
modal.onDidDismiss().then(async ({ data }) => {
|
||||
if (data?.highScore <= (this.appSnekHighScore || 0)) return
|
||||
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Saving high score...',
|
||||
})
|
||||
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.embassyApi.setDbValue<number>(
|
||||
['gaming', 'snake', 'high-score'],
|
||||
data.highScore,
|
||||
)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
this.loadingCtrl.dismiss()
|
||||
}
|
||||
})
|
||||
|
||||
modal.present()
|
||||
}
|
||||
}
|
||||
13
web/projects/ui/src/app/app/snek/snek.module.ts
Normal file
13
web/projects/ui/src/app/app/snek/snek.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
|
||||
import { SnekDirective } from './snek.directive'
|
||||
import { SnakePage } from './snake.page'
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, IonicModule],
|
||||
declarations: [SnekDirective, SnakePage],
|
||||
exports: [SnekDirective, SnakePage],
|
||||
})
|
||||
export class SnekModule {}
|
||||
17
web/projects/ui/src/app/apps/loading/loading.module.ts
Normal file
17
web/projects/ui/src/app/apps/loading/loading.module.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { LoadingModule } from '@start9labs/shared'
|
||||
import { LoadingPage } from './loading.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: LoadingPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [LoadingModule, RouterModule.forChild(routes)],
|
||||
declarations: [LoadingPage],
|
||||
})
|
||||
export class LoadingPageModule {}
|
||||
4
web/projects/ui/src/app/apps/loading/loading.page.html
Normal file
4
web/projects/ui/src/app/apps/loading/loading.page.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<app-loading
|
||||
class="ion-page"
|
||||
(finished)="navCtrl.navigateForward('/login')"
|
||||
></app-loading>
|
||||
19
web/projects/ui/src/app/apps/loading/loading.page.ts
Normal file
19
web/projects/ui/src/app/apps/loading/loading.page.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import {
|
||||
provideSetupLogsService,
|
||||
provideSetupService,
|
||||
} from '@start9labs/shared'
|
||||
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
|
||||
@Component({
|
||||
templateUrl: 'loading.page.html',
|
||||
providers: [
|
||||
provideSetupService(ApiService),
|
||||
provideSetupLogsService(ApiService),
|
||||
],
|
||||
})
|
||||
export class LoadingPage {
|
||||
readonly navCtrl = inject(NavController)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<div class="center-container">
|
||||
<ng-container *ngIf="!caTrusted; else trusted">
|
||||
<ion-card id="untrusted" class="text-center">
|
||||
<ion-icon name="lock-closed-outline" class="wiz-icon"></ion-icon>
|
||||
<h1>Trust Your Root CA</h1>
|
||||
<p>
|
||||
Download and trust your server's Root Certificate Authority to establish
|
||||
a secure (HTTPS) connection. You will need to repeat this on every
|
||||
device you use to connect to your server.
|
||||
</p>
|
||||
<ol>
|
||||
<li>
|
||||
<b>Bookmark this page</b>
|
||||
- Save this page so you can access it later. You can also find the
|
||||
address in the
|
||||
<code>StartOS-info.html</code>
|
||||
file downloaded at the end of initial setup.
|
||||
</li>
|
||||
<li>
|
||||
<b>Download your server's Root CA</b>
|
||||
- Your server uses its Root CA to generate SSL/TLS certificates for
|
||||
itself and installed services. These certificates are then used to
|
||||
encrypt network traffic with your client devices.
|
||||
<br />
|
||||
<ion-button
|
||||
strong
|
||||
size="small"
|
||||
shape="round"
|
||||
color="tertiary"
|
||||
(click)="download()"
|
||||
>
|
||||
Download
|
||||
<ion-icon slot="end" name="download-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</li>
|
||||
<li>
|
||||
<b>Trust your server's Root CA</b>
|
||||
- Follow instructions for your OS. By trusting your server's Root CA,
|
||||
your device can verify the authenticity of encrypted communications
|
||||
with your server.
|
||||
<br />
|
||||
<ion-button
|
||||
strong
|
||||
size="small"
|
||||
shape="round"
|
||||
color="primary"
|
||||
href="https://docs.start9.com/0.3.5.x/user-manual/trust-ca#establishing-trust"
|
||||
target="_blank"
|
||||
noreferrer
|
||||
>
|
||||
View Instructions
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</li>
|
||||
<li>
|
||||
<b>Test</b>
|
||||
- Refresh the page. If refreshing the page does not work, you may need
|
||||
to quit and re-open your browser, then revisit this page.
|
||||
<br />
|
||||
<ion-button
|
||||
strong
|
||||
size="small"
|
||||
shape="round"
|
||||
class="refresh"
|
||||
(click)="refresh()"
|
||||
>
|
||||
Refresh
|
||||
<ion-icon slot="end" name="refresh"></ion-icon>
|
||||
</ion-button>
|
||||
</li>
|
||||
</ol>
|
||||
<ion-button fill="clear" (click)="launchHttps()" [disabled]="caTrusted">
|
||||
Skip
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<span class="skip_detail">(not recommended)</span>
|
||||
</ion-card>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #trusted>
|
||||
<ion-card id="trusted" class="text-center">
|
||||
<ion-icon
|
||||
name="shield-checkmark-outline"
|
||||
class="wiz-icon"
|
||||
color="success"
|
||||
></ion-icon>
|
||||
<h1>Root CA Trusted!</h1>
|
||||
<p>
|
||||
You have successfully trusted your server's Root CA and may now log in
|
||||
securely.
|
||||
</p>
|
||||
<ion-button strong (click)="launchHttps()" color="tertiary" shape="round">
|
||||
Go to login
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-card>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
<a
|
||||
id="install-cert"
|
||||
href="/eos/local.crt"
|
||||
[download]="
|
||||
config.isLocal() ? document.location.hostname + '.crt' : 'startos.crt'
|
||||
"
|
||||
></a>
|
||||
@@ -0,0 +1,83 @@
|
||||
#trusted {
|
||||
max-width: 40%;
|
||||
}
|
||||
|
||||
#untrusted {
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.center-container {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
ion-card {
|
||||
color: var(--ion-color-dark);
|
||||
background: #414141;
|
||||
box-shadow: 0 4px 4px rgba(17, 17, 17, 0.144);
|
||||
border-radius: 35px;
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
|
||||
h1 {
|
||||
font-weight: bold;
|
||||
font-size: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 21px;
|
||||
line-height: 25px;
|
||||
margin-bottom: 30px;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
ol {
|
||||
font-size: 17px;
|
||||
line-height: 25px;
|
||||
text-align: left;
|
||||
|
||||
li {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
ion-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.refresh {
|
||||
--background: var(--ion-color-success-shade);
|
||||
}
|
||||
|
||||
.wiz-icon {
|
||||
font-size: 64px;
|
||||
}
|
||||
|
||||
.skip_detail {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
margin-top: -13px;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
#trusted, #untrusted {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 701px) and (max-width: 1200px) {
|
||||
#trusted, #untrusted {
|
||||
max-width: 75%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Component, Inject } from '@angular/core'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { RELATIVE_URL } from '@start9labs/shared'
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
import { WINDOW } from '@ng-web-apis/common'
|
||||
|
||||
@Component({
|
||||
selector: 'ca-wizard',
|
||||
templateUrl: './ca-wizard.component.html',
|
||||
styleUrls: ['./ca-wizard.component.scss'],
|
||||
})
|
||||
export class CAWizardComponent {
|
||||
caTrusted = false
|
||||
|
||||
constructor(
|
||||
private readonly api: ApiService,
|
||||
public readonly config: ConfigService,
|
||||
@Inject(RELATIVE_URL) private readonly relativeUrl: string,
|
||||
@Inject(DOCUMENT) public readonly document: Document,
|
||||
@Inject(WINDOW) private readonly windowRef: Window,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.testHttps().catch(e =>
|
||||
console.warn('Failed Https connection attempt'),
|
||||
)
|
||||
}
|
||||
|
||||
download() {
|
||||
this.document.getElementById('install-cert')?.click()
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.document.location.reload()
|
||||
}
|
||||
|
||||
launchHttps() {
|
||||
const host = this.config.getHost()
|
||||
this.windowRef.open(`https://${host}`, '_self')
|
||||
}
|
||||
|
||||
private async testHttps() {
|
||||
const url = `https://${this.document.location.host}${this.relativeUrl}`
|
||||
await this.api.echo({ message: 'ping' }, url).then(() => {
|
||||
this.caTrusted = true
|
||||
})
|
||||
}
|
||||
}
|
||||
30
web/projects/ui/src/app/apps/login/login.module.ts
Normal file
30
web/projects/ui/src/app/apps/login/login.module.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { LoginPage } from './login.page'
|
||||
import { CAWizardComponent } from './ca-wizard/ca-wizard.component'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
import { TuiHintModule, TuiTooltipModule } from '@taiga-ui/core'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: LoginPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
SharedPipesModule,
|
||||
RouterModule.forChild(routes),
|
||||
TuiTooltipModule,
|
||||
TuiHintModule,
|
||||
],
|
||||
declarations: [LoginPage, CAWizardComponent],
|
||||
})
|
||||
export class LoginPageModule {}
|
||||
93
web/projects/ui/src/app/apps/login/login.page.html
Normal file
93
web/projects/ui/src/app/apps/login/login.page.html
Normal file
@@ -0,0 +1,93 @@
|
||||
<ion-content class="content">
|
||||
<!-- Local HTTP -->
|
||||
<ng-container *ngIf="config.isLanHttp(); else notLanHttp">
|
||||
<ca-wizard></ca-wizard>
|
||||
</ng-container>
|
||||
|
||||
<!-- not Local HTTP -->
|
||||
<ng-template #notLanHttp>
|
||||
<div *ngIf="config.isTorHttp()" class="banner">
|
||||
<ion-item color="warning">
|
||||
<ion-icon slot="start" name="warning-outline"></ion-icon>
|
||||
<ion-label>
|
||||
<h2 style="font-weight: bold">Http detected</h2>
|
||||
<p style="font-weight: 600">
|
||||
Tor is faster over https. Your Root CA must be trusted.
|
||||
<a
|
||||
href="https://docs.start9.com/0.3.5.x/user-manual/trust-ca"
|
||||
target="_blank"
|
||||
noreferrer
|
||||
style="color: black"
|
||||
>
|
||||
View instructions
|
||||
</a>
|
||||
</p>
|
||||
</ion-label>
|
||||
<ion-button slot="end" color="light" (click)="launchHttps()">
|
||||
Open Https
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
</div>
|
||||
|
||||
<ion-grid class="grid">
|
||||
<ion-row class="row">
|
||||
<ion-col>
|
||||
<ion-card>
|
||||
<img
|
||||
alt="StartOS Icon"
|
||||
class="header-icon"
|
||||
src="assets/img/icon.png"
|
||||
/>
|
||||
<ion-card-header>
|
||||
<ion-card-title class="title">Login to StartOS</ion-card-title>
|
||||
</ion-card-header>
|
||||
<ion-card-content class="ion-margin">
|
||||
<form (submit)="submit()">
|
||||
<ion-item color="dark" fill="solid">
|
||||
<ion-icon
|
||||
slot="start"
|
||||
size="small"
|
||||
color="base"
|
||||
name="key-outline"
|
||||
style="margin-right: 16px"
|
||||
></ion-icon>
|
||||
<ion-input
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
[type]="unmasked ? 'text' : 'password'"
|
||||
[(ngModel)]="password"
|
||||
(ionChange)="error = ''"
|
||||
></ion-input>
|
||||
<ion-button
|
||||
slot="end"
|
||||
fill="clear"
|
||||
color="dark"
|
||||
(click)="unmasked = !unmasked"
|
||||
>
|
||||
<ion-icon
|
||||
slot="icon-only"
|
||||
size="small"
|
||||
[name]="unmasked ? 'eye-off-outline' : 'eye-outline'"
|
||||
></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
<p class="error ion-text-center">
|
||||
<ion-text color="danger">{{ error }}</ion-text>
|
||||
</p>
|
||||
<ion-button
|
||||
class="login-button"
|
||||
type="submit"
|
||||
expand="block"
|
||||
color="tertiary"
|
||||
>
|
||||
Login
|
||||
</ion-button>
|
||||
</form>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ng-template>
|
||||
</ion-content>
|
||||
76
web/projects/ui/src/app/apps/login/login.page.scss
Normal file
76
web/projects/ui/src/app/apps/login/login.page.scss
Normal file
@@ -0,0 +1,76 @@
|
||||
.content {
|
||||
--background: #333333;
|
||||
}
|
||||
|
||||
.grid {
|
||||
height: 100%;
|
||||
max-width: 540px;
|
||||
}
|
||||
|
||||
.row {
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.banner {
|
||||
position: absolute;
|
||||
padding: 20px;
|
||||
width: 100%;
|
||||
display: inline-block;
|
||||
|
||||
ion-item {
|
||||
max-width: 800px;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
ion-card {
|
||||
background: #414141;
|
||||
box-shadow: 0 4px 4px rgba(17, 17, 17, 0.144);
|
||||
border-radius: 35px;
|
||||
min-height: 16rem;
|
||||
contain: unset;
|
||||
overflow: unset;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
ion-item {
|
||||
--background: transparent;
|
||||
--border-radius: 0px;
|
||||
}
|
||||
|
||||
.title {
|
||||
padding-top: 55px;
|
||||
color: #e0e0e0;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
&-icon {
|
||||
width: 100px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-left: -50px;
|
||||
top: -17%;
|
||||
z-index: 100;
|
||||
}
|
||||
}
|
||||
|
||||
.login-button {
|
||||
height: 45px;
|
||||
width: 120px;
|
||||
--border-radius: 50px;
|
||||
margin: 0 auto;
|
||||
margin-top: 27px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.item-interactive {
|
||||
--highlight-background: #5260ff !important;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: block;
|
||||
padding-top: 4px;
|
||||
}
|
||||
64
web/projects/ui/src/app/apps/login/login.page.ts
Normal file
64
web/projects/ui/src/app/apps/login/login.page.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Component, Inject } from '@angular/core'
|
||||
import { getPlatforms, LoadingController } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { AuthService } from 'src/app/services/auth.service'
|
||||
import { Router } from '@angular/router'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
import { WINDOW } from '@ng-web-apis/common'
|
||||
|
||||
@Component({
|
||||
selector: 'login',
|
||||
templateUrl: './login.page.html',
|
||||
styleUrls: ['./login.page.scss'],
|
||||
})
|
||||
export class LoginPage {
|
||||
password = ''
|
||||
unmasked = false
|
||||
error = ''
|
||||
|
||||
constructor(
|
||||
private readonly router: Router,
|
||||
private readonly authService: AuthService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly api: ApiService,
|
||||
public readonly config: ConfigService,
|
||||
@Inject(DOCUMENT) public readonly document: Document,
|
||||
@Inject(WINDOW) private readonly windowRef: Window,
|
||||
) {}
|
||||
|
||||
launchHttps() {
|
||||
const host = this.config.getHost()
|
||||
this.windowRef.open(`https://${host}`, '_self')
|
||||
}
|
||||
|
||||
async submit() {
|
||||
this.error = ''
|
||||
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Logging in...',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
document.cookie = ''
|
||||
if (this.password.length > 64) {
|
||||
this.error = 'Password must be less than 65 characters'
|
||||
return
|
||||
}
|
||||
await this.api.login({
|
||||
password: this.password,
|
||||
metadata: { platforms: getPlatforms() },
|
||||
})
|
||||
|
||||
this.password = ''
|
||||
this.authService.setVerified()
|
||||
this.router.navigate([''], { replaceUrl: true })
|
||||
} catch (e: any) {
|
||||
// code 7 is for incorrect password
|
||||
this.error = e.code === 7 ? 'Invalid Password' : e.message
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { BackupReportPage } from './backup-report.page'
|
||||
|
||||
@NgModule({
|
||||
declarations: [BackupReportPage],
|
||||
imports: [CommonModule, IonicModule],
|
||||
exports: [BackupReportPage],
|
||||
})
|
||||
export class BackupReportPageModule {}
|
||||
@@ -0,0 +1,44 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Backup Report</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<ion-item-group>
|
||||
<ion-item-divider>
|
||||
Completed: {{ timestamp | date : 'medium' }}
|
||||
</ion-item-divider>
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>System data</h2>
|
||||
<p><ion-text [color]="system.color">{{ system.result }}</ion-text></p>
|
||||
</ion-label>
|
||||
<ion-icon
|
||||
slot="end"
|
||||
[name]="system.icon"
|
||||
[color]="system.color"
|
||||
></ion-icon>
|
||||
</ion-item>
|
||||
<ion-item *ngFor="let pkg of report?.packages | keyvalue">
|
||||
<ion-label>
|
||||
<h2>{{ pkg.key }}</h2>
|
||||
<p>
|
||||
<ion-text [color]="pkg.value.error ? 'danger' : 'success'">
|
||||
{{ pkg.value.error ? 'Failed: ' + pkg.value.error : 'Succeeded' }}
|
||||
</ion-text>
|
||||
</p>
|
||||
</ion-label>
|
||||
<ion-icon
|
||||
slot="end"
|
||||
[name]="pkg.value.error ? 'remove-circle-outline' : 'checkmark'"
|
||||
[color]="pkg.value.error ? 'danger' : 'success'"
|
||||
></ion-icon>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { BackupReport } from 'src/app/services/api/api.types'
|
||||
|
||||
@Component({
|
||||
selector: 'backup-report',
|
||||
templateUrl: './backup-report.page.html',
|
||||
})
|
||||
export class BackupReportPage {
|
||||
@Input() report!: BackupReport
|
||||
@Input() timestamp!: string
|
||||
|
||||
system!: {
|
||||
result: string
|
||||
icon: 'remove' | 'remove-circle-outline' | 'checkmark'
|
||||
color: 'dark' | 'danger' | 'success'
|
||||
}
|
||||
|
||||
constructor(private readonly modalCtrl: ModalController) {}
|
||||
|
||||
ngOnInit() {
|
||||
if (!this.report.server.attempted) {
|
||||
this.system = {
|
||||
result: 'Not Attempted',
|
||||
icon: 'remove',
|
||||
color: 'dark',
|
||||
}
|
||||
} else if (this.report.server.error) {
|
||||
this.system = {
|
||||
result: `Failed: ${this.report.server.error}`,
|
||||
icon: 'remove-circle-outline',
|
||||
color: 'danger',
|
||||
}
|
||||
} else {
|
||||
this.system = {
|
||||
result: 'Succeeded',
|
||||
icon: 'checkmark',
|
||||
color: 'success',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async dismiss() {
|
||||
return this.modalCtrl.dismiss(true)
|
||||
}
|
||||
}
|
||||
21
web/projects/ui/src/app/apps/ui/modals/form/form.module.ts
Normal file
21
web/projects/ui/src/app/apps/ui/modals/form/form.module.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ReactiveFormsModule } from '@angular/forms'
|
||||
import { TuiValueChangesModule } from '@taiga-ui/cdk'
|
||||
import { TuiButtonModule, TuiModeModule } from '@taiga-ui/core'
|
||||
import { FormModule } from 'src/app/common/form/form.module'
|
||||
import { FormPage } from './form.page'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
ReactiveFormsModule,
|
||||
TuiValueChangesModule,
|
||||
TuiButtonModule,
|
||||
TuiModeModule,
|
||||
FormModule,
|
||||
],
|
||||
declarations: [FormPage],
|
||||
exports: [FormPage],
|
||||
})
|
||||
export class FormPageModule {}
|
||||
20
web/projects/ui/src/app/apps/ui/modals/form/form.page.html
Normal file
20
web/projects/ui/src/app/apps/ui/modals/form/form.page.html
Normal file
@@ -0,0 +1,20 @@
|
||||
<form
|
||||
(submit.capture.prevent)="0"
|
||||
(reset.capture.prevent.stop)="onReset()"
|
||||
[formGroup]="form"
|
||||
(tuiValueChanges)="markAsDirty()"
|
||||
>
|
||||
<form-group [spec]="spec"></form-group>
|
||||
<footer tuiMode="onDark">
|
||||
<ng-content></ng-content>
|
||||
<button
|
||||
*ngFor="let button of buttons; let last = last"
|
||||
tuiButton
|
||||
[appearance]="last ? 'primary' : 'flat'"
|
||||
[type]="last ? 'submit' : 'button'"
|
||||
(click)="onClick(button.handler)"
|
||||
>
|
||||
{{ button.text }}
|
||||
</button>
|
||||
</footer>
|
||||
</form>
|
||||
12
web/projects/ui/src/app/apps/ui/modals/form/form.page.scss
Normal file
12
web/projects/ui/src/app/apps/ui/modals/form/form.page.scss
Normal file
@@ -0,0 +1,12 @@
|
||||
footer {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 1rem 0;
|
||||
margin: 1rem 0 -1rem;
|
||||
gap: 1rem;
|
||||
background: var(--tui-elevation-01);
|
||||
border-top: 1px solid var(--tui-base-02);
|
||||
}
|
||||
96
web/projects/ui/src/app/apps/ui/modals/form/form.page.ts
Normal file
96
web/projects/ui/src/app/apps/ui/modals/form/form.page.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
OnInit,
|
||||
} from '@angular/core'
|
||||
import { InputSpec } from '@start9labs/start-sdk/lib/config/configTypes'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
import { tuiMarkControlAsTouchedAndValidate } from '@taiga-ui/cdk'
|
||||
import { TuiDialogFormService } from '@taiga-ui/kit'
|
||||
import { FormGroup } from '@angular/forms'
|
||||
import { compare, Operation } from 'fast-json-patch'
|
||||
import { InvalidService } from 'src/app/common/form/invalid.service'
|
||||
import { FormService } from 'src/app/services/form.service'
|
||||
|
||||
export interface ActionButton<T> {
|
||||
text: string
|
||||
handler: (value: T) => Promise<boolean | void> | void
|
||||
}
|
||||
|
||||
export interface FormContext<T> {
|
||||
spec: InputSpec
|
||||
buttons: ActionButton<T>[]
|
||||
value?: T
|
||||
patch?: Operation[]
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'form-page',
|
||||
templateUrl: './form.page.html',
|
||||
styleUrls: ['./form.page.scss'],
|
||||
providers: [InvalidService],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FormPage<T extends Record<string, any>> implements OnInit {
|
||||
private readonly dialogFormService = inject(TuiDialogFormService)
|
||||
private readonly formService = inject(FormService)
|
||||
private readonly invalidService = inject(InvalidService)
|
||||
private readonly context = inject<TuiDialogContext<void, FormContext<T>>>(
|
||||
POLYMORPHEUS_CONTEXT,
|
||||
{ optional: true },
|
||||
)
|
||||
|
||||
@Input() spec = this.context?.data.spec || {}
|
||||
@Input() buttons = this.context?.data.buttons || []
|
||||
@Input() patch = this.context?.data.patch || []
|
||||
@Input() value?: T = this.context?.data.value
|
||||
|
||||
form = new FormGroup({})
|
||||
|
||||
ngOnInit() {
|
||||
this.dialogFormService.markAsPristine()
|
||||
this.form = this.formService.createForm(this.spec, this.value)
|
||||
this.process(this.patch)
|
||||
}
|
||||
|
||||
onReset() {
|
||||
const { value } = this.form
|
||||
|
||||
this.form = this.formService.createForm(this.spec)
|
||||
this.process(compare(this.form.value, value))
|
||||
tuiMarkControlAsTouchedAndValidate(this.form)
|
||||
this.markAsDirty()
|
||||
}
|
||||
|
||||
async onClick(handler: ActionButton<T>['handler']) {
|
||||
tuiMarkControlAsTouchedAndValidate(this.form)
|
||||
this.invalidService.scrollIntoView()
|
||||
|
||||
if (this.form.valid && (await handler(this.form.value as T))) {
|
||||
this.context?.$implicit.complete()
|
||||
}
|
||||
}
|
||||
|
||||
markAsDirty() {
|
||||
this.dialogFormService.markAsDirty()
|
||||
}
|
||||
|
||||
private process(patch: Operation[]) {
|
||||
patch.forEach(({ op, path }) => {
|
||||
const control = this.form.get(path.substring(1).split('/'))
|
||||
|
||||
if (!control || !control.parent) return
|
||||
|
||||
if (op !== 'remove') {
|
||||
control.markAsDirty()
|
||||
control.markAsTouched()
|
||||
}
|
||||
|
||||
control.parent.markAsDirty()
|
||||
control.parent.markAsTouched()
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<ion-content>
|
||||
<div
|
||||
style="margin: 24px 24px 12px 24px; display: flex; flex-direction: column"
|
||||
>
|
||||
<ion-item style="padding-bottom: 8px">
|
||||
<ion-label>
|
||||
<h1>{{ options.title }}</h1>
|
||||
<br />
|
||||
<p>{{ options.message }}</p>
|
||||
<ng-container *ngIf="options.warning">
|
||||
<br />
|
||||
<p>
|
||||
<ion-text color="warning">{{ options.warning }}</ion-text>
|
||||
</p>
|
||||
</ng-container>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<form (ngSubmit)="submit()">
|
||||
<div style="margin: 0 0 24px 16px">
|
||||
<p class="input-label">{{ options.label }}</p>
|
||||
<ion-item
|
||||
lines="none"
|
||||
[color]="(theme$ | async) === 'Light' ? 'light' : 'dark'"
|
||||
>
|
||||
<ion-input
|
||||
#mainInput
|
||||
type="text"
|
||||
name="value"
|
||||
[ngModel]="masked ? maskedValue : value"
|
||||
(ngModelChange)="transformInput($event)"
|
||||
[placeholder]="options.placeholder"
|
||||
(ionChange)="error = ''"
|
||||
></ion-input>
|
||||
<ion-button
|
||||
slot="end"
|
||||
*ngIf="options.useMask"
|
||||
fill="clear"
|
||||
color="light"
|
||||
(click)="toggleMask()"
|
||||
>
|
||||
<ion-icon
|
||||
slot="icon-only"
|
||||
[name]="!masked ? 'eye-off-outline' : 'eye-outline'"
|
||||
size="small"
|
||||
></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
<!-- error -->
|
||||
<p *ngIf="error">
|
||||
<ion-text color="danger">{{ error }}</ion-text>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="ion-text-right">
|
||||
<ion-button fill="clear" (click)="cancel()">Cancel</ion-button>
|
||||
<ion-button
|
||||
fill="clear"
|
||||
type="submit"
|
||||
[disabled]="!value && !options.required"
|
||||
>
|
||||
{{ options.buttonText }}
|
||||
</ion-button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { GenericInputComponent } from './generic-input.component'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
|
||||
@NgModule({
|
||||
declarations: [GenericInputComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
FormsModule,
|
||||
RouterModule.forChild([]),
|
||||
SharedPipesModule,
|
||||
],
|
||||
exports: [GenericInputComponent],
|
||||
})
|
||||
export class GenericInputComponentModule {}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { Component, inject, Input, ViewChild } from '@angular/core'
|
||||
import { ModalController, IonicSafeString, IonInput } from '@ionic/angular'
|
||||
import { getErrorMessage, THEME } from '@start9labs/shared'
|
||||
import { mask } from 'src/app/util/mask'
|
||||
|
||||
@Component({
|
||||
selector: 'generic-input',
|
||||
templateUrl: './generic-input.component.html',
|
||||
})
|
||||
export class GenericInputComponent {
|
||||
@ViewChild('mainInput') elem?: IonInput
|
||||
|
||||
@Input() options!: GenericInputOptions
|
||||
|
||||
value!: string
|
||||
masked!: boolean
|
||||
|
||||
maskedValue?: string
|
||||
|
||||
error: string | IonicSafeString = ''
|
||||
|
||||
readonly theme$ = inject(THEME)
|
||||
|
||||
constructor(private readonly modalCtrl: ModalController) {}
|
||||
|
||||
ngOnInit() {
|
||||
const defaultOptions: Partial<GenericInputOptions> = {
|
||||
buttonText: 'Submit',
|
||||
required: true,
|
||||
useMask: false,
|
||||
initialValue: '',
|
||||
}
|
||||
this.options = {
|
||||
...defaultOptions,
|
||||
...this.options,
|
||||
}
|
||||
|
||||
this.masked = !!this.options.useMask
|
||||
this.value = this.options.initialValue || ''
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
setTimeout(() => this.elem?.setFocus(), 400)
|
||||
}
|
||||
|
||||
toggleMask() {
|
||||
this.masked = !this.masked
|
||||
}
|
||||
|
||||
cancel() {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
|
||||
transformInput(newValue: string) {
|
||||
let i = 0
|
||||
this.value = newValue
|
||||
.split('')
|
||||
.map(x => (x === '●' ? this.value[i++] : x))
|
||||
.join('')
|
||||
this.maskedValue = mask(this.value)
|
||||
}
|
||||
|
||||
async submit() {
|
||||
const value = this.value.trim()
|
||||
|
||||
if (!value && this.options.required) return
|
||||
|
||||
try {
|
||||
const response = await this.options.submitFn(value)
|
||||
this.modalCtrl.dismiss({ response, value }, 'success')
|
||||
} catch (e: any) {
|
||||
this.error = getErrorMessage(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface GenericInputOptions {
|
||||
// required
|
||||
title: string
|
||||
message: string
|
||||
submitFn: (value: string) => Promise<any>
|
||||
// optional
|
||||
label?: string
|
||||
warning?: string
|
||||
buttonText?: string
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
useMask?: boolean
|
||||
initialValue?: string | null
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { Routes, RouterModule } from '@angular/router'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadChildren: () =>
|
||||
import('./pages/backups/backups.module').then(m => m.BackupsPageModule),
|
||||
},
|
||||
{
|
||||
path: 'jobs',
|
||||
loadChildren: () =>
|
||||
import('./pages/backup-jobs/backup-jobs.module').then(
|
||||
m => m.BackupJobsPageModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'targets',
|
||||
loadChildren: () =>
|
||||
import('./pages/backup-targets/backup-targets.module').then(
|
||||
m => m.BackupTargetsPageModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'history',
|
||||
loadChildren: () =>
|
||||
import('./pages/backup-history/backup-history.module').then(
|
||||
m => m.BackupHistoryPageModule,
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(routes)],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class BackupsModule {}
|
||||
@@ -0,0 +1,60 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button defaultHref="system"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Backup Progress</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding with-widgets">
|
||||
<ion-grid *ngIf="pkgs$ | async as pkgs">
|
||||
<ion-row *ngIf="backupProgress$ | async as backupProgress">
|
||||
<ion-col>
|
||||
<ion-item-group>
|
||||
<ng-container *ngFor="let pkg of pkgs | keyvalue">
|
||||
<ion-item *ngIf="backupProgress[pkg.key] as pkgProgress">
|
||||
<ion-avatar slot="start">
|
||||
<img [src]="pkg.value.icon" />
|
||||
</ion-avatar>
|
||||
<ion-label>{{ pkg.value.manifest.title }}</ion-label>
|
||||
<!-- complete -->
|
||||
<ion-note
|
||||
*ngIf="pkgProgress.complete; else incomplete"
|
||||
class="inline"
|
||||
slot="end"
|
||||
>
|
||||
<ion-icon name="checkmark" color="success"></ion-icon>
|
||||
|
||||
<ion-text color="success">Complete</ion-text>
|
||||
</ion-note>
|
||||
<!-- incomplete -->
|
||||
<ng-template #incomplete>
|
||||
<ng-container
|
||||
*ngIf="pkg.key | pkgMainStatus | async as pkgStatus"
|
||||
>
|
||||
<!-- active -->
|
||||
<ion-note
|
||||
*ngIf="pkgStatus === 'backing-up'; else queued"
|
||||
class="inline"
|
||||
slot="end"
|
||||
>
|
||||
<ion-spinner
|
||||
color="dark"
|
||||
style="height: 12px; width: 12px; margin-right: 6px"
|
||||
></ion-spinner>
|
||||
<ion-text color="dark">Backing up</ion-text>
|
||||
</ion-note>
|
||||
<!-- queued -->
|
||||
<ng-template #queued>
|
||||
<ion-note slot="end">Waiting...</ion-note>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
</ng-template>
|
||||
</ion-item>
|
||||
</ng-container>
|
||||
</ion-item-group>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,47 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Pipe,
|
||||
PipeTransform,
|
||||
} from '@angular/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { take, Observable } from 'rxjs'
|
||||
import {
|
||||
DataModel,
|
||||
PackageMainStatus,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'backing-up',
|
||||
templateUrl: './backing-up.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BackingUpComponent {
|
||||
readonly pkgs$ = this.patch.watch$('package-data').pipe(take(1))
|
||||
readonly backupProgress$ = this.patch.watch$(
|
||||
'server-info',
|
||||
'status-info',
|
||||
'current-backup',
|
||||
'backup-progress',
|
||||
)
|
||||
|
||||
constructor(private readonly patch: PatchDB<DataModel>) {}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'pkgMainStatus',
|
||||
})
|
||||
export class PkgMainStatusPipe implements PipeTransform {
|
||||
transform(pkgId: string): Observable<PackageMainStatus> {
|
||||
return this.patch.watch$(
|
||||
'package-data',
|
||||
pkgId,
|
||||
'installed',
|
||||
'status',
|
||||
'main',
|
||||
'status',
|
||||
)
|
||||
}
|
||||
|
||||
constructor(private readonly patch: PatchDB<DataModel>) {}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Directive, HostListener } from '@angular/core'
|
||||
import { LoadingController, ModalController } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { TargetSelectPage } from '../modals/target-select/target-select.page'
|
||||
import {
|
||||
CifsBackupTarget,
|
||||
DiskBackupTarget,
|
||||
} from 'src/app/services/api/api.types'
|
||||
import { BackupSelectPage } from '../modals/backup-select/backup-select.page'
|
||||
|
||||
@Directive({
|
||||
selector: '[backupCreate]',
|
||||
})
|
||||
export class BackupCreateDirective {
|
||||
serviceIds: string[] = []
|
||||
|
||||
constructor(
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly embassyApi: ApiService,
|
||||
) {}
|
||||
|
||||
@HostListener('click') onClick() {
|
||||
this.presentModalTarget()
|
||||
}
|
||||
|
||||
async presentModalTarget() {
|
||||
const modal = await this.modalCtrl.create({
|
||||
presentingElement: await this.modalCtrl.getTop(),
|
||||
component: TargetSelectPage,
|
||||
componentProps: { type: 'create' },
|
||||
})
|
||||
|
||||
modal.onDidDismiss<CifsBackupTarget | DiskBackupTarget>().then(res => {
|
||||
if (res.data) {
|
||||
this.presentModalSelect(res.data.id)
|
||||
}
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
private async presentModalSelect(targetId: string) {
|
||||
const modal = await this.modalCtrl.create({
|
||||
presentingElement: await this.modalCtrl.getTop(),
|
||||
component: BackupSelectPage,
|
||||
componentProps: {
|
||||
btnText: 'Create Backup',
|
||||
},
|
||||
})
|
||||
|
||||
modal.onWillDismiss().then(res => {
|
||||
if (res.data) {
|
||||
this.createBackup(targetId, res.data)
|
||||
}
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
private async createBackup(
|
||||
targetId: string,
|
||||
pkgIds: string[],
|
||||
): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Beginning backup...',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
await this.embassyApi
|
||||
.createBackup({
|
||||
'target-id': targetId,
|
||||
'package-ids': pkgIds,
|
||||
})
|
||||
.finally(() => loader.dismiss())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Directive, HostListener } from '@angular/core'
|
||||
import {
|
||||
LoadingController,
|
||||
ModalController,
|
||||
NavController,
|
||||
} from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import {
|
||||
GenericInputComponent,
|
||||
GenericInputOptions,
|
||||
} from 'src/app/apps/ui/modals/generic-input/generic-input.component'
|
||||
import { BackupInfo, BackupTarget } from 'src/app/services/api/api.types'
|
||||
import * as argon2 from '@start9labs/argon2'
|
||||
import { TargetSelectPage } from '../modals/target-select/target-select.page'
|
||||
import { RecoverSelectPage } from '../modals/recover-select/recover-select.page'
|
||||
|
||||
@Directive({
|
||||
selector: '[backupRestore]',
|
||||
})
|
||||
export class BackupRestoreDirective {
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
) {}
|
||||
|
||||
@HostListener('click') onClick() {
|
||||
this.presentModalTarget()
|
||||
}
|
||||
|
||||
async presentModalTarget() {
|
||||
const modal = await this.modalCtrl.create({
|
||||
presentingElement: await this.modalCtrl.getTop(),
|
||||
component: TargetSelectPage,
|
||||
componentProps: { type: 'restore' },
|
||||
})
|
||||
|
||||
modal.onDidDismiss<BackupTarget>().then(res => {
|
||||
if (res.data) {
|
||||
this.presentModalPassword(res.data)
|
||||
}
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
async presentModalPassword(target: BackupTarget): Promise<void> {
|
||||
const options: GenericInputOptions = {
|
||||
title: 'Password Required',
|
||||
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,
|
||||
buttonText: 'Next',
|
||||
submitFn: async (password: string) => {
|
||||
const passwordHash = target['embassy-os']?.['password-hash'] || ''
|
||||
argon2.verify(passwordHash, password)
|
||||
return this.getBackupInfo(target.id, password)
|
||||
},
|
||||
}
|
||||
|
||||
const modal = await this.modalCtrl.create({
|
||||
componentProps: { options },
|
||||
cssClass: 'alertlike-modal',
|
||||
presentingElement: await this.modalCtrl.getTop(),
|
||||
component: GenericInputComponent,
|
||||
})
|
||||
|
||||
modal.onDidDismiss().then(res => {
|
||||
if (res.data) {
|
||||
const { value, response } = res.data
|
||||
this.presentModalSelect(target.id, response, value)
|
||||
}
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
private async getBackupInfo(
|
||||
targetId: string,
|
||||
password: string,
|
||||
): Promise<BackupInfo> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Decrypting drive...',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
return this.embassyApi
|
||||
.getBackupInfo({
|
||||
'target-id': targetId,
|
||||
password,
|
||||
})
|
||||
.finally(() => loader.dismiss())
|
||||
}
|
||||
|
||||
private async presentModalSelect(
|
||||
targetId: string,
|
||||
backupInfo: BackupInfo,
|
||||
password: string,
|
||||
): Promise<void> {
|
||||
const modal = await this.modalCtrl.create({
|
||||
componentProps: {
|
||||
targetId,
|
||||
backupInfo,
|
||||
password,
|
||||
},
|
||||
presentingElement: await this.modalCtrl.getTop(),
|
||||
component: RecoverSelectPage,
|
||||
})
|
||||
|
||||
modal.onWillDismiss().then(res => {
|
||||
if (res.role === 'success') {
|
||||
this.navCtrl.navigateRoot('/services')
|
||||
}
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { BackupSelectPage } from './backup-select.page'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
|
||||
@NgModule({
|
||||
declarations: [BackupSelectPage],
|
||||
imports: [CommonModule, IonicModule, FormsModule],
|
||||
exports: [BackupSelectPage],
|
||||
})
|
||||
export class BackupSelectPageModule {}
|
||||
@@ -0,0 +1,57 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Select Services to Back Up</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<ng-container *ngIf="pkgs.length; else empty">
|
||||
<ion-item-group>
|
||||
<ion-item-divider>
|
||||
<ion-buttons slot="end" style="padding-bottom: 6px">
|
||||
<ion-button fill="clear" (click)="toggleSelectAll()">
|
||||
<b>{{ selectAll ? 'Select All' : 'Deselect All' }}</b>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-item-divider>
|
||||
<ion-item *ngFor="let pkg of pkgs">
|
||||
<ion-avatar slot="start">
|
||||
<img alt="" [src]="pkg.icon" />
|
||||
</ion-avatar>
|
||||
<ion-label>
|
||||
<h2>{{ pkg.title }}</h2>
|
||||
</ion-label>
|
||||
<ion-checkbox
|
||||
slot="end"
|
||||
[(ngModel)]="pkg.checked"
|
||||
(ionChange)="handleChange()"
|
||||
[disabled]="pkg.disabled"
|
||||
></ion-checkbox>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ng-container>
|
||||
<ng-template #empty>
|
||||
<h2 class="center">No services installed!</h2>
|
||||
</ng-template>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="end" class="ion-padding-end">
|
||||
<ion-button
|
||||
[disabled]="!hasSelection"
|
||||
fill="solid"
|
||||
color="primary"
|
||||
(click)="done()"
|
||||
class="enter-click btn-128"
|
||||
>
|
||||
{{ btnText }}
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
@@ -0,0 +1,5 @@
|
||||
.center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { firstValueFrom, map } from 'rxjs'
|
||||
import { DataModel, PackageState } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'backup-select',
|
||||
templateUrl: './backup-select.page.html',
|
||||
styleUrls: ['./backup-select.page.scss'],
|
||||
})
|
||||
export class BackupSelectPage {
|
||||
@Input() btnText!: string
|
||||
@Input() selectedIds: string[] = []
|
||||
|
||||
hasSelection = false
|
||||
selectAll = false
|
||||
pkgs: {
|
||||
id: string
|
||||
title: string
|
||||
icon: string
|
||||
disabled: boolean
|
||||
checked: boolean
|
||||
}[] = []
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.pkgs = await firstValueFrom(
|
||||
this.patch.watch$('package-data').pipe(
|
||||
map(pkgs => {
|
||||
return Object.values(pkgs)
|
||||
.map(pkg => {
|
||||
const { id, title } = pkg.manifest
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
icon: pkg.icon,
|
||||
disabled: pkg.state !== PackageState.Installed,
|
||||
checked: this.selectedIds.includes(id),
|
||||
}
|
||||
})
|
||||
.sort((a, b) =>
|
||||
b.title.toLowerCase() > a.title.toLowerCase() ? -1 : 1,
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
|
||||
async done() {
|
||||
const pkgIds = this.pkgs.filter(p => p.checked).map(p => p.id)
|
||||
this.modalCtrl.dismiss(pkgIds)
|
||||
}
|
||||
|
||||
handleChange() {
|
||||
this.hasSelection = this.pkgs.some(p => p.checked)
|
||||
}
|
||||
|
||||
toggleSelectAll() {
|
||||
this.pkgs.forEach(pkg => (pkg.checked = this.selectAll))
|
||||
this.selectAll = !this.selectAll
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { RecoverSelectPage } from './recover-select.page'
|
||||
import { ToOptionsPipe } from './to-options.pipe'
|
||||
|
||||
@NgModule({
|
||||
declarations: [RecoverSelectPage, ToOptionsPipe],
|
||||
imports: [CommonModule, IonicModule, FormsModule],
|
||||
exports: [RecoverSelectPage],
|
||||
})
|
||||
export class RecoverSelectPageModule {}
|
||||
@@ -0,0 +1,61 @@
|
||||
<ng-container
|
||||
*ngIf="packageData$ | toOptions : backupInfo['package-backups'] | async as options"
|
||||
>
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Select Services to Restore</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<ion-item-group>
|
||||
<ion-item *ngFor="let option of options">
|
||||
<ion-label>
|
||||
<h2>{{ option.title }}</h2>
|
||||
<p>Version {{ option.version }}</p>
|
||||
<p>Backup made: {{ option.timestamp | date : 'medium' }}</p>
|
||||
<p *ngIf="!option.installed && !option['newer-eos']">
|
||||
<ion-text color="success">Ready to restore</ion-text>
|
||||
</p>
|
||||
<p *ngIf="option.installed">
|
||||
<ion-text color="warning">
|
||||
Unavailable. {{ option.title }} is already installed.
|
||||
</ion-text>
|
||||
</p>
|
||||
<p *ngIf="option['newer-eos']">
|
||||
<ion-text color="danger">
|
||||
Unavailable. Backup was made on a newer version of StartOS.
|
||||
</ion-text>
|
||||
</p>
|
||||
</ion-label>
|
||||
<ion-checkbox
|
||||
slot="end"
|
||||
[(ngModel)]="option.checked"
|
||||
[disabled]="option.installed || option['newer-eos']"
|
||||
(ionChange)="handleChange(options)"
|
||||
></ion-checkbox>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="end" class="ion-padding-end">
|
||||
<ion-button
|
||||
fill="solid"
|
||||
color="primary"
|
||||
class="enter-click btn-128"
|
||||
[disabled]="!hasSelection"
|
||||
(click)="restore(options)"
|
||||
>
|
||||
Restore Selected
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
</ng-container>
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import {
|
||||
LoadingController,
|
||||
ModalController,
|
||||
IonicSafeString,
|
||||
} from '@ionic/angular'
|
||||
import { getErrorMessage } from '@start9labs/shared'
|
||||
import { BackupInfo } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { AppRecoverOption } from './to-options.pipe'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { take } from 'rxjs'
|
||||
|
||||
@Component({
|
||||
selector: 'recover-select',
|
||||
templateUrl: './recover-select.page.html',
|
||||
styleUrls: ['./recover-select.page.scss'],
|
||||
})
|
||||
export class RecoverSelectPage {
|
||||
@Input() targetId!: string
|
||||
@Input() backupInfo!: BackupInfo
|
||||
@Input() password!: string
|
||||
@Input() oldPassword?: string
|
||||
|
||||
readonly packageData$ = this.patch.watch$('package-data').pipe(take(1))
|
||||
|
||||
hasSelection = false
|
||||
error: string | IonicSafeString = ''
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
dismiss() {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
|
||||
handleChange(options: AppRecoverOption[]) {
|
||||
this.hasSelection = options.some(o => o.checked)
|
||||
}
|
||||
|
||||
async restore(options: AppRecoverOption[]): Promise<void> {
|
||||
const ids = options.filter(({ checked }) => !!checked).map(({ id }) => id)
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Initializing...',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.embassyApi.restorePackages({
|
||||
ids,
|
||||
'target-id': this.targetId,
|
||||
password: this.password,
|
||||
})
|
||||
this.modalCtrl.dismiss(undefined, 'success')
|
||||
} catch (e: any) {
|
||||
this.error = getErrorMessage(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { Emver } from '@start9labs/shared'
|
||||
import { map, Observable } from 'rxjs'
|
||||
import { PackageBackupInfo } from 'src/app/services/api/api.types'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
export interface AppRecoverOption extends PackageBackupInfo {
|
||||
id: string
|
||||
checked: boolean
|
||||
installed: boolean
|
||||
'newer-eos': boolean
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'toOptions',
|
||||
})
|
||||
export class ToOptionsPipe implements PipeTransform {
|
||||
constructor(
|
||||
private readonly config: ConfigService,
|
||||
private readonly emver: Emver,
|
||||
) {}
|
||||
|
||||
transform(
|
||||
packageData$: Observable<Record<string, PackageDataEntry>>,
|
||||
packageBackups: Record<string, PackageBackupInfo> = {},
|
||||
): Observable<AppRecoverOption[]> {
|
||||
return packageData$.pipe(
|
||||
map(packageData =>
|
||||
Object.keys(packageBackups)
|
||||
.map(id => ({
|
||||
...packageBackups[id],
|
||||
id,
|
||||
installed: !!packageData[id],
|
||||
checked: false,
|
||||
'newer-eos': this.compare(packageBackups[id]['os-version']),
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
b.title.toLowerCase() > a.title.toLowerCase() ? -1 : 1,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private compare(version: string): boolean {
|
||||
// checks to see if backup was made on a newer version of eOS
|
||||
return this.emver.compare(version, this.config.version) === 1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { TargetSelectPage, TargetStatusComponent } from './target-select.page'
|
||||
import { TargetPipesModule } from '../../pipes/target-pipes.module'
|
||||
import { TextSpinnerComponentModule } from '@start9labs/shared'
|
||||
|
||||
@NgModule({
|
||||
declarations: [TargetSelectPage, TargetStatusComponent],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
TargetPipesModule,
|
||||
TextSpinnerComponentModule,
|
||||
],
|
||||
exports: [TargetSelectPage],
|
||||
})
|
||||
export class TargetSelectPageModule {}
|
||||
@@ -0,0 +1,55 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>
|
||||
Select Backup {{ type === 'create' ? 'Target' : 'Source' }}
|
||||
</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content>
|
||||
<!-- loading -->
|
||||
<text-spinner
|
||||
*ngIf="loading$ | async; else loaded"
|
||||
[text]="type === 'create' ? 'Loading Backup Targets' : 'Loading Backup Sources'"
|
||||
></text-spinner>
|
||||
|
||||
<!-- loaded -->
|
||||
<ng-template #loaded>
|
||||
<ion-item-group>
|
||||
<ion-item-divider>Saved Targets</ion-item-divider>
|
||||
<ion-item
|
||||
button
|
||||
*ngFor="let target of targets"
|
||||
(click)="select(target)"
|
||||
[disabled]="
|
||||
(isOneOff && !target.mountable) ||
|
||||
(type === 'restore' && !target['embassy-os'])
|
||||
"
|
||||
>
|
||||
<ng-container *ngIf="target | getDisplayInfo as displayInfo">
|
||||
<ion-icon
|
||||
slot="start"
|
||||
[name]="displayInfo.icon"
|
||||
size="large"
|
||||
></ion-icon>
|
||||
<ion-label>
|
||||
<h1 style="font-size: x-large">{{ displayInfo.name }}</h1>
|
||||
<target-status [type]="type" [target]="target"></target-status>
|
||||
<p>{{ displayInfo.description }}</p>
|
||||
<p>{{ displayInfo.path }}</p>
|
||||
</ion-label>
|
||||
</ng-container>
|
||||
</ion-item>
|
||||
|
||||
<div *ngIf="!targets.length" class="ion-text-center ion-padding-top">
|
||||
<h2 class="ion-padding-bottom">No saved targets</h2>
|
||||
<ion-button (click)="goToTargets()">Go to Targets</ion-button>
|
||||
</div>
|
||||
</ion-item-group>
|
||||
</ng-template>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,72 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { ModalController, NavController } from '@ionic/angular'
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
import { BackupTarget } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { BackupType } from '../../pages/backup-targets/backup-targets.page'
|
||||
|
||||
@Component({
|
||||
selector: 'target-select',
|
||||
templateUrl: './target-select.page.html',
|
||||
styleUrls: ['./target-select.page.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TargetSelectPage {
|
||||
@Input() type!: BackupType
|
||||
@Input() isOneOff = true
|
||||
|
||||
targets: BackupTarget[] = []
|
||||
|
||||
loading$ = new BehaviorSubject(true)
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly navCtrl: NavController,
|
||||
private readonly api: ApiService,
|
||||
private readonly errToast: ErrorToastService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.getTargets()
|
||||
}
|
||||
|
||||
dismiss() {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
|
||||
select(target: BackupTarget): void {
|
||||
this.modalCtrl.dismiss(target)
|
||||
}
|
||||
|
||||
goToTargets() {
|
||||
this.modalCtrl
|
||||
.dismiss()
|
||||
.then(() => this.navCtrl.navigateForward(`/backups/targets`))
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
await this.getTargets()
|
||||
}
|
||||
|
||||
private async getTargets(): Promise<void> {
|
||||
this.loading$.next(true)
|
||||
try {
|
||||
this.targets = (await this.api.getBackupTargets({})).saved
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
this.loading$.next(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'target-status',
|
||||
templateUrl: './target-status.component.html',
|
||||
styleUrls: ['./target-select.page.scss'],
|
||||
})
|
||||
export class TargetStatusComponent {
|
||||
@Input() type!: BackupType
|
||||
@Input() target!: BackupTarget
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<div class="inline">
|
||||
<h2 *ngIf="!target.mountable; else mountable">
|
||||
<ion-icon name="cellular-outline" color="danger"></ion-icon>
|
||||
Unable to connect
|
||||
</h2>
|
||||
|
||||
<ng-template #mountable>
|
||||
<h2 *ngIf="type === 'create'; else restore">
|
||||
<ion-icon name="cloud-outline" color="success"></ion-icon>
|
||||
{{
|
||||
(target | hasValidBackup)
|
||||
? 'Available, contains existing backup'
|
||||
: 'Available for fresh backup'
|
||||
}}
|
||||
</h2>
|
||||
|
||||
<ng-template #restore>
|
||||
<h2 *ngIf="target | hasValidBackup; else noBackup">
|
||||
<ion-icon name="cloud-done-outline" color="success"></ion-icon>
|
||||
Embassy backup detected
|
||||
</h2>
|
||||
<ng-template #noBackup>
|
||||
<h2>
|
||||
<ion-icon name="cloud-offline-outline" color="danger"></ion-icon>
|
||||
No Embassy backup
|
||||
</h2>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
</ng-template>
|
||||
</div>
|
||||
@@ -0,0 +1,30 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import {
|
||||
BackupHistoryPage,
|
||||
DurationPipe,
|
||||
HasErrorPipe,
|
||||
} from './backup-history.page'
|
||||
import { TargetPipesModule } from '../../pipes/target-pipes.module'
|
||||
import { BackupReportPageModule } from 'src/app/apps/ui/modals/backup-report/backup-report.module'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: BackupHistoryPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
TargetPipesModule,
|
||||
BackupReportPageModule,
|
||||
RouterModule.forChild(routes),
|
||||
],
|
||||
declarations: [BackupHistoryPage, DurationPipe, HasErrorPipe],
|
||||
})
|
||||
export class BackupHistoryPageModule {}
|
||||
@@ -0,0 +1,93 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button defaultHref="/backups"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Backup History</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding-top">
|
||||
<ion-item-divider>
|
||||
Past Events
|
||||
<ion-button
|
||||
class="ion-padding-start"
|
||||
color="danger"
|
||||
strong
|
||||
size="small"
|
||||
(click)="deleteSelected()"
|
||||
[disabled]="empty"
|
||||
>
|
||||
Delete Selected
|
||||
</ion-button>
|
||||
</ion-item-divider>
|
||||
<div class="grid-fixed">
|
||||
<ion-grid class="ion-padding">
|
||||
<ion-row class="grid-headings">
|
||||
<ion-col size="3.5" class="inline">
|
||||
<div class="checkbox" (click)="toggleAll(runs)">
|
||||
<ion-icon
|
||||
[name]="empty ? 'square-outline' : count === runs.length ? 'checkbox-outline' : 'remove-circle-outline'"
|
||||
></ion-icon>
|
||||
</div>
|
||||
Started At
|
||||
</ion-col>
|
||||
<ion-col size="2">Duration</ion-col>
|
||||
<ion-col size="1.5">Result</ion-col>
|
||||
<ion-col size="2.5">Job</ion-col>
|
||||
<ion-col size="2.5">Target</ion-col>
|
||||
</ion-row>
|
||||
|
||||
<!-- loading -->
|
||||
<ng-container *ngIf="loading$ | async; else loaded">
|
||||
<ion-row
|
||||
*ngFor="let row of ['', '', '']"
|
||||
class="ion-align-items-center grid-row-border"
|
||||
>
|
||||
<ion-col>
|
||||
<ion-skeleton-text animated></ion-skeleton-text>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ng-container>
|
||||
<!-- loaded -->
|
||||
<ng-template #loaded>
|
||||
<ion-row
|
||||
*ngFor="let run of runs"
|
||||
class="ion-align-items-center grid-row-border"
|
||||
[class.highlighted]="selected[run.id]"
|
||||
>
|
||||
<ion-col size="3.5" class="inline">
|
||||
<div class="checkbox" (click)="toggleChecked(run.id)">
|
||||
<ion-icon
|
||||
[name]="selected[run.id] ? 'checkbox-outline' : 'square-outline'"
|
||||
></ion-icon>
|
||||
</div>
|
||||
{{ run['started-at'] | date : 'medium' }}
|
||||
</ion-col>
|
||||
<ion-col size="2">
|
||||
{{ run['started-at']| duration : run['completed-at'] }} Minutes
|
||||
</ion-col>
|
||||
<ion-col size="1.5">
|
||||
<ion-icon
|
||||
*ngIf="run.report | hasError; else noError"
|
||||
name="close"
|
||||
color="danger"
|
||||
></ion-icon>
|
||||
<ng-template #noError>
|
||||
<ion-icon name="checkmark" color="success"></ion-icon>
|
||||
</ng-template>
|
||||
<a (click)="presentModalReport(run)">Report</a>
|
||||
</ion-col>
|
||||
<ion-col size="2.5">{{ run.job.name || 'No job' }}</ion-col>
|
||||
<ion-col size="2.5" class="inline">
|
||||
<ion-icon
|
||||
[name]="(run.job.target | getDisplayInfo).icon"
|
||||
size="small"
|
||||
></ion-icon>
|
||||
{{ run.job.target.name }}
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ng-template>
|
||||
</ion-grid>
|
||||
</div>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,3 @@
|
||||
.highlighted {
|
||||
background-color: var(--ion-color-medium-shade);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { BackupReport, BackupRun } from 'src/app/services/api/api.types'
|
||||
import { LoadingController, ModalController } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
import { BackupReportPage } from 'src/app/apps/ui/modals/backup-report/backup-report.page'
|
||||
|
||||
@Component({
|
||||
selector: 'backup-history',
|
||||
templateUrl: './backup-history.page.html',
|
||||
styleUrls: ['./backup-history.page.scss'],
|
||||
})
|
||||
export class BackupHistoryPage {
|
||||
selected: Record<string, boolean> = {}
|
||||
runs: BackupRun[] = []
|
||||
loading$ = new BehaviorSubject(true)
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly api: ApiService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
this.runs = await this.api.getBackupRuns({})
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
this.loading$.next(false)
|
||||
}
|
||||
}
|
||||
|
||||
get empty() {
|
||||
return this.count === 0
|
||||
}
|
||||
|
||||
get count() {
|
||||
return Object.keys(this.selected).length
|
||||
}
|
||||
|
||||
async presentModalReport(run: BackupRun) {
|
||||
const modal = await this.modalCtrl.create({
|
||||
component: BackupReportPage,
|
||||
componentProps: {
|
||||
report: run.report,
|
||||
timestamp: run['completed-at'],
|
||||
},
|
||||
})
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
async toggleChecked(id: string) {
|
||||
if (this.selected[id]) {
|
||||
delete this.selected[id]
|
||||
} else {
|
||||
this.selected[id] = true
|
||||
}
|
||||
}
|
||||
|
||||
async toggleAll(runs: BackupRun[]) {
|
||||
if (this.empty) {
|
||||
runs.forEach(r => (this.selected[r.id] = true))
|
||||
} else {
|
||||
this.selected = {}
|
||||
}
|
||||
}
|
||||
|
||||
async deleteSelected(): Promise<void> {
|
||||
const ids = Object.keys(this.selected)
|
||||
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Deleting...',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.api.deleteBackupRuns({ ids })
|
||||
this.selected = {}
|
||||
this.runs = this.runs.filter(r => !ids.includes(r.id))
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'duration',
|
||||
})
|
||||
export class DurationPipe implements PipeTransform {
|
||||
transform(start: string, finish: string): number {
|
||||
const diffMs = new Date(finish).valueOf() - new Date(start).valueOf()
|
||||
return diffMs / 100
|
||||
}
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'hasError',
|
||||
})
|
||||
export class HasErrorPipe implements PipeTransform {
|
||||
transform(report: BackupReport): boolean {
|
||||
const osErr = !!report.server.error
|
||||
const pkgErr = !!Object.values(report.packages).find(pkg => pkg.error)
|
||||
return osErr || pkgErr
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { BackupJobsPage } from './backup-jobs.page'
|
||||
import { NewJobPage } from './new-job/new-job.page'
|
||||
import { EditJobPage } from './edit-job/edit-job.page'
|
||||
import { JobOptionsComponent } from './job-options/job-options.component'
|
||||
import { ToHumanCronPipe } from './pipes'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { TargetSelectPageModule } from '../../modals/target-select/target-select.module'
|
||||
import { TargetPipesModule } from '../../pipes/target-pipes.module'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: BackupJobsPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
FormsModule,
|
||||
TargetSelectPageModule,
|
||||
TargetPipesModule,
|
||||
],
|
||||
declarations: [
|
||||
BackupJobsPage,
|
||||
ToHumanCronPipe,
|
||||
NewJobPage,
|
||||
EditJobPage,
|
||||
JobOptionsComponent,
|
||||
],
|
||||
})
|
||||
export class BackupJobsPageModule {}
|
||||
@@ -0,0 +1,92 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button defaultHref="/backups"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Backup Jobs</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding-top">
|
||||
<ion-item-group>
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>
|
||||
Scheduling automatic backups is an excellent way to ensure your
|
||||
Embassy data is safely backed up. Your Embassy will issue a
|
||||
notification whenever one of your scheduled backups succeeds or fails.
|
||||
<a [href]="docsUrl" target="_blank" rel="noreferrer">
|
||||
View instructions
|
||||
</a>
|
||||
</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item-divider>
|
||||
Saved Jobs
|
||||
<ion-button
|
||||
class="ion-padding-start"
|
||||
strong
|
||||
size="small"
|
||||
(click)="presentModalCreate()"
|
||||
>
|
||||
<ion-icon slot="start" name="add"></ion-icon>
|
||||
New Job
|
||||
</ion-button>
|
||||
</ion-item-divider>
|
||||
|
||||
<div class="grid-fixed">
|
||||
<ion-grid class="ion-padding">
|
||||
<ion-row class="grid-headings">
|
||||
<ion-col size="2.5">Name</ion-col>
|
||||
<ion-col size="2.5">Target</ion-col>
|
||||
<ion-col size="2">Packages</ion-col>
|
||||
<ion-col size="3">Schedule</ion-col>
|
||||
<ion-col size="2"></ion-col>
|
||||
</ion-row>
|
||||
<!-- loading -->
|
||||
<ng-container *ngIf="loading$ | async; else loaded">
|
||||
<ion-row
|
||||
*ngFor="let row of ['', '']"
|
||||
class="ion-align-items-center grid-row-border"
|
||||
>
|
||||
<ion-col>
|
||||
<ion-skeleton-text animated></ion-skeleton-text>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ng-container>
|
||||
<!-- loaded -->
|
||||
<ng-template #loaded>
|
||||
<ion-row
|
||||
*ngFor="let job of jobs; let i = index"
|
||||
class="ion-align-items-center grid-row-border"
|
||||
>
|
||||
<ion-col size="2.5">{{ job.name }}</ion-col>
|
||||
<ion-col size="2.5" class="inline">
|
||||
<ion-icon
|
||||
[name]="(job.target | getDisplayInfo).icon"
|
||||
size="small"
|
||||
></ion-icon>
|
||||
{{ job.target.name }}
|
||||
</ion-col>
|
||||
<ion-col size="2">{{ job['package-ids'].length }} Packages</ion-col>
|
||||
<ion-col size="3">{{ (job.cron | toHumanCron).message }}</ion-col>
|
||||
<ion-col size="2">
|
||||
<ion-buttons style="float: right">
|
||||
<ion-button size="small" (click)="presentModalUpdate(job)">
|
||||
<ion-icon name="pencil"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button
|
||||
size="small"
|
||||
(click)="presentAlertDelete(job.id, i)"
|
||||
>
|
||||
<ion-icon name="trash"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ng-template>
|
||||
</ion-grid>
|
||||
</div>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Component } from '@angular/core'
|
||||
import {
|
||||
AlertController,
|
||||
LoadingController,
|
||||
ModalController,
|
||||
} from '@ionic/angular'
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
import { BackupJob } from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { EditJobPage } from './edit-job/edit-job.page'
|
||||
import { NewJobPage } from './new-job/new-job.page'
|
||||
|
||||
@Component({
|
||||
selector: 'backup-jobs',
|
||||
templateUrl: './backup-jobs.page.html',
|
||||
styleUrls: ['./backup-jobs.page.scss'],
|
||||
})
|
||||
export class BackupJobsPage {
|
||||
readonly docsUrl =
|
||||
'https://docs.start9.com/latest/user-manual/backups/backup-jobs'
|
||||
|
||||
jobs: BackupJob[] = []
|
||||
|
||||
loading$ = new BehaviorSubject(true)
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly alertCtrl: AlertController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly errToast: ErrorToastService,
|
||||
private readonly api: ApiService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
this.jobs = await this.api.getBackupJobs({})
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
this.loading$.next(false)
|
||||
}
|
||||
}
|
||||
|
||||
async presentModalCreate() {
|
||||
const modal = await this.modalCtrl.create({
|
||||
presentingElement: await this.modalCtrl.getTop(),
|
||||
component: NewJobPage,
|
||||
componentProps: {
|
||||
count: this.jobs.length + 1,
|
||||
},
|
||||
})
|
||||
|
||||
modal.onWillDismiss().then(res => {
|
||||
if (res.data) {
|
||||
this.jobs.push(res.data)
|
||||
}
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
async presentModalUpdate(job: BackupJob) {
|
||||
const modal = await this.modalCtrl.create({
|
||||
presentingElement: await this.modalCtrl.getTop(),
|
||||
component: EditJobPage,
|
||||
componentProps: {
|
||||
existingJob: job,
|
||||
},
|
||||
})
|
||||
|
||||
modal.onWillDismiss().then((res: { data?: BackupJob }) => {
|
||||
if (res.data) {
|
||||
const { name, target, cron } = res.data
|
||||
job.name = name
|
||||
job.target = target
|
||||
job.cron = cron
|
||||
job['package-ids'] = res.data['package-ids']
|
||||
}
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
async presentAlertDelete(id: string, index: number) {
|
||||
const alert = await this.alertCtrl.create({
|
||||
header: 'Confirm',
|
||||
message: 'Delete backup job? This action cannot be undone.',
|
||||
buttons: [
|
||||
{
|
||||
text: 'Cancel',
|
||||
role: 'cancel',
|
||||
},
|
||||
{
|
||||
text: 'Delete',
|
||||
handler: () => {
|
||||
this.delete(id, index)
|
||||
},
|
||||
cssClass: 'enter-click',
|
||||
},
|
||||
],
|
||||
})
|
||||
await alert.present()
|
||||
}
|
||||
|
||||
private async delete(id: string, i: number): Promise<void> {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Deleting...',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
await this.api.removeBackupTarget({ id })
|
||||
this.jobs.splice(i, 1)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Edit Job</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<ion-item-group>
|
||||
<job-options [job]="job"></job-options>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="end" class="ion-padding-end">
|
||||
<ion-button
|
||||
fill="solid"
|
||||
color="primary"
|
||||
[disabled]="!job.target || saving"
|
||||
(click)="save()"
|
||||
class="enter-click btn-128"
|
||||
[class.no-click]="saving"
|
||||
>
|
||||
Save
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
@@ -0,0 +1,3 @@
|
||||
h2 {
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { BackupJob } from 'src/app/services/api/api.types'
|
||||
import { LoadingController, ModalController } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { BackupJobBuilder } from '../job-options/job-options.component'
|
||||
|
||||
@Component({
|
||||
selector: 'edit-job',
|
||||
templateUrl: './edit-job.page.html',
|
||||
styleUrls: ['./edit-job.page.scss'],
|
||||
})
|
||||
export class EditJobPage {
|
||||
@Input() existingJob!: BackupJob
|
||||
|
||||
job = {} as BackupJobBuilder
|
||||
|
||||
saving = false
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly api: ApiService,
|
||||
private readonly errToast: ErrorToastService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.job = new BackupJobBuilder(this.existingJob)
|
||||
}
|
||||
|
||||
async dismiss() {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
|
||||
async save() {
|
||||
this.saving = true
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Saving Job',
|
||||
})
|
||||
await loader.present()
|
||||
|
||||
try {
|
||||
const job = await this.api.updateBackupJob(
|
||||
this.job.buildUpdate(this.existingJob.id),
|
||||
)
|
||||
this.modalCtrl.dismiss(job)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
this.saving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
<div class="ion-padding-start">
|
||||
<p class="input-label">Job Name</p>
|
||||
<ion-item color="dark">
|
||||
<ion-input placeholder="My Backup Job" [(ngModel)]="job.name"></ion-input>
|
||||
</ion-item>
|
||||
</div>
|
||||
|
||||
<ion-item button (click)="presentModalTarget()">
|
||||
<ion-label>
|
||||
<h2>Target</h2>
|
||||
</ion-label>
|
||||
<ion-note slot="end" color="success">
|
||||
{{ job.target.type || 'Select target' }}
|
||||
</ion-note>
|
||||
</ion-item>
|
||||
|
||||
<ion-item button (click)="presentModalPackages()">
|
||||
<ion-label>
|
||||
<h2>Packages</h2>
|
||||
</ion-label>
|
||||
<ion-note slot="end" color="success">
|
||||
{{ job['package-ids'].length + ' selected' }}
|
||||
</ion-note>
|
||||
</ion-item>
|
||||
|
||||
<div class="ion-padding-start">
|
||||
<p class="input-label">Schedule</p>
|
||||
<ion-item color="dark">
|
||||
<ion-input placeholder="* * * * *" [(ngModel)]="job.cron"></ion-input>
|
||||
</ion-item>
|
||||
<p *ngIf="job.cron | toHumanCron as human" style="padding-left: 6px">
|
||||
<ion-text [color]="human.color">{{ human.message }}</ion-text>
|
||||
</p>
|
||||
</div>
|
||||
@@ -0,0 +1,9 @@
|
||||
h2 {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.input-label {
|
||||
margin-bottom: 6px;
|
||||
font-size: medium;
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { ModalController } from '@ionic/angular'
|
||||
import { BackupJob, BackupTarget, RR } from 'src/app/services/api/api.types'
|
||||
import { BackupSelectPage } from '../../../modals/backup-select/backup-select.page'
|
||||
import { TargetSelectPage } from '../../../modals/target-select/target-select.page'
|
||||
|
||||
@Component({
|
||||
selector: 'job-options',
|
||||
templateUrl: './job-options.component.html',
|
||||
styleUrls: ['./job-options.component.scss'],
|
||||
})
|
||||
export class JobOptionsComponent {
|
||||
@Input() job!: BackupJobBuilder
|
||||
|
||||
constructor(private readonly modalCtrl: ModalController) {}
|
||||
|
||||
async presentModalTarget() {
|
||||
const modal = await this.modalCtrl.create({
|
||||
presentingElement: await this.modalCtrl.getTop(),
|
||||
component: TargetSelectPage,
|
||||
componentProps: { type: 'create' },
|
||||
})
|
||||
|
||||
modal.onWillDismiss<BackupTarget>().then(res => {
|
||||
if (res.data) {
|
||||
this.job.target = res.data
|
||||
}
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
|
||||
async presentModalPackages() {
|
||||
const modal = await this.modalCtrl.create({
|
||||
presentingElement: await this.modalCtrl.getTop(),
|
||||
component: BackupSelectPage,
|
||||
componentProps: {
|
||||
btnText: 'Done',
|
||||
selectedIds: this.job['package-ids'],
|
||||
},
|
||||
})
|
||||
|
||||
modal.onWillDismiss().then(res => {
|
||||
if (res.data) {
|
||||
this.job['package-ids'] = res.data
|
||||
}
|
||||
})
|
||||
|
||||
await modal.present()
|
||||
}
|
||||
}
|
||||
|
||||
export class BackupJobBuilder {
|
||||
name: string
|
||||
target: BackupTarget
|
||||
cron: string
|
||||
'package-ids': string[]
|
||||
now = false
|
||||
|
||||
constructor(readonly job: Partial<BackupJob>) {
|
||||
const { name, target, cron } = job
|
||||
this.name = name || ''
|
||||
this.target = target || ({} as BackupTarget)
|
||||
this.cron = cron || '0 2 * * *'
|
||||
this['package-ids'] = job['package-ids'] || []
|
||||
}
|
||||
|
||||
buildCreate(): RR.CreateBackupJobReq {
|
||||
const { name, target, cron, now } = this
|
||||
|
||||
return {
|
||||
name,
|
||||
'target-id': target.id,
|
||||
cron,
|
||||
'package-ids': this['package-ids'],
|
||||
now,
|
||||
}
|
||||
}
|
||||
|
||||
buildUpdate(id: string): RR.UpdateBackupJobReq {
|
||||
const { name, target, cron } = this
|
||||
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
'target-id': target.id,
|
||||
cron,
|
||||
'package-ids': this['package-ids'],
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Create New Job</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<ion-button (click)="dismiss()">
|
||||
<ion-icon slot="icon-only" name="close"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<ion-item-group>
|
||||
<job-options [job]="job"></job-options>
|
||||
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>Also Execute Now</h2>
|
||||
</ion-label>
|
||||
<ion-toggle slot="end" [(ngModel)]="job.now"></ion-toggle>
|
||||
</ion-item>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
|
||||
<ion-footer>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="end" class="ion-padding-end">
|
||||
<ion-button
|
||||
fill="solid"
|
||||
color="primary"
|
||||
[disabled]="!job.target || saving"
|
||||
(click)="save()"
|
||||
class="enter-click btn-128"
|
||||
[class.no-click]="saving"
|
||||
>
|
||||
Save Job
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-footer>
|
||||
@@ -0,0 +1,3 @@
|
||||
h2 {
|
||||
font-weight: bold;
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { LoadingController, ModalController } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ErrorToastService } from '@start9labs/shared'
|
||||
import { BackupJobBuilder } from '../job-options/job-options.component'
|
||||
|
||||
@Component({
|
||||
selector: 'new-job',
|
||||
templateUrl: './new-job.page.html',
|
||||
styleUrls: ['./new-job.page.scss'],
|
||||
})
|
||||
export class NewJobPage {
|
||||
@Input() count!: number
|
||||
|
||||
readonly docsUrl =
|
||||
'https://docs.start9.com/latest/user-manual/backups/backup-jobs'
|
||||
|
||||
job = {} as BackupJobBuilder
|
||||
|
||||
saving = false
|
||||
|
||||
constructor(
|
||||
private readonly modalCtrl: ModalController,
|
||||
private readonly loadingCtrl: LoadingController,
|
||||
private readonly api: ApiService,
|
||||
private readonly errToast: ErrorToastService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.job = new BackupJobBuilder({ name: `Backup Job ${this.count}` })
|
||||
}
|
||||
|
||||
async dismiss() {
|
||||
this.modalCtrl.dismiss()
|
||||
}
|
||||
|
||||
async save() {
|
||||
const loader = await this.loadingCtrl.create({
|
||||
message: 'Saving Job',
|
||||
})
|
||||
await loader.present()
|
||||
this.saving = true
|
||||
|
||||
try {
|
||||
const job = await this.api.createBackupJob(this.job.buildCreate())
|
||||
this.modalCtrl.dismiss(job)
|
||||
} catch (e: any) {
|
||||
this.errToast.present(e)
|
||||
} finally {
|
||||
loader.dismiss()
|
||||
this.saving = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import cronstrue from 'cronstrue'
|
||||
|
||||
@Pipe({
|
||||
name: 'toHumanCron',
|
||||
})
|
||||
export class ToHumanCronPipe implements PipeTransform {
|
||||
transform(cron: string): { message: string; color: string } {
|
||||
const toReturn = {
|
||||
message: '',
|
||||
color: 'success',
|
||||
}
|
||||
|
||||
try {
|
||||
const human = cronstrue.toString(cron, {
|
||||
verbose: true,
|
||||
throwExceptionOnParseError: true,
|
||||
})
|
||||
const zero = Number(cron[0])
|
||||
const one = Number(cron[1])
|
||||
if (Number.isNaN(zero) || Number.isNaN(one)) {
|
||||
throw new Error(
|
||||
`${human}. Cannot run cron jobs more than once per hour`,
|
||||
)
|
||||
}
|
||||
toReturn.message = human
|
||||
} catch (e) {
|
||||
toReturn.message = e as string
|
||||
toReturn.color = 'danger'
|
||||
}
|
||||
|
||||
return toReturn
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { UnitConversionPipesModule } from '@start9labs/shared'
|
||||
import { SkeletonListComponentModule } from 'src/app/common/skeleton-list/skeleton-list.component.module'
|
||||
import { FormPageModule } from 'src/app/apps/ui/modals/form/form.module'
|
||||
import { BackupTargetsPage } from './backup-targets.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: BackupTargetsPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
declarations: [BackupTargetsPage],
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
SkeletonListComponentModule,
|
||||
UnitConversionPipesModule,
|
||||
FormPageModule,
|
||||
RouterModule.forChild(routes),
|
||||
],
|
||||
})
|
||||
export class BackupTargetsPageModule {}
|
||||
@@ -0,0 +1,166 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button defaultHref="/backups"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Backup Targets</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding-top">
|
||||
<ion-item-group>
|
||||
<ion-item>
|
||||
<ion-label>
|
||||
<h2>
|
||||
Backup targets are physical or virtual locations for storing encrypted
|
||||
backups. They can be physical drives plugged into your server, shared
|
||||
folders on your Local Area Network (LAN), or third party clouds such
|
||||
as Dropbox or Google Drive.
|
||||
<a [href]="docsUrl" target="_blank" rel="noreferrer">
|
||||
View instructions
|
||||
</a>
|
||||
</h2>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<!-- unknown disks -->
|
||||
<ion-item-divider>
|
||||
Unknown Physical Drives
|
||||
<ion-button
|
||||
class="ion-padding-start"
|
||||
strong
|
||||
size="small"
|
||||
(click)="refresh()"
|
||||
>
|
||||
<ion-icon slot="start" name="refresh"></ion-icon>
|
||||
Refresh
|
||||
</ion-button>
|
||||
</ion-item-divider>
|
||||
|
||||
<div class="grid-fixed">
|
||||
<ion-grid class="ion-padding">
|
||||
<ion-row class="grid-headings">
|
||||
<ion-col size="3">Make/Model</ion-col>
|
||||
<ion-col size="3">Label</ion-col>
|
||||
<ion-col size="2">Capacity</ion-col>
|
||||
<ion-col size="2">Used</ion-col>
|
||||
<ion-col size="2"></ion-col>
|
||||
</ion-row>
|
||||
<!-- loading -->
|
||||
<ion-row
|
||||
*ngIf="loading$ | async; else loaded"
|
||||
class="ion-align-items-center grid-row-border"
|
||||
>
|
||||
<ion-col>
|
||||
<ion-skeleton-text animated></ion-skeleton-text>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
<!-- loaded -->
|
||||
<ng-template #loaded>
|
||||
<ion-row
|
||||
*ngFor="let disk of targets['unknown-disks']; let i = index"
|
||||
class="ion-align-items-center grid-row-border"
|
||||
>
|
||||
<ion-col size="3">
|
||||
{{ disk.vendor || 'unknown make' }}, {{ disk.model || 'unknown
|
||||
model' }}
|
||||
</ion-col>
|
||||
<ion-col size="3">{{ disk.label }}</ion-col>
|
||||
<ion-col size="2">{{ disk.capacity | convertBytes }}</ion-col>
|
||||
<ion-col size="2">
|
||||
{{ disk.used ? (disk.used | convertBytes) : 'unknown' }}
|
||||
</ion-col>
|
||||
<ion-col size="2">
|
||||
<ion-button
|
||||
strong
|
||||
size="small"
|
||||
style="float: right"
|
||||
(click)="presentModalAddPhysical(disk, i)"
|
||||
>
|
||||
<ion-icon name="add" slot="start"></ion-icon>
|
||||
Save
|
||||
</ion-button>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
|
||||
<p *ngIf="!targets['unknown-disks'].length">
|
||||
To add a new physical backup target, connect the drive and click
|
||||
refresh.
|
||||
</p>
|
||||
</ng-template>
|
||||
</ion-grid>
|
||||
</div>
|
||||
|
||||
<!-- saved targets -->
|
||||
<ion-item-divider>
|
||||
Saved Targets
|
||||
<ion-button
|
||||
class="ion-padding-start"
|
||||
strong
|
||||
size="small"
|
||||
(click)="presentModalAddRemote()"
|
||||
>
|
||||
<ion-icon slot="start" name="add"></ion-icon>
|
||||
Add Target
|
||||
</ion-button>
|
||||
</ion-item-divider>
|
||||
|
||||
<div class="grid-fixed">
|
||||
<ion-grid class="ion-padding">
|
||||
<ion-row class="grid-headings">
|
||||
<ion-col size="3">Name</ion-col>
|
||||
<ion-col>Type</ion-col>
|
||||
<ion-col>Available</ion-col>
|
||||
<ion-col size="4">Path</ion-col>
|
||||
<ion-col size="2"></ion-col>
|
||||
</ion-row>
|
||||
<!-- loading -->
|
||||
<ng-container *ngIf="loading$ | async; else loaded2">
|
||||
<ion-row
|
||||
*ngFor="let row of ['', '']"
|
||||
class="ion-align-items-center grid-row-border"
|
||||
>
|
||||
<ion-col>
|
||||
<ion-skeleton-text animated></ion-skeleton-text>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ng-container>
|
||||
<!-- loaded -->
|
||||
<ng-template #loaded2>
|
||||
<ion-row
|
||||
*ngFor="let target of targets.saved; let i = index"
|
||||
class="ion-align-items-center grid-row-border"
|
||||
>
|
||||
<ion-col size="3">{{ target.name }}</ion-col>
|
||||
<ion-col class="inline">
|
||||
<ion-icon [name]="getIcon(target.type)" size="small"></ion-icon>
|
||||
{{ target.type | titlecase }}
|
||||
</ion-col>
|
||||
<ion-col>
|
||||
<ion-icon
|
||||
[name]="target.mountable ? 'checkmark' : 'close'"
|
||||
[color]="target.mountable ? 'success' : 'danger'"
|
||||
></ion-icon>
|
||||
</ion-col>
|
||||
<ion-col size="4">{{ target.path }}</ion-col>
|
||||
<ion-col size="2">
|
||||
<ion-buttons style="float: right">
|
||||
<ion-button size="small" (click)="presentModalUpdate(target)">
|
||||
<ion-icon name="pencil"></ion-icon>
|
||||
</ion-button>
|
||||
<ion-button
|
||||
size="small"
|
||||
(click)="presentAlertDelete(target.id, i)"
|
||||
>
|
||||
<ion-icon name="trash"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-buttons>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
|
||||
<p *ngIf="!targets.saved.length">No saved backup targets.</p>
|
||||
</ng-template>
|
||||
</ion-grid>
|
||||
</div>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,241 @@
|
||||
import { Component } from '@angular/core'
|
||||
import {
|
||||
BackupTarget,
|
||||
BackupTargetType,
|
||||
RR,
|
||||
UnknownDisk,
|
||||
} from 'src/app/services/api/api.types'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import {
|
||||
cifsSpec,
|
||||
diskBackupTargetSpec,
|
||||
dropboxSpec,
|
||||
googleDriveSpec,
|
||||
remoteBackupTargetSpec,
|
||||
} from '../../types/target-types'
|
||||
import { BehaviorSubject, filter } from 'rxjs'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { ErrorService } from '@start9labs/shared'
|
||||
import {
|
||||
InputSpec,
|
||||
unionSelectKey,
|
||||
unionValueKey,
|
||||
} from '@start9labs/start-sdk/lib/config/configTypes'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { FormPage } from 'src/app/apps/ui/modals/form/form.page'
|
||||
import { LoadingService } from 'src/app/common/loading/loading.service'
|
||||
import { configBuilderToSpec } from 'src/app/util/configBuilderToSpec'
|
||||
|
||||
type BackupConfig =
|
||||
| {
|
||||
type: {
|
||||
[unionSelectKey]: 'dropbox' | 'google-drive'
|
||||
[unionValueKey]: RR.AddCloudBackupTargetReq
|
||||
}
|
||||
}
|
||||
| {
|
||||
type: {
|
||||
[unionSelectKey]: 'cifs'
|
||||
[unionValueKey]: RR.AddCifsBackupTargetReq
|
||||
}
|
||||
}
|
||||
|
||||
export type BackupType = 'create' | 'restore'
|
||||
|
||||
@Component({
|
||||
selector: 'backup-targets',
|
||||
templateUrl: './backup-targets.page.html',
|
||||
styleUrls: ['./backup-targets.page.scss'],
|
||||
})
|
||||
export class BackupTargetsPage {
|
||||
readonly docsUrl =
|
||||
'https://docs.start9.com/latest/user-manual/backups/backup-targets'
|
||||
targets: RR.GetBackupTargetsRes = {
|
||||
'unknown-disks': [],
|
||||
saved: [],
|
||||
}
|
||||
|
||||
loading$ = new BehaviorSubject(true)
|
||||
|
||||
constructor(
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly api: ApiService,
|
||||
private readonly formDialog: FormDialogService,
|
||||
) {}
|
||||
|
||||
ngOnInit() {
|
||||
this.getTargets()
|
||||
}
|
||||
|
||||
async presentModalAddPhysical(disk: UnknownDisk, index: number) {
|
||||
this.formDialog.open(FormPage, {
|
||||
label: 'New Physical Target',
|
||||
data: {
|
||||
spec: await configBuilderToSpec(diskBackupTargetSpec),
|
||||
value: {
|
||||
name: disk.label || disk.logicalname,
|
||||
},
|
||||
buttons: [
|
||||
{
|
||||
text: 'Save',
|
||||
handler: (value: Omit<RR.AddDiskBackupTargetReq, 'logicalname'>) =>
|
||||
this.add('disk', {
|
||||
logicalname: disk.logicalname,
|
||||
...value,
|
||||
}).then(disk => {
|
||||
this.targets['unknown-disks'].splice(index, 1)
|
||||
this.targets.saved.push(disk)
|
||||
|
||||
return true
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async presentModalAddRemote() {
|
||||
this.formDialog.open(FormPage, {
|
||||
label: 'New Remote Target',
|
||||
data: {
|
||||
spec: await configBuilderToSpec(remoteBackupTargetSpec),
|
||||
buttons: [
|
||||
{
|
||||
text: 'Save',
|
||||
handler: ({ type }: BackupConfig) =>
|
||||
this.add(
|
||||
type[unionSelectKey] === 'cifs' ? 'cifs' : 'cloud',
|
||||
type[unionValueKey],
|
||||
),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async presentModalUpdate(target: BackupTarget) {
|
||||
let spec: InputSpec
|
||||
|
||||
switch (target.type) {
|
||||
case 'cifs':
|
||||
spec = await configBuilderToSpec(cifsSpec)
|
||||
break
|
||||
case 'cloud':
|
||||
spec = await configBuilderToSpec(
|
||||
target.provider === 'dropbox' ? dropboxSpec : googleDriveSpec,
|
||||
)
|
||||
break
|
||||
case 'disk':
|
||||
spec = await configBuilderToSpec(diskBackupTargetSpec)
|
||||
break
|
||||
}
|
||||
|
||||
this.formDialog.open(FormPage, {
|
||||
label: 'Update Target',
|
||||
data: {
|
||||
spec,
|
||||
value: target,
|
||||
buttons: [
|
||||
{
|
||||
text: 'Save',
|
||||
handler: (
|
||||
value:
|
||||
| RR.UpdateCifsBackupTargetReq
|
||||
| RR.UpdateCloudBackupTargetReq
|
||||
| RR.UpdateDiskBackupTargetReq,
|
||||
) => this.update(target.type, { ...value, id: target.id }),
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
presentAlertDelete(id: string, index: number) {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, {
|
||||
label: 'Confirm',
|
||||
size: 's',
|
||||
data: {
|
||||
content: 'Forget backup target? This actions cannot be undone.',
|
||||
no: 'Cancel',
|
||||
yes: 'Delete',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => this.delete(id, index))
|
||||
}
|
||||
|
||||
async delete(id: string, index: number): Promise<void> {
|
||||
const loader = this.loader.open('Removing...').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.removeBackupTarget({ id })
|
||||
this.targets.saved.splice(index, 1)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.loading$.next(true)
|
||||
await this.getTargets()
|
||||
}
|
||||
|
||||
getIcon(type: BackupTargetType) {
|
||||
switch (type) {
|
||||
case 'disk':
|
||||
return 'save-outline'
|
||||
case 'cifs':
|
||||
return 'folder-open-outline'
|
||||
case 'cloud':
|
||||
return 'cloud-outline'
|
||||
}
|
||||
}
|
||||
|
||||
private async getTargets(): Promise<void> {
|
||||
try {
|
||||
this.targets = await this.api.getBackupTargets({})
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading$.next(false)
|
||||
}
|
||||
}
|
||||
|
||||
private async add(
|
||||
type: BackupTargetType,
|
||||
value:
|
||||
| RR.AddCifsBackupTargetReq
|
||||
| RR.AddCloudBackupTargetReq
|
||||
| RR.AddDiskBackupTargetReq,
|
||||
): Promise<BackupTarget> {
|
||||
const loader = this.loader.open('Saving target...').subscribe()
|
||||
|
||||
try {
|
||||
return await this.api.addBackupTarget(type, value)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async update(
|
||||
type: BackupTargetType,
|
||||
value:
|
||||
| RR.UpdateCifsBackupTargetReq
|
||||
| RR.UpdateCloudBackupTargetReq
|
||||
| RR.UpdateDiskBackupTargetReq,
|
||||
): Promise<BackupTarget> {
|
||||
const loader = this.loader.open('Saving target...').subscribe()
|
||||
|
||||
try {
|
||||
return await this.api.updateBackupTarget(type, value)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { BadgeMenuComponentModule } from 'src/app/common/badge-menu-button/badge-menu.component.module'
|
||||
import { InsecureWarningComponentModule } from 'src/app/common/insecure-warning/insecure-warning.module'
|
||||
import { GenericInputComponentModule } from 'src/app/apps/ui/modals/generic-input/generic-input.component.module'
|
||||
import { BackupCreateDirective } from '../../directives/backup-create.directive'
|
||||
import { BackupRestoreDirective } from '../../directives/backup-restore.directive'
|
||||
import {
|
||||
BackingUpComponent,
|
||||
PkgMainStatusPipe,
|
||||
} from '../../components/backing-up/backing-up.component'
|
||||
import { BackupSelectPageModule } from '../../modals/backup-select/backup-select.module'
|
||||
import { RecoverSelectPageModule } from '../../modals/recover-select/recover-select.module'
|
||||
import { TargetPipesModule } from '../../pipes/target-pipes.module'
|
||||
import { BackupsPage } from './backups.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: BackupsPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
BackupSelectPageModule,
|
||||
RecoverSelectPageModule,
|
||||
BadgeMenuComponentModule,
|
||||
InsecureWarningComponentModule,
|
||||
TargetPipesModule,
|
||||
GenericInputComponentModule,
|
||||
],
|
||||
declarations: [
|
||||
BackupsPage,
|
||||
BackupCreateDirective,
|
||||
BackupRestoreDirective,
|
||||
BackingUpComponent,
|
||||
PkgMainStatusPipe,
|
||||
],
|
||||
})
|
||||
export class BackupsPageModule {}
|
||||
@@ -0,0 +1,112 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Backups</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<badge-menu-button></badge-menu-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content class="ion-padding">
|
||||
<insecure-warning *ngIf="!secure"></insecure-warning>
|
||||
|
||||
<ion-item-group>
|
||||
<ion-item-divider>Options</ion-item-divider>
|
||||
|
||||
<ion-item button backupCreate>
|
||||
<ion-icon slot="start" name="add"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>Create a Backup</h2>
|
||||
<p>Create a one-time backup</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item button backupRestore>
|
||||
<ion-icon slot="start" name="color-wand-outline"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>Restore From Backup</h2>
|
||||
<p>Restore services from backup</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item button routerLink="jobs">
|
||||
<ion-icon slot="start" name="hammer-outline"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>Jobs</h2>
|
||||
<p>Manage backup jobs</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item button routerLink="targets">
|
||||
<ion-icon slot="start" name="server-outline"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>Targets</h2>
|
||||
<p>Manage backup targets</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
<ion-item button routerLink="history">
|
||||
<ion-icon slot="start" name="archive-outline"></ion-icon>
|
||||
<ion-label>
|
||||
<h2>History</h2>
|
||||
<p>View your entire backup history</p>
|
||||
</ion-label>
|
||||
</ion-item>
|
||||
|
||||
<ion-item-divider>Upcoming Jobs</ion-item-divider>
|
||||
|
||||
<div class="grid-fixed">
|
||||
<ion-grid class="ion-padding">
|
||||
<ion-row class="grid-headings">
|
||||
<ion-col size="3">Scheduled</ion-col>
|
||||
<ion-col size="2.5">Job</ion-col>
|
||||
<ion-col size="3">Target</ion-col>
|
||||
<ion-col size="2.5">Packages</ion-col>
|
||||
</ion-row>
|
||||
<!-- loaded -->
|
||||
<ng-container *ngIf="upcoming$ | async as upcoming; else loading;">
|
||||
<ng-container *ngIf="current$ | async as current">
|
||||
<ion-row
|
||||
*ngFor="let upcoming of upcoming"
|
||||
class="ion-align-items-center grid-row-border"
|
||||
>
|
||||
<ion-col size="3">
|
||||
<ion-text
|
||||
*ngIf="current.id === upcoming.id; else notRunning"
|
||||
color="success"
|
||||
>
|
||||
Running
|
||||
</ion-text>
|
||||
<ng-template #notRunning>
|
||||
{{ upcoming.next | date : 'MMM d, y, h:mm a' }}
|
||||
</ng-template>
|
||||
</ion-col>
|
||||
<ion-col size="2.5">{{ upcoming.name }}</ion-col>
|
||||
<ion-col size="3" class="inline">
|
||||
<ion-icon
|
||||
[name]="(upcoming.target | getDisplayInfo).icon"
|
||||
size="small"
|
||||
></ion-icon>
|
||||
{{ upcoming.target.name }}
|
||||
</ion-col>
|
||||
<ion-col size="2.5">
|
||||
{{ upcoming['package-ids'].length }} Packages
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
|
||||
<p *ngIf="!upcoming.length">
|
||||
You have no active or upcoming backup jobs.
|
||||
</p>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
<!-- loading -->
|
||||
<ng-template #loading>
|
||||
<ion-row
|
||||
*ngFor="let row of ['', '']"
|
||||
class="ion-align-items-center grid-row-border"
|
||||
>
|
||||
<ion-col>
|
||||
<ion-skeleton-text animated></ion-skeleton-text>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ng-template>
|
||||
</ion-grid>
|
||||
</div>
|
||||
</ion-item-group>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,42 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { from, map } 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 { CronJob } from 'cron'
|
||||
|
||||
@Component({
|
||||
selector: 'backups',
|
||||
templateUrl: './backups.page.html',
|
||||
styleUrls: ['./backups.page.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class BackupsPage {
|
||||
readonly secure = this.config.isSecure()
|
||||
readonly current$ = this.patch
|
||||
.watch$('server-info', 'status-info', 'current-backup', 'job')
|
||||
.pipe(map(job => job || {}))
|
||||
readonly upcoming$ = from(this.api.getBackupJobs({})).pipe(
|
||||
map(jobs =>
|
||||
jobs
|
||||
.map(job => {
|
||||
const nextDate = new CronJob(job.cron, () => {}).nextDate()
|
||||
const next = nextDate.toISO()
|
||||
const diff = nextDate.diffNow().milliseconds
|
||||
return {
|
||||
...job,
|
||||
next,
|
||||
diff,
|
||||
}
|
||||
})
|
||||
.sort((a, b) => a.diff - b.diff),
|
||||
),
|
||||
)
|
||||
|
||||
constructor(
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly config: ConfigService,
|
||||
private readonly api: ApiService,
|
||||
) {}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { BackupTarget } from 'src/app/services/api/api.types'
|
||||
|
||||
@Pipe({
|
||||
name: 'getDisplayInfo',
|
||||
})
|
||||
export class GetDisplayInfoPipe implements PipeTransform {
|
||||
transform(target: BackupTarget): DisplayInfo {
|
||||
const toReturn: DisplayInfo = {
|
||||
name: target.name,
|
||||
path: `Path: ${target.path}`,
|
||||
description: '',
|
||||
icon: '',
|
||||
}
|
||||
|
||||
switch (target.type) {
|
||||
case 'cifs':
|
||||
toReturn.description = `Network Folder: ${target.hostname}`
|
||||
toReturn.icon = 'folder-open-outline'
|
||||
break
|
||||
case 'disk':
|
||||
toReturn.description = `Physical Drive: ${
|
||||
target.vendor || 'Unknown Vendor'
|
||||
}, ${target.model || 'Unknown Model'}`
|
||||
toReturn.icon = 'save-outline'
|
||||
break
|
||||
case 'cloud':
|
||||
toReturn.description = `Provider: ${target.provider}`
|
||||
toReturn.icon = 'cloud-outline'
|
||||
break
|
||||
}
|
||||
|
||||
return toReturn
|
||||
}
|
||||
}
|
||||
|
||||
interface DisplayInfo {
|
||||
name: string
|
||||
path: string
|
||||
description: string
|
||||
icon: string
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { BackupTarget } from 'src/app/services/api/api.types'
|
||||
import { Emver } from '@start9labs/shared'
|
||||
|
||||
@Pipe({
|
||||
name: 'hasValidBackup',
|
||||
})
|
||||
export class HasValidBackupPipe implements PipeTransform {
|
||||
constructor(private readonly emver: Emver) {}
|
||||
|
||||
transform(target: BackupTarget): boolean {
|
||||
const backup = target['embassy-os']
|
||||
return !!backup && this.emver.compare(backup.version, '0.3.0') !== -1
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { HasValidBackupPipe } from './has-valid-backup.pipe'
|
||||
import { GetDisplayInfoPipe } from './get-display-info.pipe'
|
||||
|
||||
@NgModule({
|
||||
declarations: [HasValidBackupPipe, GetDisplayInfoPipe],
|
||||
imports: [CommonModule],
|
||||
exports: [HasValidBackupPipe, GetDisplayInfoPipe],
|
||||
})
|
||||
export class TargetPipesModule {}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Config } from '@start9labs/start-sdk/lib/config/builder/config'
|
||||
import { Value } from '@start9labs/start-sdk/lib/config/builder/value'
|
||||
import { Variants } from '@start9labs/start-sdk/lib/config/builder/variants'
|
||||
|
||||
export const dropboxSpec = Config.of({
|
||||
name: Value.text({
|
||||
name: 'Name',
|
||||
description: 'A friendly name for this Dropbox target',
|
||||
placeholder: 'My Dropbox',
|
||||
required: { default: null },
|
||||
}),
|
||||
token: Value.text({
|
||||
name: 'Access Token',
|
||||
description: 'The secret access token for your custom Dropbox app',
|
||||
required: { default: null },
|
||||
masked: true,
|
||||
}),
|
||||
path: Value.text({
|
||||
name: 'Path',
|
||||
description: 'The fully qualified path to the backup directory',
|
||||
placeholder: 'e.g. /Desktop/my-folder',
|
||||
required: { default: null },
|
||||
}),
|
||||
})
|
||||
|
||||
export const googleDriveSpec = Config.of({
|
||||
name: Value.text({
|
||||
name: 'Name',
|
||||
description: 'A friendly name for this Google Drive target',
|
||||
placeholder: 'My Google Drive',
|
||||
required: { default: null },
|
||||
}),
|
||||
path: Value.text({
|
||||
name: 'Path',
|
||||
description: 'The fully qualified path to the backup directory',
|
||||
placeholder: 'e.g. /Desktop/my-folder',
|
||||
required: { default: null },
|
||||
}),
|
||||
key: Value.file({
|
||||
name: 'Private Key File',
|
||||
description:
|
||||
'Your Google Drive service account private key file (.json file)',
|
||||
required: true,
|
||||
extensions: ['json'],
|
||||
}),
|
||||
})
|
||||
|
||||
export const cifsSpec = Config.of({
|
||||
name: Value.text({
|
||||
name: 'Name',
|
||||
description: 'A friendly name for this Network Folder',
|
||||
placeholder: 'My Network Folder',
|
||||
required: { default: null },
|
||||
}),
|
||||
hostname: 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: { default: null },
|
||||
patterns: [],
|
||||
}),
|
||||
path: 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: { default: null },
|
||||
}),
|
||||
username: 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: { default: null },
|
||||
placeholder: 'My Network Folder',
|
||||
}),
|
||||
password: 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,
|
||||
masked: true,
|
||||
placeholder: 'My Network Folder',
|
||||
}),
|
||||
})
|
||||
|
||||
export const remoteBackupTargetSpec = Config.of({
|
||||
type: Value.union(
|
||||
{
|
||||
name: 'Target Type',
|
||||
required: { default: 'dropbox' },
|
||||
},
|
||||
Variants.of({
|
||||
dropbox: {
|
||||
name: 'Dropbox',
|
||||
spec: dropboxSpec,
|
||||
},
|
||||
'google-drive': {
|
||||
name: 'Google Drive',
|
||||
spec: googleDriveSpec,
|
||||
},
|
||||
cifs: {
|
||||
name: 'Network Folder',
|
||||
spec: cifsSpec,
|
||||
},
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
export const diskBackupTargetSpec = Config.of({
|
||||
name: Value.text({
|
||||
name: 'Name',
|
||||
description: 'A friendly name for this physical target',
|
||||
placeholder: 'My Physical Target',
|
||||
required: { default: null },
|
||||
}),
|
||||
path: Value.text({
|
||||
name: 'Path',
|
||||
description: 'The fully qualified path to the backup directory',
|
||||
placeholder: 'e.g. /Backups/my-folder',
|
||||
required: { default: null },
|
||||
}),
|
||||
})
|
||||
26
web/projects/ui/src/app/apps/ui/pages/home/home.module.ts
Normal file
26
web/projects/ui/src/app/apps/ui/pages/home/home.module.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { HomePage } from './home.page'
|
||||
import { BadgeMenuComponentModule } from 'src/app/common/badge-menu-button/badge-menu.component.module'
|
||||
import { WidgetListComponentModule } from 'src/app/common/widget-list/widget-list.component.module'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: HomePage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule.forChild(routes),
|
||||
BadgeMenuComponentModule,
|
||||
WidgetListComponentModule,
|
||||
],
|
||||
declarations: [HomePage],
|
||||
})
|
||||
export class HomePageModule {}
|
||||
13
web/projects/ui/src/app/apps/ui/pages/home/home.page.html
Normal file
13
web/projects/ui/src/app/apps/ui/pages/home/home.page.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-title>Home</ion-title>
|
||||
<ion-buttons slot="end">
|
||||
<badge-menu-button></badge-menu-button>
|
||||
</ion-buttons>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
<ion-content>
|
||||
<div class="padding-top">
|
||||
<widget-list></widget-list>
|
||||
</div>
|
||||
</ion-content>
|
||||
@@ -0,0 +1,9 @@
|
||||
.padding-top {
|
||||
padding-top: 2rem;
|
||||
}
|
||||
|
||||
@media (min-width: 2000px) {
|
||||
.padding-top {
|
||||
padding-top: 10rem;
|
||||
}
|
||||
}
|
||||
8
web/projects/ui/src/app/apps/ui/pages/home/home.page.ts
Normal file
8
web/projects/ui/src/app/apps/ui/pages/home/home.page.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Component } from '@angular/core'
|
||||
|
||||
@Component({
|
||||
selector: 'home',
|
||||
templateUrl: 'home.page.html',
|
||||
styleUrls: ['home.page.scss'],
|
||||
})
|
||||
export class HomePage {}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { Routes, RouterModule } from '@angular/router'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import {
|
||||
SharedPipesModule,
|
||||
EmverPipesModule,
|
||||
ResponsiveColModule,
|
||||
} from '@start9labs/shared'
|
||||
import {
|
||||
FilterPackagesPipeModule,
|
||||
CategoriesModule,
|
||||
ItemModule,
|
||||
SearchModule,
|
||||
SkeletonModule,
|
||||
} from '@start9labs/marketplace'
|
||||
import { BadgeMenuComponentModule } from 'src/app/common/badge-menu-button/badge-menu.component.module'
|
||||
import { StoreIconComponentModule } from 'src/app/common/store-icon/store-icon.component.module'
|
||||
import { MarketplaceStatusModule } from '../marketplace-status/marketplace-status.module'
|
||||
import { MarketplaceListPage } from './marketplace-list.page'
|
||||
import { MarketplaceSettingsPageModule } from './marketplace-settings/marketplace-settings.module'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: MarketplaceListPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
FormsModule,
|
||||
RouterModule.forChild(routes),
|
||||
SharedPipesModule,
|
||||
EmverPipesModule,
|
||||
FilterPackagesPipeModule,
|
||||
MarketplaceStatusModule,
|
||||
BadgeMenuComponentModule,
|
||||
ItemModule,
|
||||
CategoriesModule,
|
||||
SearchModule,
|
||||
SkeletonModule,
|
||||
MarketplaceSettingsPageModule,
|
||||
StoreIconComponentModule,
|
||||
ResponsiveColModule,
|
||||
],
|
||||
declarations: [MarketplaceListPage],
|
||||
exports: [MarketplaceListPage],
|
||||
})
|
||||
export class MarketplaceListPageModule {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user