Merge branch 'next/minor' of github.com:Start9Labs/start-os into next/major

This commit is contained in:
Aiden McClelland
2023-11-13 14:59:16 -07:00
1115 changed files with 6871 additions and 1851 deletions

View 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 {}

View 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>

View 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);
}
}

View 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()
}
}

View 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 {}

View 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()
}
}

View File

@@ -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>

View File

@@ -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 {}

View File

@@ -0,0 +1,9 @@
.connection-toolbar {
padding: 0 24px;
--min-height: 36px;
}
.icon {
font-size: 23px;
padding-right: 12px;
}

View File

@@ -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>,
) {}
}

View 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>

View 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;
}

View 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))
}
}

View 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 {}

View 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>

View 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;
}

View 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,
) {}
}

View 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 {}

View File

@@ -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>

View 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
}

View 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 {}

View 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>

View File

@@ -0,0 +1,6 @@
.canvas-center {
padding-top: 20px;
display: flex;
align-items: center;
justify-content: center;
}

View 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
}
}

View 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()
}
}

View 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 {}

View 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 {}

View File

@@ -0,0 +1,4 @@
<app-loading
class="ion-page"
(finished)="navCtrl.navigateForward('/login')"
></app-loading>

View 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)
}

View File

@@ -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>

View File

@@ -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%;
}
}

View File

@@ -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
})
}
}

View 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 {}

View 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>

View 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;
}

View 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()
}
}
}

View File

@@ -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 {}

View File

@@ -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>

View File

@@ -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)
}
}

View 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 {}

View 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>

View 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);
}

View 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()
})
}
}

View File

@@ -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>

View File

@@ -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 {}

View File

@@ -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
}

View File

@@ -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 {}

View File

@@ -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>
&nbsp;
<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>

View File

@@ -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>) {}
}

View File

@@ -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())
}
}

View File

@@ -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()
}
}

View File

@@ -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 {}

View File

@@ -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>

View File

@@ -0,0 +1,5 @@
.center {
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -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
}
}

View File

@@ -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 {}

View File

@@ -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>

View File

@@ -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()
}
}
}

View File

@@ -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
}
}

View File

@@ -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 {}

View File

@@ -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>

View File

@@ -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
}

View File

@@ -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>

View File

@@ -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 {}

View File

@@ -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>
&nbsp; {{ run.job.target.name }}
</ion-col>
</ion-row>
</ng-template>
</ion-grid>
</div>
</ion-content>

View File

@@ -0,0 +1,3 @@
.highlighted {
background-color: var(--ion-color-medium-shade);
}

View File

@@ -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
}
}

View File

@@ -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 {}

View File

@@ -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>
&nbsp; {{ 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>

View File

@@ -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()
}
}
}

View File

@@ -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>

View File

@@ -0,0 +1,3 @@
h2 {
font-weight: bold;
}

View File

@@ -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
}
}
}

View File

@@ -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>

View File

@@ -0,0 +1,9 @@
h2 {
font-weight: bold;
}
.input-label {
margin-bottom: 6px;
font-size: medium;
font-weight: bold;
}

View File

@@ -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'],
}
}
}

View File

@@ -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>

View File

@@ -0,0 +1,3 @@
h2 {
font-weight: bold;
}

View File

@@ -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
}
}
}

View File

@@ -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
}
}

View File

@@ -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 {}

View File

@@ -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>
&nbsp; {{ 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>

View File

@@ -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()
}
}
}

View File

@@ -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 {}

View File

@@ -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>
&nbsp; {{ 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>

View File

@@ -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,
) {}
}

View File

@@ -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
}

View File

@@ -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
}
}

View File

@@ -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 {}

View File

@@ -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 },
}),
})

View 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 {}

View 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>

View File

@@ -0,0 +1,9 @@
.padding-top {
padding-top: 2rem;
}
@media (min-width: 2000px) {
.padding-top {
padding-top: 10rem;
}
}

View File

@@ -0,0 +1,8 @@
import { Component } from '@angular/core'
@Component({
selector: 'home',
templateUrl: 'home.page.html',
styleUrls: ['home.page.scss'],
})
export class HomePage {}

View File

@@ -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