rename frontend to web and update contributing guide (#2509)

* rename frontend to web and update contributing guide

* rename this time

* fix build

* restructure rust code

* update documentation

* update descriptions

* Update CONTRIBUTING.md

Co-authored-by: J H <2364004+Blu-J@users.noreply.github.com>

---------

Co-authored-by: Aiden McClelland <me@drbonez.dev>
Co-authored-by: Aiden McClelland <3732071+dr-bonez@users.noreply.github.com>
Co-authored-by: J H <2364004+Blu-J@users.noreply.github.com>
This commit is contained in:
Matt Hill
2023-11-13 14:22:23 -07:00
committed by GitHub
parent 871f78b570
commit 86567e7fa5
968 changed files with 812 additions and 6672 deletions

View File

@@ -0,0 +1,31 @@
{
"$schema": "../../node_modules/@angular/service-worker/config/schema.json",
"index": "/index.html",
"assetGroups": [
{
"name": "app",
"installMode": "prefetch",
"resources": {
"files": [
"/favicon.ico",
"/index.html",
"/manifest.webmanifest",
"/*.css",
"/*.js"
]
}
},
{
"name": "assets",
"installMode": "lazy",
"updateMode": "prefetch",
"resources": {
"files": [
"/assets/**",
"/svg/**",
"/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
]
}
}
]
}

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 {
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 { AppRoutingModule } from './app-routing.module'
import { OSWelcomePageModule } from './modals/os-welcome/os-welcome.module'
import { GenericInputComponentModule } from './modals/generic-input/generic-input.component.module'
import { MarketplaceModule } from './marketplace.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 './components/toast-container/toast-container.module'
import { ConnectionBarComponentModule } from './components/connection-bar/connection-bar.component.module'
import { WidgetsPageModule } from './pages/widgets/widgets.module'
import { ServiceWorkerModule } from '@angular/service-worker'
import { environment } from '../environments/environment'
@NgModule({
declarations: [AppComponent],
imports: [
HttpClientModule,
BrowserAnimationsModule,
IonicModule.forRoot({
mode: 'md',
}),
AppRoutingModule,
MenuModule,
PreloaderModule,
FooterModule,
EnterModule,
OSWelcomePageModule,
MarkdownModule,
GenericInputComponentModule,
MonacoEditorModule,
SharedPipesModule,
MarketplaceModule,
PatchDbModule,
ToastContainerModule,
ConnectionBarComponentModule,
TuiRootModule,
TuiDialogModule,
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',
}),
],
providers: APP_PROVIDERS,
bootstrap: [AppComponent],
})
export class AppModule {}

View File

@@ -0,0 +1,57 @@
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 { RELATIVE_URL, THEME, WorkspaceConfig } from '@start9labs/shared'
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'
const {
useMocks,
ui: { api },
} = require('../../../../config.json') as WorkspaceConfig
export const APP_PROVIDERS: Provider[] = [
FilterPackagesPipe,
UntypedFormBuilder,
IonNav,
{
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,
},
]
export function appInitializer(
auth: AuthService,
localStorage: ClientStorageService,
router: Router,
): () => void {
return () => {
auth.init()
localStorage.init()
router.initialNavigation()
}
}

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 { heightCollapse } from '../../util/animations'
import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs/operators'
import { DataModel } from '../../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,132 @@
import {
ChangeDetectionStrategy,
Component,
inject,
Inject,
} from '@angular/core'
import { EOSService } from '../../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: '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 =>
p['install-progress'] &&
!curr[p.manifest.id]?.['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]?.installed?.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 'src/app/components/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,80 @@
<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: 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: 100">a</p>
<p style="font-family: Redacted">a</p>
</div>

View File

@@ -0,0 +1,99 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
// TODO: Turn into DI token if this is needed someplace else too
const ICONS = [
'add',
'alert-outline',
'alert-circle-outline',
'aperture-outline',
'arrow-back',
'arrow-forward',
'arrow-up',
'briefcase-outline',
'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',
'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',
'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,54 @@
import { Directive, HostListener, Input } from '@angular/core'
import { LoadingController, ModalController } from '@ionic/angular'
import { ErrorToastService } from '@start9labs/shared'
import { SnakePage } from '../../modals/snake/snake.page'
import { ApiService } from '../../services/api/embassy-api.service'
@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...',
backdropDismiss: true,
})
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,11 @@
import { NgModule } from '@angular/core'
import { SnakePageModule } from '../../modals/snake/snake.module'
import { SnekDirective } from './snek.directive'
@NgModule({
imports: [SnakePageModule],
declarations: [SnekDirective],
exports: [SnekDirective],
})
export class SnekModule {}

View File

@@ -0,0 +1,17 @@
<ng-template #content>
<ng-content></ng-content>
</ng-template>
<a
*ngIf="externalLink; else internal"
[href]="link"
target="_blank"
rel="noreferrer"
>
<ng-container *ngTemplateOutlet="content"></ng-container>
</a>
<ng-template #internal>
<a [routerLink]="link" [queryParams]="qp">
<ng-container *ngTemplateOutlet="content"></ng-container>
</a>
</ng-template>

View File

@@ -0,0 +1,12 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { RouterModule } from '@angular/router'
import { AnyLinkComponent } from './any-link.component'
@NgModule({
declarations: [AnyLinkComponent],
imports: [CommonModule, RouterModule.forChild([])],
exports: [AnyLinkComponent],
})
export class AnyLinkModule {}

View File

@@ -0,0 +1,4 @@
a {
text-decoration: none;
color: unset;
}

View File

@@ -0,0 +1,27 @@
import {
Component,
Input,
ChangeDetectionStrategy,
OnInit,
} from '@angular/core'
@Component({
selector: 'any-link',
templateUrl: './any-link.component.html',
styleUrls: ['./any-link.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AnyLinkComponent implements OnInit {
@Input() link!: string
@Input() qp?: Record<string, string>
externalLink = false
ngOnInit() {
try {
const _ = new URL(this.link)
this.externalLink = true
} catch {
this.externalLink = false
}
}
}

View File

@@ -0,0 +1,16 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button defaultHref="system"></ion-back-button>
</ion-buttons>
<ion-title>
{{ type === 'create' ? 'Create Backup' : 'Restore From Backup' }}
</ion-title>
<ion-buttons slot="end">
<ion-button [disabled]="loading" (click)="refresh()">
Refresh
<ion-icon slot="end" name="refresh"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>

View File

@@ -0,0 +1,20 @@
<div class="inline">
<h2 *ngIf="type === 'create'; else restore">
<ion-icon name="cloud-outline" color="success"></ion-icon>
{{
hasValidBackup
? 'Available, contains existing backup'
: 'Available for fresh backup'
}}
</h2>
<ng-template #restore>
<h2 *ngIf="hasValidBackup">
<ion-icon name="cloud-done-outline" color="success"></ion-icon>
StartOS backup detected
</h2>
<h2 *ngIf="!hasValidBackup">
<ion-icon name="cloud-offline-outline" color="danger"></ion-icon>
No StartOS backup
</h2>
</ng-template>
</div>

View File

@@ -0,0 +1,172 @@
<backup-drives-header [type]="type"></backup-drives-header>
<ion-content class="ion-padding with-widgets">
<!-- loading -->
<text-spinner
*ngIf="loading; else loaded"
[text]="loadingText"
></text-spinner>
<!-- loaded -->
<ng-template #loaded>
<!-- error -->
<ion-item *ngIf="loadingError; else noError">
<ion-label>
<ion-text color="danger">
{{ loadingError }}
</ion-text>
</ion-label>
</ion-item>
<ng-template #noError>
<ion-item-group>
<!-- ** cifs ** -->
<ion-item-divider>Network Folders</ion-item-divider>
<ion-item>
<ion-label>
<h2>
{{
type === 'create'
? 'Backup server to'
: 'Restore your services from'
}}
a folder on another computer that is connected to the same network
as your Start9 server. View the
<a
href="https://docs.start9.com/0.3.5.x/user-manual/backups/backup-create#network-folder"
target="_blank"
noreferrer
style="text-decoration: none"
>
Instructions
<ion-icon name="open-outline" size="small"></ion-icon>
</a>
</h2>
</ion-label>
</ion-item>
<!-- add new cifs -->
<ion-item button detail="false" (click)="presentModalAddCifs()">
<ion-icon
slot="start"
name="add"
size="large"
color="dark"
></ion-icon>
<ion-label>
<b>Open New</b>
</ion-label>
</ion-item>
<!-- cifs list -->
<ng-container *ngFor="let target of cifs; let i = index">
<ion-item
button
*ngIf="target.entry as cifs"
(click)="select(target)"
>
<ion-icon
slot="start"
name="folder-open-outline"
size="large"
></ion-icon>
<ion-label>
<h1>{{ cifs.path.split('/').pop() }}</h1>
<ng-container *ngIf="cifs.mountable">
<backup-drives-status
[type]="type"
[hasValidBackup]="target.hasValidBackup"
></backup-drives-status>
</ng-container>
<h2 *ngIf="!cifs.mountable" class="inline">
<ion-icon name="cellular-outline" color="danger"></ion-icon>
Unable to connect
</h2>
<p>Hostname: {{ cifs.hostname }}</p>
<p>Path: {{ cifs.path }}</p>
</ion-label>
<ion-note
slot="end"
class="click-area"
(click)="presentActionCifs($event, target, i)"
>
<ion-icon name="ellipsis-horizontal"></ion-icon>
</ion-note>
</ion-item>
</ng-container>
<br />
<!-- ** drives ** -->
<ion-item-divider>Physical Drives</ion-item-divider>
<!-- always -->
<ion-item>
<ion-label>
<h2>
{{
type === 'create'
? 'Backup server to'
: 'Restore your services from'
}}
a physical drive that is plugged directly into your Start9 Server.
View the
<a
href="https://docs.start9.com/0.3.5.x/user-manual/backups/backup-create#physical-drive"
target="_blank"
noreferrer
style="text-decoration: none"
>
Instructions
<ion-icon name="open-outline" size="small"></ion-icon>
</a>
.
<ion-text color="warning">
Warning. Do not use this option if you are using a Raspberry Pi
with an external SSD. The Raspberry Pi does not support more
than one external drive without additional power and can cause
data corruption.
</ion-text>
</h2>
</ion-label>
</ion-item>
<!-- no drives -->
<div
*ngIf="!drives.length; else hasDrives"
class="ion-padding-bottom ion-text-center"
>
<br />
<p>
No drives detected.
<a style="cursor: pointer" (click)="refresh()">
Refresh
<ion-icon name="refresh"></ion-icon>
</a>
</p>
</div>
<!-- drives detected -->
<ng-template #hasDrives>
<ion-item
button
*ngFor="let target of drives"
(click)="select(target)"
>
<ion-icon slot="start" name="save-outline" size="large"></ion-icon>
<ng-container *ngIf="target.entry as drive">
<ion-label>
<h1>{{ drive.label || drive.logicalname }}</h1>
<backup-drives-status
[type]="type"
[hasValidBackup]="target.hasValidBackup"
></backup-drives-status>
<p>
{{ drive.vendor || 'Unknown Vendor' }} -
{{ drive.model || 'Unknown Model' }}
</p>
<p>Capacity: {{ drive.capacity | convertBytes }}</p>
</ion-label>
</ng-container>
</ion-item>
</ng-template>
</ion-item-group>
</ng-template>
</ng-template>
</ion-content>

View File

@@ -0,0 +1,34 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import {
BackupDrivesComponent,
BackupDrivesHeaderComponent,
BackupDrivesStatusComponent,
} from './backup-drives.component'
import {
UnitConversionPipesModule,
TextSpinnerComponentModule,
} from '@start9labs/shared'
import { GenericFormPageModule } from 'src/app/modals/generic-form/generic-form.module'
@NgModule({
declarations: [
BackupDrivesComponent,
BackupDrivesHeaderComponent,
BackupDrivesStatusComponent,
],
imports: [
CommonModule,
IonicModule,
UnitConversionPipesModule,
TextSpinnerComponentModule,
GenericFormPageModule,
],
exports: [
BackupDrivesComponent,
BackupDrivesHeaderComponent,
BackupDrivesStatusComponent,
],
})
export class BackupDrivesComponentModule {}

View File

@@ -0,0 +1,18 @@
.click-area {
padding: 50px;
&:hover {
background-color: var(--ion-color-medium-tint);
}
ion-icon {
font-size: 27px;
}
}
@media (max-width: 1000px) {
.click-area {
padding: 18px 0px 10px;
}
}

View File

@@ -0,0 +1,313 @@
import { Component, EventEmitter, Input, Output } from '@angular/core'
import { BackupService } from './backup.service'
import {
CifsBackupTarget,
DiskBackupTarget,
RR,
} from 'src/app/services/api/api.types'
import {
ActionSheetController,
AlertController,
LoadingController,
ModalController,
} from '@ionic/angular'
import { GenericFormPage } from 'src/app/modals/generic-form/generic-form.page'
import { ConfigSpec } from 'src/app/pkg-config/config-types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ErrorToastService } from '@start9labs/shared'
import { MappedBackupTarget } from 'src/app/types/mapped-backup-target'
type BackupType = 'create' | 'restore'
@Component({
selector: 'backup-drives',
templateUrl: './backup-drives.component.html',
styleUrls: ['./backup-drives.component.scss'],
})
export class BackupDrivesComponent {
@Input() type!: BackupType
@Output() onSelect: EventEmitter<
MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>
> = new EventEmitter()
loadingText = ''
constructor(
private readonly loadingCtrl: LoadingController,
private readonly actionCtrl: ActionSheetController,
private readonly alertCtrl: AlertController,
private readonly modalCtrl: ModalController,
private readonly embassyApi: ApiService,
private readonly errToast: ErrorToastService,
private readonly backupService: BackupService,
) {}
get loading() {
return this.backupService.loading
}
get loadingError() {
return this.backupService.loadingError
}
get drives() {
return this.backupService.drives
}
get cifs() {
return this.backupService.cifs
}
ngOnInit() {
this.loadingText =
this.type === 'create'
? 'Fetching Backup Targets'
: 'Fetching Backup Sources'
this.backupService.getBackupTargets()
}
select(
target: MappedBackupTarget<CifsBackupTarget | DiskBackupTarget>,
): void {
if (target.entry.type === 'cifs' && !target.entry.mountable) {
const message =
'Unable to connect to Network Folder. Ensure (1) target computer is connected to the same LAN as your Start9 Server, (2) target folder is being shared, and (3) hostname, path, and credentials are accurate.'
this.presentAlertError(message)
return
}
if (this.type === 'restore' && !target.hasValidBackup) {
const message = `${
target.entry.type === 'cifs' ? 'Network Folder' : 'Drive partition'
} does not contain a valid Start9 Server backup.`
this.presentAlertError(message)
return
}
this.onSelect.emit(target)
}
async presentModalAddCifs(): Promise<void> {
const modal = await this.modalCtrl.create({
component: GenericFormPage,
componentProps: {
title: 'New Network Folder',
spec: CifsSpec,
buttons: [
{
text: 'Connect',
handler: (value: RR.AddBackupTargetReq) => {
return this.addCifs(value)
},
isSubmit: true,
},
],
},
})
await modal.present()
}
async presentActionCifs(
event: Event,
target: MappedBackupTarget<CifsBackupTarget>,
index: number,
): Promise<void> {
event.stopPropagation()
const entry = target.entry as CifsBackupTarget
const action = await this.actionCtrl.create({
header: entry.hostname,
subHeader: 'Shared Folder',
mode: 'ios',
buttons: [
{
text: 'Forget',
icon: 'trash',
role: 'destructive',
handler: () => {
this.deleteCifs(target.id, index)
},
},
{
text: 'Edit',
icon: 'pencil',
handler: () => {
this.presentModalEditCifs(target.id, entry, index)
},
},
],
})
await action.present()
}
private async presentAlertError(message: string): Promise<void> {
const alert = await this.alertCtrl.create({
header: 'Error',
message,
buttons: ['OK'],
})
await alert.present()
}
private async addCifs(value: RR.AddBackupTargetReq): Promise<boolean> {
const loader = await this.loadingCtrl.create({
message: 'Testing connectivity to shared folder...',
})
await loader.present()
try {
const res = await this.embassyApi.addBackupTarget(value)
const [id, entry] = Object.entries(res)[0]
this.backupService.cifs.unshift({
id,
hasValidBackup: this.backupService.hasValidBackup(entry),
entry,
})
return true
} catch (e: any) {
this.errToast.present(e)
return false
} finally {
loader.dismiss()
}
}
private async presentModalEditCifs(
id: string,
entry: CifsBackupTarget,
index: number,
): Promise<void> {
const { hostname, path, username } = entry
const modal = await this.modalCtrl.create({
component: GenericFormPage,
componentProps: {
title: 'Update Shared Folder',
spec: CifsSpec,
buttons: [
{
text: 'Save',
handler: (value: RR.AddBackupTargetReq) => {
return this.editCifs({ id, ...value }, index)
},
isSubmit: true,
},
],
initialValue: {
hostname,
path,
username,
},
},
})
await modal.present()
}
private async editCifs(
value: RR.UpdateBackupTargetReq,
index: number,
): Promise<void> {
const loader = await this.loadingCtrl.create({
message: 'Testing connectivity to shared folder...',
})
await loader.present()
try {
const res = await this.embassyApi.updateBackupTarget(value)
this.backupService.cifs[index].entry = Object.values(res)[0]
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
private async deleteCifs(id: string, index: number): Promise<void> {
const loader = await this.loadingCtrl.create({
message: 'Removing...',
})
await loader.present()
try {
await this.embassyApi.removeBackupTarget({ id })
this.backupService.cifs.splice(index, 1)
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
refresh() {
this.backupService.getBackupTargets()
}
}
@Component({
selector: 'backup-drives-header',
templateUrl: './backup-drives-header.component.html',
styleUrls: ['./backup-drives.component.scss'],
})
export class BackupDrivesHeaderComponent {
@Input() type!: BackupType
@Output() onClose: EventEmitter<void> = new EventEmitter()
constructor(private readonly backupService: BackupService) {}
get loading() {
return this.backupService.loading
}
refresh() {
this.backupService.getBackupTargets()
}
}
@Component({
selector: 'backup-drives-status',
templateUrl: './backup-drives-status.component.html',
styleUrls: ['./backup-drives.component.scss'],
})
export class BackupDrivesStatusComponent {
@Input() type!: BackupType
@Input() hasValidBackup!: boolean
}
const CifsSpec: ConfigSpec = {
hostname: {
type: 'string',
name: 'Hostname/IP',
description:
'The hostname or IP address of the target device on your Local Area Network.',
placeholder: `e.g. 'MyComputer.local' OR '192.168.1.4'`,
nullable: false,
masked: false,
copyable: false,
},
path: {
type: 'string',
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',
nullable: false,
masked: false,
copyable: false,
},
username: {
type: 'string',
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.`,
nullable: false,
masked: false,
copyable: false,
},
password: {
type: 'string',
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.`,
nullable: true,
masked: true,
copyable: false,
},
}

View File

@@ -0,0 +1,62 @@
import { Injectable } from '@angular/core'
import { IonicSafeString } from '@ionic/core'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import {
BackupTarget,
CifsBackupTarget,
DiskBackupTarget,
} from 'src/app/services/api/api.types'
import { MappedBackupTarget } from 'src/app/types/mapped-backup-target'
import { getErrorMessage, Emver } from '@start9labs/shared'
@Injectable({
providedIn: 'root',
})
export class BackupService {
cifs: MappedBackupTarget<CifsBackupTarget>[] = []
drives: MappedBackupTarget<DiskBackupTarget>[] = []
loading = true
loadingError: string | IonicSafeString = ''
constructor(
private readonly embassyApi: ApiService,
private readonly emver: Emver,
) {}
async getBackupTargets(): Promise<void> {
this.loading = true
try {
const targets = await this.embassyApi.getBackupTargets({})
// cifs
this.cifs = Object.entries(targets)
.filter(([_, target]) => target.type === 'cifs')
.map(([id, cifs]) => {
return {
id,
hasValidBackup: this.hasValidBackup(cifs),
entry: cifs as CifsBackupTarget,
}
})
// drives
this.drives = Object.entries(targets)
.filter(([_, target]) => target.type === 'disk')
.map(([id, drive]) => {
return {
id,
hasValidBackup: this.hasValidBackup(drive),
entry: drive as DiskBackupTarget,
}
})
} catch (e: any) {
this.loadingError = getErrorMessage(e)
} finally {
this.loading = false
}
}
hasValidBackup(target: BackupTarget): boolean {
const backup = target['embassy-os']
return !!backup && this.emver.compare(backup.version, '0.3.0') !== -1
}
}

View File

@@ -0,0 +1,24 @@
<ng-container *ngIf="enableWidgets">
<ion-button class="widgets" color="dark" (click)="onWidgets()">
<ion-icon name="grid-outline"></ion-icon>
</ion-button>
<ion-button
*ngIf="widgetDrawer$ | async as drawer"
class="sidebar"
color="dark"
(click)="onSidebar(drawer)"
>
<ion-icon name="grid-outline"></ion-icon>
</ion-button>
</ng-container>
<div class="wrapper">
<ion-badge
*ngIf="!(sidebarOpen$ | async) && (unreadCount$ | async) as unreadCount"
mode="md"
class="md-badge"
color="danger"
>
{{ unreadCount }}
</ion-badge>
<ion-menu-button color="dark"></ion-menu-button>
</div>

View File

@@ -0,0 +1,11 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { BadgeMenuComponent } from './badge-menu.component'
@NgModule({
imports: [CommonModule, IonicModule],
declarations: [BadgeMenuComponent],
exports: [BadgeMenuComponent],
})
export class BadgeMenuComponentModule {}

View File

@@ -0,0 +1,33 @@
:host {
display: flex;
align-items: center;
padding-right: 8px;
}
.sidebar {
display: none;
}
@media screen and (min-width: 992px) {
.widgets {
display: none;
}
.sidebar {
display: inline-block;
}
}
.wrapper {
position: relative;
}
.md-badge {
background-color: var(--ion-color-danger);
position: absolute;
top: -8px;
left: 56%;
border-radius: 6px;
z-index: 1;
font-size: 80%;
}

View File

@@ -0,0 +1,49 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { SplitPaneTracker } from 'src/app/services/split-pane.service'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
import { TuiDialogService } from '@taiga-ui/core'
import { WIDGETS_COMPONENT } from '../../pages/widgets/widgets.page'
import { WorkspaceConfig } from '@start9labs/shared'
import {
ClientStorageService,
WidgetDrawer,
} from 'src/app/services/client-storage.service'
const { enableWidgets } =
require('../../../../../../config.json') as WorkspaceConfig
@Component({
selector: 'badge-menu-button',
templateUrl: './badge-menu.component.html',
styleUrls: ['./badge-menu.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class BadgeMenuComponent {
readonly unreadCount$ = this.patch.watch$(
'server-info',
'unread-notification-count',
)
readonly sidebarOpen$ = this.splitPane.sidebarOpen$
readonly widgetDrawer$ = this.clientStorageService.widgetDrawer$
readonly enableWidgets = enableWidgets
constructor(
private readonly splitPane: SplitPaneTracker,
private readonly patch: PatchDB<DataModel>,
private readonly dialog: TuiDialogService,
private readonly clientStorageService: ClientStorageService,
) {}
onSidebar(drawer: WidgetDrawer) {
this.clientStorageService.updateWidgetDrawer({
...drawer,
open: !drawer.open,
})
}
onWidgets() {
this.dialog.open(WIDGETS_COMPONENT, { label: 'Widgets' }).subscribe()
}
}

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,16 @@
<ion-button
*ngIf="data.description"
class="slot-start"
fill="clear"
(click)="presentAlertDescription($event)"
>
<ion-icon name="help-circle-outline" slot="icon-only" size="small"></ion-icon>
</ion-button>
<span>{{ data.name }}</span>
<ion-text color="success" *ngIf="data.new">&nbsp;(New)</ion-text>
<ion-text color="success" *ngIf="data.newOptions">&nbsp;(New Options)</ion-text>
<ion-text color="warning" *ngIf="data.edited">&nbsp;(Edited)</ion-text>
<span *ngIf="data.required">&nbsp;*</span>

View File

@@ -0,0 +1,372 @@
<ion-item-group [formGroup]="formGroup">
<div *ngFor="let entry of formGroup.controls | keyvalue: asIsOrder">
<div *ngIf="objectSpec[entry.key] as spec">
<!-- string or number -->
<ng-container *ngIf="spec.type === 'string' || spec.type === 'number'">
<!-- label -->
<h4 class="input-label">
<form-label
[data]="{
name: spec.name,
description: spec.description,
new: original?.[entry.key] === undefined,
edited: entry.value.dirty,
required: !spec.nullable
}"
></form-label>
</h4>
<ion-item [color]="(theme$ | async) === 'Light' ? 'light' : 'dark'">
<ion-textarea
*ngIf="spec.type === 'string' && spec.textarea; else notTextArea"
[placeholder]="spec.placeholder || 'Enter ' + spec.name"
[formControlName]="entry.key"
(ionFocus)="presentAlertChangeWarning(entry.key, spec)"
(ionChange)="handleInputChange()"
></ion-textarea>
<ng-template #notTextArea>
<ion-input
type="text"
[inputmode]="spec.type === 'number' ? 'tel' : 'text'"
[class.redacted]="
spec.type === 'string' &&
entry.value.value &&
spec.masked &&
!unmasked[entry.key]
"
[placeholder]="spec.placeholder || 'Enter ' + spec.name"
[formControlName]="entry.key"
(ionFocus)="presentAlertChangeWarning(entry.key, spec)"
(ionChange)="handleInputChange()"
></ion-input>
</ng-template>
<ion-button
*ngIf="spec.type === 'string' && spec.masked"
slot="end"
fill="clear"
color="light"
(click)="unmasked[entry.key] = !unmasked[entry.key]"
>
<ion-icon
slot="icon-only"
[name]="unmasked[entry.key] ? 'eye-off-outline' : 'eye-outline'"
size="small"
></ion-icon>
</ion-button>
<ion-note
*ngIf="spec.type === 'number' && spec.units"
slot="end"
color="light"
style="font-size: medium"
>
{{ spec.units }}
</ion-note>
</ion-item>
<p class="error-message">
<span *ngIf="(formGroup | getControl: entry.key).errors as errors">
{{ errors | getError: $any(spec)['pattern-description'] }}
</span>
</p>
</ng-container>
<!-- boolean or enum -->
<ion-item
*ngIf="spec.type === 'boolean' || spec.type === 'enum'"
style="--padding-start: 0"
>
<ion-button
*ngIf="spec.description"
fill="clear"
(click)="presentAlertBoolEnumDescription($event, spec)"
style="--padding-start: 0"
>
<ion-icon
name="help-circle-outline"
slot="icon-only"
size="small"
></ion-icon>
</ion-button>
<ion-label>
<b>
{{ spec.name }}
<ion-text
*ngIf="original?.[entry.key] === undefined"
color="success"
>
(New)
</ion-text>
<ion-text *ngIf="entry.value.dirty" color="warning">
(Edited)
</ion-text>
</b>
</ion-label>
<!-- boolean -->
<ion-toggle
*ngIf="spec.type === 'boolean'"
slot="end"
[formControlName]="entry.key"
(ionChange)="handleBooleanChange(entry.key, spec)"
></ion-toggle>
<!-- enum -->
<!-- class enter-click disables the enter click on the modal behind the select -->
<ion-select
*ngIf="spec.type === 'enum' && formGroup.get(entry.key) as control"
[interfaceOptions]="{
message: spec.warning | toWarningText,
cssClass: 'enter-click'
}"
slot="end"
placeholder="Select"
[formControlName]="entry.key"
[selectedText]="spec['value-names'][control.value]"
>
<ion-select-option
*ngFor="let option of spec.values"
[value]="option"
>
{{ spec['value-names'][option] }}
</ion-select-option>
</ion-select>
</ion-item>
<!-- object -->
<ng-container *ngIf="spec.type === 'object'">
<!-- label -->
<ion-item-divider
(click)="toggleExpandObject(entry.key)"
style="cursor: pointer"
[class.error-border]="entry.value.invalid"
>
<form-label
[data]="{
name: spec.name,
description: spec.description,
new: original?.[entry.key] === undefined,
edited: entry.value.dirty,
newOptions: objectDisplay[entry.key].hasNewOptions
}"
></form-label>
<ion-icon
slot="end"
name="chevron-up"
[color]="entry.value.invalid ? 'danger' : undefined"
[ngStyle]="{
transform: objectDisplay[entry.key].expanded
? 'rotate(0deg)'
: 'rotate(180deg)',
transition: 'transform 0.42s ease-out'
}"
></ion-icon>
</ion-item-divider>
<!-- body -->
<tui-expand
[expanded]="objectDisplay[entry.key].expanded"
[id]="objectId | toElementId: entry.key"
>
<div class="nested-wrapper">
<form-object
[objectSpec]="spec.spec"
[formGroup]="$any(entry.value)"
[current]="current?.[entry.key]"
[original]="original?.[entry.key]"
(hasNewOptions)="setHasNew(entry.key)"
></form-object>
</div>
</tui-expand>
</ng-container>
<!-- union -->
<form-union
*ngIf="spec.type === 'union'"
[spec]="spec"
[formGroup]="$any(entry.value)"
[current]="current?.[entry.key]"
[original]="original?.[entry.key]"
></form-union>
<!-- list (not enum) -->
<ng-container *ngIf="spec.type === 'list' && spec.subtype !== 'enum'">
<ng-container
*ngIf="formGroup.get(entry.key) as formArr"
[formArrayName]="entry.key"
>
<!-- label -->
<ion-item-divider [class.error-border]="entry.value.invalid">
<form-label
[data]="{
name: spec.name,
description: spec.description,
new: original?.[entry.key] === undefined,
edited: entry.value.dirty,
required: !!(spec.range | toRange).min
}"
></form-label>
<ion-button
strong
fill="clear"
color="dark"
slot="end"
(click)="addListItemWrapper(entry.key, spec)"
>
<ion-icon slot="start" name="add"></ion-icon>
Add
</ion-button>
</ion-item-divider>
<p class="error-message" style="margin-bottom: 8px">
<span *ngIf="(formGroup | getControl: entry.key).errors as errors">
{{ errors | getError }}
</span>
</p>
<!-- body -->
<div class="nested-wrapper">
<div
*ngFor="
let abstractControl of $any(formArr).controls;
let i = index
"
>
<!-- object or union -->
<ng-container
*ngIf="spec.subtype === 'object' || spec.subtype === 'union'"
>
<!-- object/union label -->
<ion-item
button
(click)="toggleExpandListObject(entry.key, i)"
[class.error-border]="abstractControl.invalid"
>
<form-label
[data]="{
name:
objectListDisplay[entry.key][i].displayAs ||
'Entry ' + (i + 1),
new: false,
edited: abstractControl.dirty
}"
></form-label>
<ion-icon
slot="end"
name="chevron-up"
[color]="abstractControl.invalid ? 'danger' : undefined"
[ngStyle]="{
transform: objectListDisplay[entry.key][i].expanded
? 'rotate(0deg)'
: 'rotate(180deg)',
transition: 'transform 0.42s ease-out'
}"
></ion-icon>
</ion-item>
<!-- object/union body -->
<tui-expand
style="padding-left: 24px"
[expanded]="objectListDisplay[entry.key][i].expanded"
[id]="objectId | toElementId: entry.key:i"
>
<form-object
*ngIf="spec.subtype === 'object'"
[objectSpec]="$any(spec.spec).spec"
[formGroup]="abstractControl"
[current]="current?.[entry.key]?.[i]"
[original]="original?.[entry.key]?.[i]"
(onInputChange)="
updateLabel(entry.key, i, $any(spec.spec)['display-as'])
"
></form-object>
<form-union
*ngIf="spec.subtype === 'union'"
[spec]="$any(spec.spec)"
[formGroup]="abstractControl"
[current]="current?.[entry.key]?.[i]"
[original]="original?.[entry.key]?.[i]"
(onInputChange)="
updateLabel(entry.key, i, $any(spec.spec)['display-as'])
"
></form-union>
<div style="text-align: right; padding-top: 12px">
<ion-button
fill="clear"
(click)="presentAlertDelete(entry.key, i)"
color="danger"
>
<ion-icon slot="start" name="close"></ion-icon>
Delete
</ion-button>
</div>
</tui-expand>
</ng-container>
<!-- string or number -->
<div
*ngIf="spec.subtype === 'string' || spec.subtype === 'number'"
[id]="objectId | toElementId: entry.key:i"
>
<ion-item
[color]="(theme$ | async) === 'Light' ? 'light' : 'dark'"
>
<ion-input
type="text"
[inputmode]="spec.subtype === 'number' ? 'tel' : 'text'"
[placeholder]="
$any(spec.spec).placeholder || 'Enter ' + spec.name
"
[formControlName]="i"
></ion-input>
<ion-button
strong
fill="clear"
slot="end"
color="danger"
(click)="presentAlertDelete(entry.key, i)"
>
<ion-icon slot="icon-only" name="close"></ion-icon>
</ion-button>
</ion-item>
<p class="error-message">
<span
*ngIf="
(formGroup | getControl: entry.key:i).errors as errors
"
>
{{ errors | getError: $any(spec)['pattern-description'] }}
</span>
</p>
</div>
</div>
</div>
</ng-container>
</ng-container>
<!-- list (enum) -->
<ng-container *ngIf="spec.type === 'list' && spec.subtype === 'enum'">
<ng-container
*ngIf="formGroup.get(entry.key) as formArr"
[formArrayName]="entry.key"
>
<!-- label -->
<p class="input-label">
<form-label
[data]="{
name: spec.name,
description: spec.description,
new: original?.[entry.key] === undefined,
edited: entry.value.dirty
}"
></form-label>
</p>
<!-- list -->
<ion-item
button
detail="false"
color="dark"
(click)="presentModalEnumList(entry.key, $any(spec), formArr.value)"
>
<ion-label style="white-space: nowrap !important">
<h2>{{ formArr.value | toEnumListDisplay: $any(spec.spec) }}</h2>
</ion-label>
<ion-button slot="end" fill="clear" color="light">
<ion-icon slot="icon-only" name="chevron-down"></ion-icon>
</ion-button>
</ion-item>
<p class="error-message">
<span *ngIf="(formGroup | getControl: entry.key).errors as errors">
{{ errors | getError }}
</span>
</p>
</ng-container>
</ng-container>
</div>
</div>
</ion-item-group>

View File

@@ -0,0 +1,47 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import {
FormObjectComponent,
FormUnionComponent,
FormLabelComponent,
} from './form-object.component'
import {
GetErrorPipe,
ToWarningTextPipe,
ToElementIdPipe,
GetControlPipe,
ToEnumListDisplayPipe,
ToRangePipe,
} from './form-object.pipes'
import { IonicModule } from '@ionic/angular'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { SharedPipesModule } from '@start9labs/shared'
import { TuiElasticContainerModule } from '@taiga-ui/kit'
import { EnumListPageModule } from 'src/app/modals/enum-list/enum-list.module'
import { TuiExpandModule } from '@taiga-ui/core'
@NgModule({
declarations: [
FormObjectComponent,
FormUnionComponent,
FormLabelComponent,
ToWarningTextPipe,
GetErrorPipe,
ToEnumListDisplayPipe,
ToElementIdPipe,
GetControlPipe,
ToRangePipe,
],
imports: [
CommonModule,
IonicModule,
FormsModule,
ReactiveFormsModule,
SharedPipesModule,
EnumListPageModule,
TuiElasticContainerModule,
TuiExpandModule,
],
exports: [FormObjectComponent, FormLabelComponent],
})
export class FormObjectComponentModule {}

View File

@@ -0,0 +1,42 @@
.slot-start {
display: inline-block;
vertical-align: middle;
--padding-start: 0;
--padding-end: 7px;
}
.error-border {
border-color: var(--ion-color-danger-shade);
--border-color: var(--ion-color-danger-shade);
}
.redacted {
font-family: 'Redacted'
}
ion-input {
font-family: 'Courier New';
font-weight: bold;
--placeholder-font-weight: 400;
}
ion-item-divider {
text-transform: unset;
--padding-top: 18px;
--padding-start: 0;
border-bottom: 1px solid var(--ion-item-border-color, var(--ion-border-color, var(--ion-color-step-150, rgba(0, 0, 0, 0.13))))
}
.nested-wrapper {
padding: 0 0 16px 24px;
}
.error-message {
margin-top: 2px;
font-size: small;
color: var(--ion-color-danger);
}
.indent {
margin-left: 24px;
}

View File

@@ -0,0 +1,425 @@
import {
Component,
Input,
Output,
EventEmitter,
ChangeDetectionStrategy,
Inject,
inject,
SimpleChanges,
} from '@angular/core'
import { FormArray, UntypedFormArray, UntypedFormGroup } from '@angular/forms'
import { AlertButton, AlertController, ModalController } from '@ionic/angular'
import {
ConfigSpec,
ListValueSpecOf,
ValueSpec,
ValueSpecBoolean,
ValueSpecEnum,
ValueSpecList,
ValueSpecListOf,
ValueSpecUnion,
} from 'src/app/pkg-config/config-types'
import { FormService } from 'src/app/services/form.service'
import { EnumListPage } from 'src/app/modals/enum-list/enum-list.page'
import { THEME, pauseFor } from '@start9labs/shared'
import { v4 } from 'uuid'
import { DOCUMENT } from '@angular/common'
const Mustache = require('mustache')
interface Config {
[key: string]: any
}
@Component({
selector: 'form-object',
templateUrl: './form-object.component.html',
styleUrls: ['./form-object.component.scss'],
})
export class FormObjectComponent {
@Input() objectSpec!: ConfigSpec
@Input() formGroup!: UntypedFormGroup
@Input() current?: Config
@Input() original?: Config
@Output() onInputChange = new EventEmitter<void>()
@Output() hasNewOptions = new EventEmitter<void>()
warningAck: { [key: string]: boolean } = {}
unmasked: { [key: string]: boolean } = {}
objectDisplay: {
[key: string]: { expanded: boolean; hasNewOptions: boolean }
} = {}
objectListDisplay: {
[key: string]: { expanded: boolean; displayAs: string }[]
} = {}
objectId = v4()
readonly theme$ = inject(THEME)
constructor(
private readonly alertCtrl: AlertController,
private readonly modalCtrl: ModalController,
private readonly formService: FormService,
@Inject(DOCUMENT) private readonly document: Document,
) {}
ngOnInit() {
this.setDisplays()
// setTimeout hack to avoid ExpressionChangedAfterItHasBeenCheckedError
setTimeout(() => {
if (
this.original &&
Object.keys(this.current || {}).some(
key => this.original![key] === undefined,
)
)
this.hasNewOptions.emit()
})
}
ngOnChanges(changes: SimpleChanges) {
const specChanges = changes['objectSpec']
if (!specChanges) return
if (
!specChanges.firstChange &&
Object.keys({
...specChanges.previousValue,
...specChanges.currentValue,
}).length !== Object.keys(specChanges.previousValue).length
) {
this.setDisplays()
}
}
private setDisplays() {
Object.keys(this.objectSpec).forEach(key => {
const spec = this.objectSpec[key]
if (spec.type === 'list' && ['object', 'union'].includes(spec.subtype)) {
this.objectListDisplay[key] = []
this.formGroup.get(key)?.value.forEach((obj: any, index: number) => {
const displayAs = (spec.spec as ListValueSpecOf<'object'>)[
'display-as'
]
this.objectListDisplay[key][index] = {
expanded: false,
displayAs: displayAs
? (Mustache as any).render(displayAs, obj)
: '',
}
})
} else if (spec.type === 'object') {
this.objectDisplay[key] = {
expanded: false,
hasNewOptions: false,
}
}
})
}
addListItemWrapper<T extends ValueSpec>(
key: string,
spec: T extends ValueSpecUnion ? never : T,
) {
this.presentAlertChangeWarning(key, spec, () => this.addListItem(key))
}
toggleExpandObject(key: string) {
this.objectDisplay[key].expanded = !this.objectDisplay[key].expanded
}
toggleExpandListObject(key: string, i: number) {
this.objectListDisplay[key][i].expanded =
!this.objectListDisplay[key][i].expanded
}
updateLabel(key: string, i: number, displayAs: string) {
this.objectListDisplay[key][i].displayAs = displayAs
? Mustache.render(displayAs, this.formGroup.get(key)?.value[i])
: ''
}
handleInputChange() {
this.onInputChange.emit()
}
setHasNew(key: string) {
this.hasNewOptions.emit()
setTimeout(() => {
this.objectDisplay[key].hasNewOptions = true
}, 100)
}
handleBooleanChange(key: string, spec: ValueSpecBoolean) {
if (spec.warning) {
const current = this.formGroup.get(key)?.value
const cancelFn = () => this.formGroup.get(key)?.setValue(!current)
this.presentAlertChangeWarning(key, spec, undefined, cancelFn)
}
}
async presentModalEnumList(
key: string,
spec: ValueSpecListOf<'enum'>,
current: string[],
) {
const modal = await this.modalCtrl.create({
componentProps: {
key,
spec,
current,
},
component: EnumListPage,
})
modal.onWillDismiss<string[]>().then(({ data }) => {
if (!data) return
this.updateEnumList(key, current, data)
})
await modal.present()
}
async presentAlertChangeWarning<T extends ValueSpec>(
key: string,
spec: T extends ValueSpecUnion ? never : T,
okFn?: Function,
cancelFn?: Function,
) {
if (!spec.warning || this.warningAck[key]) return okFn ? okFn() : null
this.warningAck[key] = true
const buttons: AlertButton[] = [
{
text: 'Ok',
handler: () => {
if (okFn) okFn()
},
cssClass: 'enter-click',
},
]
if (okFn || cancelFn) {
buttons.unshift({
text: 'Cancel',
handler: () => {
if (cancelFn) cancelFn()
},
})
}
const alert = await this.alertCtrl.create({
header: 'Warning',
subHeader: `Editing ${spec.name} has consequences:`,
message: spec.warning,
buttons,
})
await alert.present()
}
async presentAlertDelete(key: string, index: number) {
const alert = await this.alertCtrl.create({
header: 'Confirm',
message: 'Are you sure you want to delete this entry?',
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: 'Delete',
handler: () => {
this.deleteListItem(key, index)
},
cssClass: 'enter-click',
},
],
})
await alert.present()
}
async presentAlertBoolEnumDescription(
event: Event,
spec: ValueSpecBoolean | ValueSpecEnum,
) {
event.stopPropagation()
const { name, description } = spec
const alert = await this.alertCtrl.create({
header: name,
message: description,
buttons: [
{
text: 'OK',
cssClass: 'enter-click',
},
],
})
await alert.present()
}
private addListItem(key: string): void {
const arr = this.formGroup.get(key) as UntypedFormArray
const listSpec = this.objectSpec[key] as ValueSpecList
const newItem = this.formService.getListItem(listSpec, undefined)!
const index = arr.length
arr.insert(index, newItem)
if (['object', 'union'].includes(listSpec.subtype)) {
const displayAs = (listSpec.spec as ListValueSpecOf<'object'>)[
'display-as'
]
this.objectListDisplay[key].push({
expanded: false,
displayAs: displayAs ? Mustache.render(displayAs, newItem.value) : '',
})
}
setTimeout(() => {
const element = this.document.getElementById(
getElementId(this.objectId, key, index),
)
element?.parentElement?.scrollIntoView({ behavior: 'smooth' })
if (['object', 'union'].includes(listSpec.subtype)) {
pauseFor(250).then(() => this.toggleExpandListObject(key, index))
}
}, 100)
arr.markAsDirty()
}
private deleteListItem(key: string, index: number, markDirty = true): void {
// if (this.objectListDisplay[key])
// this.objectListDisplay[key][index].height = '0px'
const arr = this.formGroup.get(key) as UntypedFormArray
if (markDirty) arr.markAsDirty()
pauseFor(250).then(() => {
if (this.objectListDisplay[key])
this.objectListDisplay[key].splice(index, 1)
arr.removeAt(index)
})
}
private updateEnumList(key: string, current: string[], updated: string[]) {
const arr = this.formGroup.get(key) as FormArray
for (let i = current.length - 1; i >= 0; i--) {
if (!updated.includes(current[i])) {
arr.removeAt(i)
}
}
const listSpec = this.objectSpec[key] as ValueSpecList
updated.forEach(val => {
if (!current.includes(val)) {
const newItem = this.formService.getListItem(listSpec, val)!
arr.insert(arr.length, newItem)
}
})
arr.markAsDirty()
}
asIsOrder() {
return 0
}
}
@Component({
selector: 'form-union',
templateUrl: './form-union.component.html',
styleUrls: ['./form-object.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FormUnionComponent {
@Input() formGroup!: UntypedFormGroup
@Input() spec!: ValueSpecUnion
@Input() current?: Config
@Input() original?: Config
get unionValue() {
return this.formGroup.get(this.spec.tag.id)?.value
}
get isNew() {
return !this.original
}
get hasNewOptions() {
const tagId = this.spec.tag.id
return (
this.original?.[tagId] === this.current?.[tagId] &&
!!Object.keys(this.current || {}).find(
key => this.original![key] === undefined,
)
)
}
objectId = v4()
constructor(private readonly formService: FormService) {}
updateUnion(e: any): void {
const tagId = this.spec.tag.id
Object.keys(this.formGroup.controls).forEach(control => {
if (control === tagId) return
this.formGroup.removeControl(control)
})
const unionGroup = this.formService.getUnionObject(
this.spec as ValueSpecUnion,
e.detail.value,
)
Object.keys(unionGroup.controls).forEach(control => {
if (control === tagId) return
this.formGroup.addControl(control, unionGroup.controls[control])
})
}
}
@Component({
selector: 'form-label',
templateUrl: './form-label.component.html',
styleUrls: ['./form-object.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FormLabelComponent {
@Input() data!: {
name: string
new: boolean
edited: boolean
description?: string
required?: boolean
newOptions?: boolean
}
constructor(private readonly alertCtrl: AlertController) {}
async presentAlertDescription(event: Event) {
event.stopPropagation()
const { name, description } = this.data
const alert = await this.alertCtrl.create({
header: name,
message: description,
buttons: [
{
text: 'OK',
cssClass: 'enter-click',
},
],
})
await alert.present()
}
}
export function getElementId(objectId: string, key: string, index = 0): string {
return `${key}-${index}-${objectId}`
}

View File

@@ -0,0 +1,92 @@
import { Pipe, PipeTransform } from '@angular/core'
import {
AbstractControl,
FormGroup,
UntypedFormArray,
ValidationErrors,
} from '@angular/forms'
import { IonicSafeString } from '@ionic/angular'
import { ListValueSpecOf } from 'src/app/pkg-config/config-types'
import { Range } from 'src/app/pkg-config/config-utilities'
import { getElementId } from './form-object.component'
@Pipe({
name: 'getError',
})
export class GetErrorPipe implements PipeTransform {
transform(errors: ValidationErrors, patternDesc?: string): string {
if (errors['required']) {
return 'Required'
} else if (errors['pattern']) {
return patternDesc || 'Invalid pattern'
} else if (errors['notNumber']) {
return 'Must be a number'
} else if (errors['numberNotInteger']) {
return 'Must be an integer'
} else if (errors['numberNotInRange']) {
return errors['numberNotInRange'].value
} else if (errors['listNotUnique']) {
return errors['listNotUnique'].value
} else if (errors['listNotInRange']) {
return errors['listNotInRange'].value
} else if (errors['listItemIssue']) {
return errors['listItemIssue'].value
} else {
return 'Unknown error'
}
}
}
@Pipe({
name: 'toEnumListDisplay',
})
export class ToEnumListDisplayPipe implements PipeTransform {
transform(arr: string[], spec: ListValueSpecOf<'enum'>): string {
return arr.map((v: string) => spec['value-names'][v]).join(', ')
}
}
@Pipe({
name: 'toWarningText',
})
export class ToWarningTextPipe implements PipeTransform {
transform(text?: string): IonicSafeString | string {
return text
? new IonicSafeString(`<ion-text color="warning">${text}</ion-text>`)
: ''
}
}
@Pipe({
name: 'toRange',
})
export class ToRangePipe implements PipeTransform {
transform(range: string): Range {
return Range.from(range)
}
}
@Pipe({
name: 'toElementId',
})
export class ToElementIdPipe implements PipeTransform {
transform(objectId: string, key: string, index = 0): string {
return getElementId(objectId, key, index)
}
}
@Pipe({
name: 'getControl',
})
export class GetControlPipe implements PipeTransform {
transform(
formGroup: FormGroup,
key: string,
index?: number,
): AbstractControl {
const abstractControl = formGroup.get(key)!
if (index !== undefined)
return (abstractControl as UntypedFormArray).at(index)
return abstractControl
}
}

View File

@@ -0,0 +1,42 @@
<div [formGroup]="formGroup">
<!-- union enum -->
<ion-item-divider [class.error-border]="formGroup.invalid">
<form-label
[data]="{
name: spec.tag.name,
description: spec.tag.description,
new: isNew,
newOptions: hasNewOptions,
edited: formGroup.dirty
}"
></form-label>
<!-- class enter-click disables the enter click on the modal behind the select -->
<ion-select
[interfaceOptions]="{
message: spec.tag.warning | toWarningText,
cssClass: 'enter-click'
}"
slot="end"
placeholder="Select"
[formControlName]="spec.tag.id"
[selectedText]="spec.tag['variant-names'][unionValue]"
(ionChange)="updateUnion($event)"
>
<ion-select-option
*ngFor="let option of spec.variants | keyvalue"
[value]="option.key"
>
{{ spec.tag['variant-names'][option.key] }}
</ion-select-option>
</ion-select>
</ion-item-divider>
<tui-elastic-container [id]="objectId | toElementId: 'union'" class="indent">
<form-object
[objectSpec]="spec.variants[unionValue]"
[formGroup]="formGroup"
[current]="current"
[original]="original"
></form-object>
</tui-elastic-container>
</div>

View File

@@ -0,0 +1,94 @@
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-back-button [defaultHref]="defaultBack"></ion-back-button>
</ion-buttons>
<ion-title>{{ pageTitle }}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content
[scrollEvents]="true"
(ionScroll)="handleScroll($event)"
(ionScrollEnd)="handleScrollEnd()"
class="ion-padding with-widgets"
>
<ion-infinite-scroll
id="scroller"
[disabled]="infiniteStatus !== 1"
position="top"
threshold="1000"
(ionInfinite)="doInfinite($event)"
>
<ion-infinite-scroll-content
loadingSpinner="lines"
></ion-infinite-scroll-content>
</ion-infinite-scroll>
<text-spinner *ngIf="loading" text="Loading Logs"></text-spinner>
<div id="container">
<div id="template"></div>
</div>
<ng-container *ngIf="!loading">
<div id="bottom-div"></div>
<p
*ngIf="websocketStatus === 'reconnecting'"
class="ion-text-center loading-dots"
>
<ion-text color="success">Reconnecting</ion-text>
</p>
<p
*ngIf="websocketStatus === 'disconnected'"
class="ion-text-center loading-dots"
>
<ion-text color="warning">Waiting for network connectivity</ion-text>
</p>
<div
[ngStyle]="{
position: 'fixed',
bottom: '96px',
right: isOnBottom ? '-52px' : '30px',
'background-color': 'var(--ion-color-medium)',
'border-radius': '100%',
transition: 'right 0.25s ease-out'
}"
>
<ion-button
style="
width: 50px;
height: 50px;
--padding-start: 0px;
--padding-end: 0px;
--border-radius: 100%;
"
color="dark"
(click)="scrollToBottom(); autoScroll = true"
strong
>
<ion-icon name="chevron-down"></ion-icon>
</ion-button>
</div>
</ng-container>
</ion-content>
<ion-footer class="with-widgets">
<ion-toolbar>
<div class="inline ion-padding-start">
<ion-checkbox [(ngModel)]="autoScroll" color="dark"></ion-checkbox>
<p class="ion-padding-start">Autoscroll</p>
</div>
<ion-button
*ngIf="!loading"
slot="end"
class="ion-padding-end"
fill="clear"
strong
(click)="download()"
>
Download
<ion-icon slot="end" name="download-outline"></ion-icon>
</ion-button>
</ion-toolbar>
</ion-footer>

View File

@@ -0,0 +1,13 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { LogsComponent } from './logs.component'
import { FormsModule } from '@angular/forms'
import { TextSpinnerComponentModule } from '@start9labs/shared'
@NgModule({
declarations: [LogsComponent],
imports: [CommonModule, IonicModule, TextSpinnerComponentModule, FormsModule],
exports: [LogsComponent],
})
export class LogsComponentModule {}

View File

@@ -0,0 +1,5 @@
#container {
padding-bottom: 16px;
font-family: monospace;
white-space: pre-line;
}

View File

@@ -0,0 +1,259 @@
import { Component, Input, ViewChild } from '@angular/core'
import { IonContent, LoadingController } from '@ionic/angular'
import {
bufferTime,
catchError,
filter,
finalize,
from,
Observable,
switchMap,
takeUntil,
tap,
} from 'rxjs'
import { WebSocketSubjectConfig } from 'rxjs/webSocket'
import {
LogsRes,
ServerLogsReq,
ErrorToastService,
toLocalIsoString,
Log,
DownloadHTMLService,
} from '@start9labs/shared'
import { TuiDestroyService } from '@taiga-ui/cdk'
import { RR } from 'src/app/services/api/api.types'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConnectionService } from 'src/app/services/connection.service'
var Convert = require('ansi-to-html')
var convert = new Convert({
newline: true,
bg: 'transparent',
colors: {
4: 'Cyan',
},
escapeXML: true,
})
@Component({
selector: 'logs',
templateUrl: './logs.component.html',
styleUrls: ['./logs.component.scss'],
providers: [TuiDestroyService, DownloadHTMLService],
})
export class LogsComponent {
@ViewChild(IonContent)
private content?: IonContent
@Input() followLogs!: (
params: RR.FollowServerLogsReq,
) => Promise<RR.FollowServerLogsRes>
@Input() fetchLogs!: (params: ServerLogsReq) => Promise<LogsRes>
@Input() context!: string
@Input() defaultBack!: string
@Input() pageTitle!: string
loading = true
infiniteStatus: 0 | 1 | 2 = 0
startCursor?: string
isOnBottom = true
autoScroll = true
websocketStatus:
| 'connecting'
| 'connected'
| 'reconnecting'
| 'disconnected' = 'connecting'
limit = 400
count = 0
constructor(
private readonly errToast: ErrorToastService,
private readonly destroy$: TuiDestroyService,
private readonly api: ApiService,
private readonly loadingCtrl: LoadingController,
private readonly downloadHtml: DownloadHTMLService,
private readonly connectionService: ConnectionService,
) {}
async ngOnInit() {
from(this.followLogs({ limit: this.limit }))
.pipe(
switchMap(({ 'start-cursor': startCursor, guid }) => {
this.startCursor = startCursor
return this.connect$(guid)
}),
takeUntil(this.destroy$),
finalize(() => console.log('CLOSING')),
)
.subscribe()
}
async doInfinite(e: any): Promise<void> {
try {
const res = await this.fetchLogs({
cursor: this.startCursor,
before: true,
limit: this.limit,
})
this.processRes(res)
} catch (e: any) {
this.errToast.present(e)
} finally {
e.target.complete()
}
}
handleScroll(e: any) {
if (e.detail.deltaY < -50) this.autoScroll = false
}
handleScrollEnd() {
const bottomDiv = document.getElementById('bottom-div')
this.isOnBottom =
!!bottomDiv &&
bottomDiv.getBoundingClientRect().top - 420 < window.innerHeight
}
scrollToBottom() {
this.content?.scrollToBottom(200)
}
async download() {
const loader = await this.loadingCtrl.create({
message: 'Processing 10,000 logs...',
})
await loader.present()
try {
const { entries } = await this.fetchLogs({
before: true,
limit: 10000,
})
const styles = {
'background-color': '#222428',
color: '#e0e0e0',
'font-family': 'monospace',
}
const html = this.convertToAnsi(entries)
this.downloadHtml.download(`${this.context}-logs.html`, html, styles)
} catch (e: any) {
this.errToast.present(e)
} finally {
loader.dismiss()
}
}
private reconnect$(): Observable<Log[]> {
return from(this.followLogs({})).pipe(
tap(_ => this.recordConnectionChange()),
switchMap(({ guid }) => this.connect$(guid, true)),
)
}
private connect$(guid: string, reconnect = false) {
const config: WebSocketSubjectConfig<Log> = {
url: `/rpc/${guid}`,
openObserver: {
next: () => {
this.websocketStatus = 'connected'
},
},
}
return this.api.openLogsWebsocket$(config).pipe(
tap(_ => this.count++),
bufferTime(1000),
tap(msgs => {
this.loading = false
this.processRes({ entries: msgs })
if (this.infiniteStatus === 0 && this.count >= this.limit)
this.infiniteStatus = 1
}),
catchError(() => {
this.recordConnectionChange(false)
return this.connectionService.connected$.pipe(
tap(
connected =>
(this.websocketStatus = connected
? 'reconnecting'
: 'disconnected'),
),
filter(Boolean),
switchMap(() => this.reconnect$()),
)
}),
)
}
private recordConnectionChange(success = true) {
const container = document.getElementById('container')
const elem = document.getElementById('template')?.cloneNode()
if (!(elem instanceof HTMLElement)) return
elem.innerHTML = `<div style="padding: ${
success ? '36px 0' : '36px 0 0 0'
}; color: ${success ? '#2fdf75' : '#ff4961'}; text-align: center;">${
success ? 'Reconnected' : 'Disconnected'
} at ${toLocalIsoString(new Date())}</div>`
container?.append(elem)
if (this.isOnBottom) {
setTimeout(() => {
this.scrollToBottom()
}, 25)
}
}
private processRes(res: LogsRes) {
const { entries, 'start-cursor': startCursor } = res
if (!entries.length) return
const container = document.getElementById('container')
const newLogs = document.getElementById('template')?.cloneNode()
if (!(newLogs instanceof HTMLElement)) return
newLogs.innerHTML = this.convertToAnsi(entries)
// if response contains a startCursor, it means we are scrolling backwards
if (startCursor) {
this.startCursor = startCursor
const beforeContainerHeight = container?.scrollHeight || 0
container?.prepend(newLogs)
const afterContainerHeight = container?.scrollHeight || 0
// maintain scroll height
setTimeout(() => {
this.content?.scrollToPoint(
0,
afterContainerHeight - beforeContainerHeight,
)
}, 25)
if (entries.length < this.limit) {
this.infiniteStatus = 2
}
} else {
container?.append(newLogs)
if (this.autoScroll) {
setTimeout(() => {
this.scrollToBottom()
}, 25)
}
}
}
private convertToAnsi(entries: Log[]) {
return entries
.map(
entry =>
`<span style="color: #FFF; font-weight: bold;">${toLocalIsoString(
new Date(entry.timestamp),
)}</span>&nbsp;&nbsp;${convert.toHtml(entry.message)}`,
)
.join('<br />')
}
}

View File

@@ -0,0 +1 @@
<qr-code [value]="text" size="400"></qr-code>

View File

@@ -0,0 +1,12 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { QRComponent } from './qr.component'
import { IonicModule } from '@ionic/angular'
import { QrCodeModule } from 'ng-qrcode'
@NgModule({
declarations: [QRComponent],
imports: [CommonModule, IonicModule, QrCodeModule],
exports: [QRComponent],
})
export class QRComponentModule {}

View File

@@ -0,0 +1,10 @@
import { Component, Input } from '@angular/core'
@Component({
selector: 'qr',
templateUrl: './qr.component.html',
styleUrls: ['./qr.component.scss'],
})
export class QRComponent {
@Input() text!: string
}

View File

@@ -0,0 +1,51 @@
<ng-container *ngIf="groups">
<ion-item-group>
<ng-container *ngFor="let g of groupsArr">
<ion-item-divider>
<ion-skeleton-text
animated
style="width: 120px; height: 16px"
></ion-skeleton-text>
</ion-item-divider>
<ion-item *ngFor="let r of rowsArr">
<ion-avatar *ngIf="showAvatar" slot="start">
<ion-skeleton-text [animated]="true"></ion-skeleton-text>
</ion-avatar>
<ion-label>
<ion-skeleton-text
animated
style="width: 200px; height: 14px"
></ion-skeleton-text>
</ion-label>
<ion-note slot="end">
<ion-skeleton-text
animated
style="width: 80px; height: 14px"
></ion-skeleton-text>
</ion-note>
</ion-item>
</ng-container>
</ion-item-group>
</ng-container>
<ng-container *ngIf="!groups">
<ion-item-group>
<ion-item *ngFor="let r of rowsArr">
<ion-avatar *ngIf="showAvatar" slot="start">
<ion-skeleton-text [animated]="true"></ion-skeleton-text>
</ion-avatar>
<ion-label>
<ion-skeleton-text
animated
style="width: 200px; height: 14px"
></ion-skeleton-text>
</ion-label>
<ion-note slot="end">
<ion-skeleton-text
animated
style="width: 80px; height: 14px"
></ion-skeleton-text>
</ion-note>
</ion-item>
</ion-item-group>
</ng-container>

View File

@@ -0,0 +1,12 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { SkeletonListComponent } from './skeleton-list.component'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
@NgModule({
declarations: [SkeletonListComponent],
imports: [CommonModule, IonicModule, RouterModule.forChild([])],
exports: [SkeletonListComponent],
})
export class SkeletonListComponentModule {}

View File

@@ -0,0 +1,19 @@
import { Component, Input } from '@angular/core'
@Component({
selector: 'skeleton-list',
templateUrl: './skeleton-list.component.html',
styleUrls: ['./skeleton-list.component.scss'],
})
export class SkeletonListComponent {
@Input() groups = 0
@Input() rows = 3
@Input() showAvatar = false
groupsArr: number[] = []
rowsArr: number[] = []
ngOnInit() {
this.groupsArr = Array(this.groups).fill(0)
this.rowsArr = Array(this.rows).fill(0)
}
}

View File

@@ -0,0 +1,30 @@
<p
[style.color]="
(connected$ | async) ? 'var(--ion-color-' + rendering.color + ')' : 'gray'
"
[style.font-size]="size"
[style.font-style]="style"
[style.font-weight]="weight"
>
{{ (connected$ | async) ? rendering.display : 'Unknown' }}
<span
*ngIf="
rendering.display === PR[PS.Stopping].display &&
(sigtermTimeout | durationToSeconds) > 30
"
>
this may take a while
</span>
<span *ngIf="installProgress">
<ion-text
*ngIf="installProgress | installProgressDisplay as progress"
color="primary"
>
{{ progress }}
</ion-text>
</span>
<span *ngIf="rendering.showDots" class="loading-dots"></span>
</p>

View File

@@ -0,0 +1,18 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { UnitConversionPipesModule } from '@start9labs/shared'
import { StatusComponent } from './status.component'
import { InstallProgressPipeModule } from 'src/app/pipes/install-progress/install-progress.module'
@NgModule({
declarations: [StatusComponent],
imports: [
CommonModule,
IonicModule,
UnitConversionPipesModule,
InstallProgressPipeModule,
],
exports: [StatusComponent],
})
export class StatusComponentModule {}

View File

@@ -0,0 +1,29 @@
import { Component, Input } from '@angular/core'
import { ConnectionService } from 'src/app/services/connection.service'
import { InstallProgress } from 'src/app/services/patch-db/data-model'
import {
PrimaryRendering,
PrimaryStatus,
StatusRendering,
} from 'src/app/services/pkg-status-rendering.service'
@Component({
selector: 'status',
templateUrl: './status.component.html',
styleUrls: ['./status.component.scss'],
})
export class StatusComponent {
PS = PrimaryStatus
PR = PrimaryRendering
@Input() rendering!: StatusRendering
@Input() size?: string
@Input() style?: string = 'regular'
@Input() weight?: string = 'normal'
@Input() installProgress?: InstallProgress
@Input() sigtermTimeout?: string | null = null
readonly connected$ = this.connectionService.connected$
constructor(private readonly connectionService: ConnectionService) {}
}

View File

@@ -0,0 +1,9 @@
<img
*ngIf="url | getIcon as icon; else noIcon"
[style.max-width]="size || '100%'"
[src]="icon"
alt=""
/>
<ng-template #noIcon>
<ion-icon name="storefront-outline" [style.font-size]="size"></ion-icon>
</ng-template>

View File

@@ -0,0 +1,11 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { GetIconPipe, StoreIconComponent } from './store-icon.component'
@NgModule({
declarations: [StoreIconComponent, GetIconPipe],
imports: [CommonModule, IonicModule],
exports: [StoreIconComponent],
})
export class StoreIconComponentModule {}

View File

@@ -0,0 +1,40 @@
import {
ChangeDetectionStrategy,
Component,
Input,
Pipe,
PipeTransform,
} from '@angular/core'
import { ConfigService } from 'src/app/services/config.service'
import { sameUrl } from '@start9labs/shared'
@Component({
selector: 'store-icon',
templateUrl: './store-icon.component.html',
styleUrls: ['./store-icon.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class StoreIconComponent {
@Input()
url: string = ''
@Input()
size?: string
}
@Pipe({
name: 'getIcon',
})
export class GetIconPipe implements PipeTransform {
constructor(private readonly config: ConfigService) {}
transform(url: string): string | null {
const { start9, community } = this.config.marketplace
if (sameUrl(url, start9)) {
return 'assets/img/icon.png'
} else if (sameUrl(url, community)) {
return 'assets/img/community-store.png'
}
return null
}
}

View File

@@ -0,0 +1,17 @@
<toast
*ngIf="visible$ | async as message"
header="StartOS"
[duration]="4000"
(dismiss)="onDismiss()"
>
New notifications
<button toastButton icon="close" side="start" (click)="onDismiss()"></button>
<a
toastButton
side="end"
routerLink="/notifications"
[queryParams]="{ toast: true }"
>
View
</a>
</toast>

View File

@@ -0,0 +1,27 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import { Observable, Subject, merge } from 'rxjs'
import { NotificationsToastService } from './notifications-toast.service'
@Component({
selector: 'notifications-toast',
templateUrl: './notifications-toast.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NotificationsToastComponent {
private readonly dismiss$ = new Subject<boolean>()
readonly visible$: Observable<boolean> = merge(
this.dismiss$,
this.notifications$,
)
constructor(
@Inject(NotificationsToastService)
private readonly notifications$: Observable<boolean>,
) {}
onDismiss() {
this.dismiss$.next(false)
}
}

View File

@@ -0,0 +1,20 @@
import { Injectable } from '@angular/core'
import { endWith, Observable } from 'rxjs'
import { map, pairwise } from 'rxjs/operators'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
@Injectable({ providedIn: 'root' })
export class NotificationsToastService extends Observable<boolean> {
private readonly stream$ = this.patch
.watch$('server-info', 'unread-notification-count')
.pipe(
pairwise(),
map(([prev, cur]) => cur > prev),
endWith(false),
)
constructor(private readonly patch: PatchDB<DataModel>) {
super(subscriber => this.stream$.subscribe(subscriber))
}
}

View File

@@ -0,0 +1,28 @@
<alert *ngIf="show$ | async" header="Refresh Needed" (dismiss)="onDismiss()">
<ng-container *ngIf="!onPwa; else pwa">
Your user interface is cached and out of date. Hard refresh the page to get
the latest UI.
<ul>
<li>
<b>On Mac</b>
: cmd + shift + R
</li>
<li>
<b>On Linux/Windows</b>
: ctrl + shift + R
</li>
<li>
<b>On Android/iOS</b>
: Browser specific, typically a refresh button in the browser menu.
</li>
</ul>
</ng-container>
<ng-template #pwa>
Your user interface is cached and out of date. Attempt to reload the PWA
using the button below. If you continue to see this message, uninstall and
reinstall the PWA.
</ng-template>
<!-- alertButton needs to be a direct child of alert element for ionic styling -->
<a *ngIf="!onPwa" alertButton class="enter-click" role="cancel">Ok</a>
<a *ngIf="onPwa" alertButton (click)="pwaReload()" role="cancel">Reload</a>
</alert>

View File

@@ -0,0 +1,48 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import { merge, Observable, Subject } from 'rxjs'
import { RefreshAlertService } from './refresh-alert.service'
import { SwUpdate } from '@angular/service-worker'
import { LoadingController } from '@ionic/angular'
@Component({
selector: 'refresh-alert',
templateUrl: './refresh-alert.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class RefreshAlertComponent {
private readonly dismiss$ = new Subject<boolean>()
readonly show$ = merge(this.dismiss$, this.refresh$)
onPwa = false
constructor(
@Inject(RefreshAlertService) private readonly refresh$: Observable<boolean>,
private readonly updates: SwUpdate,
private readonly loadingCtrl: LoadingController,
) {}
ngOnInit() {
this.onPwa = window.matchMedia('(display-mode: standalone)').matches
}
async pwaReload() {
const loader = await this.loadingCtrl.create({
message: 'Reloading PWA...',
})
await loader.present()
try {
// attempt to update to the latest client version available
await this.updates.activateUpdate()
} catch (e) {
console.error('Error activating update from service worker: ', e)
} finally {
loader.dismiss()
// always reload, as this resolves most out of sync cases
window.location.reload()
}
}
onDismiss() {
this.dismiss$.next(false)
}
}

View File

@@ -0,0 +1,23 @@
import { Injectable } from '@angular/core'
import { endWith, Observable } from 'rxjs'
import { map } from 'rxjs/operators'
import { Emver } from '@start9labs/shared'
import { PatchDB } from 'patch-db-client'
import { ConfigService } from '../../../services/config.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
@Injectable({ providedIn: 'root' })
export class RefreshAlertService extends Observable<boolean> {
private readonly stream$ = this.patch.watch$('server-info', 'version').pipe(
map(version => !!this.emver.compare(this.config.version, version)),
endWith(false),
)
constructor(
private readonly patch: PatchDB<DataModel>,
private readonly emver: Emver,
private readonly config: ConfigService,
) {
super(subscriber => this.stream$.subscribe(subscriber))
}
}

View File

@@ -0,0 +1,3 @@
<notifications-toast></notifications-toast>
<refresh-alert></refresh-alert>
<update-toast></update-toast>

View File

@@ -0,0 +1,8 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
@Component({
selector: 'toast-container',
templateUrl: './toast-container.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ToastContainerComponent {}

View File

@@ -0,0 +1,21 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { RouterModule } from '@angular/router'
import { AlertModule, ToastModule } from '@start9labs/shared'
import { ToastContainerComponent } from './toast-container.component'
import { NotificationsToastComponent } from './notifications-toast/notifications-toast.component'
import { RefreshAlertComponent } from './refresh-alert/refresh-alert.component'
import { UpdateToastComponent } from './update-toast/update-toast.component'
@NgModule({
imports: [CommonModule, ToastModule, AlertModule, RouterModule],
declarations: [
ToastContainerComponent,
NotificationsToastComponent,
RefreshAlertComponent,
UpdateToastComponent,
],
exports: [ToastContainerComponent],
})
export class ToastContainerModule {}

View File

@@ -0,0 +1,11 @@
<toast
*ngIf="visible$ | async as message"
class="success-toast"
header="StartOS download complete!"
(dismiss)="onDismiss()"
>
Restart your server for these updates to take effect. It can take several
minutes to come back online.
<button toastButton icon="close" side="start" (click)="onDismiss()"></button>
<button toastButton side="end" (click)="restart()">Restart</button>
</toast>

View File

@@ -0,0 +1,47 @@
import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'
import { LoadingController } from '@ionic/angular'
import { ErrorToastService } from '@start9labs/shared'
import { Observable, Subject, merge } from 'rxjs'
import { UpdateToastService } from './update-toast.service'
import { ApiService } from '../../../services/api/embassy-api.service'
@Component({
selector: 'update-toast',
templateUrl: './update-toast.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UpdateToastComponent {
private readonly dismiss$ = new Subject<boolean>()
readonly visible$: Observable<boolean> = merge(this.dismiss$, this.update$)
constructor(
@Inject(UpdateToastService) private readonly update$: Observable<boolean>,
private readonly embassyApi: ApiService,
private readonly errToast: ErrorToastService,
private readonly loadingCtrl: LoadingController,
) {}
onDismiss() {
this.dismiss$.next(false)
}
async restart(): Promise<void> {
this.onDismiss()
const loader = await this.loadingCtrl.create({
message: 'Restarting...',
})
await loader.present()
try {
await this.embassyApi.restartServer({})
} catch (e: any) {
await this.errToast.present(e)
} finally {
await loader.dismiss()
}
}
}

View File

@@ -0,0 +1,16 @@
import { Injectable } from '@angular/core'
import { endWith, Observable } from 'rxjs'
import { distinctUntilChanged, filter } from 'rxjs/operators'
import { PatchDB } from 'patch-db-client'
import { DataModel } from 'src/app/services/patch-db/data-model'
@Injectable({ providedIn: 'root' })
export class UpdateToastService extends Observable<boolean> {
private readonly stream$ = this.patch
.watch$('server-info', 'status-info', 'updated')
.pipe(distinctUntilChanged(), filter(Boolean), endWith(false))
constructor(private readonly patch: PatchDB<DataModel>) {
super(subscriber => this.stream$.subscribe(subscriber))
}
}

View File

@@ -0,0 +1,28 @@
<div
class="outer-wrapper"
#outerWrapper
[ngStyle]="{ height: outerHeight, width: outerWidth }"
>
<div
class="inner-wrapper"
#innerWrapper
[ngStyle]="{ transform: innerTransform }"
>
<ion-card>
<any-link [link]="cardDetails.link" [qp]="cardDetails.qp">
<ion-card-header>
<ion-card-title>{{ cardDetails.title }}</ion-card-title>
</ion-card-header>
<ion-card-content>
<ion-icon
[name]="cardDetails.icon"
[style.color]="cardDetails.color"
></ion-icon>
</ion-card-content>
<ion-footer>
<p>{{ cardDetails.description }}</p>
</ion-footer>
</any-link>
</ion-card>
</div>
</div>

View File

@@ -0,0 +1,18 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { WidgetCardComponent } from './widget-card.component'
import { AnyLinkModule } from 'src/app/components/any-link/any-link.component.module'
@NgModule({
declarations: [WidgetCardComponent],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
AnyLinkModule,
],
exports: [WidgetCardComponent],
})
export class WidgetCardComponentModule {}

View File

@@ -0,0 +1,68 @@
ion-card {
background: rgba(70, 70, 70, 0.31);
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
border-radius: 44px;
margin: auto;
max-height: 100%;
max-width: 100%;
text-align: center;
transition: all 350ms ease;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
transition-property: transform;
transform: scale(1.05);
transition-delay: 40ms;
}
ion-card-title {
font-family: 'Open Sans';
padding: 0.6rem;
font-weight: 600;
height: 2.4rem;
}
ion-card-content {
min-height: 8rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
ion-icon {
font-size: calc(90px + 0.4vw);
--ionicon-stroke-width: 1rem;
}
}
ion-footer {
padding: 0 1rem;
font-family: 'Open Sans';
font-size: clamp(1rem, calc(12px + 0.5vw), 1.3rem);
height: 4.5rem;
width: clamp(13rem, 80%, 18rem);
margin: 0 auto;
* {
max-width: 100%;
}
p {
margin-top: 0;
}
}
.footer-md::before {
background-image: none;
}
}
@media (max-width: 900px) {
ion-footer {
width: 10rem;
}
}
@media (max-width: 1200px) {
ion-footer {
width: 14rem;
}
}

View File

@@ -0,0 +1,66 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
HostListener,
Input,
ViewChild,
} from '@angular/core'
@Component({
selector: 'widget-card',
templateUrl: './widget-card.component.html',
styleUrls: ['./widget-card.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WidgetCardComponent {
@Input() cardDetails!: Card
@Input() containerDimensions!: Dimension
@ViewChild('outerWrapper') outerWrapper: ElementRef<HTMLElement> =
{} as ElementRef<HTMLElement>
@ViewChild('innerWrapper') innerWrapper: ElementRef<HTMLElement> =
{} as ElementRef<HTMLElement>
@HostListener('window:resize', ['$event'])
onResize() {
this.resize()
}
maxHeight = 0
maxWidth = 0
innerTransform = ''
outerWidth: any
outerHeight: any
ngAfterViewInit() {
this.maxHeight = (<HTMLElement> (
this.innerWrapper.nativeElement
)).getBoundingClientRect().height
this.maxWidth = (<HTMLElement> (
this.innerWrapper.nativeElement
)).getBoundingClientRect().width
this.resize()
}
resize() {
const height = this.containerDimensions.height
const width = this.containerDimensions.width
const isMax = width >= this.maxWidth && height >= this.maxHeight
const scale = Math.min(width / this.maxWidth, height / this.maxHeight)
this.innerTransform = isMax ? '' : 'scale(' + scale + ')'
this.outerWidth = isMax ? '' : this.maxWidth * scale
this.outerHeight = isMax ? '' : this.maxHeight * scale
}
}
export interface Dimension {
height: number
width: number
}
export interface Card {
title: string
icon: string
color: string
description: string
link: string
qp?: Record<string, string>
}

View File

@@ -0,0 +1,12 @@
<div #gridContent>
<ion-grid>
<ion-row class="ion-justify-content-center ion-align-items-center">
<ion-col *ngFor="let card of cards" sizeXs="12">
<widget-card
[cardDetails]="card"
[containerDimensions]="containerDimensions"
></widget-card>
</ion-col>
</ion-row>
</ion-grid>
</div>

View File

@@ -0,0 +1,22 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { RouterModule } from '@angular/router'
import { ResponsiveColModule } from '@start9labs/shared'
import { WidgetListComponent } from './widget-list.component'
import { AnyLinkModule } from 'src/app/components/any-link/any-link.component.module'
import { WidgetCardComponentModule } from '../widget-card/widget-card.component.module'
@NgModule({
declarations: [WidgetListComponent],
imports: [
CommonModule,
IonicModule,
RouterModule.forChild([]),
AnyLinkModule,
WidgetCardComponentModule,
ResponsiveColModule,
],
exports: [WidgetListComponent],
})
export class WidgetListComponentModule {}

View File

@@ -0,0 +1,19 @@
ion-col {
max-width: 22rem !important;
--ion-grid-column-padding: 1rem;
}
@media (min-width: 1700px) {
div {
padding: 0 7%;
}
ion-col {
max-width: 24rem !important;
}
}
@media (min-width: 2000px) {
div {
padding: 0 12%;
}
}

View File

@@ -0,0 +1,84 @@
import {
ChangeDetectionStrategy,
Component,
ElementRef,
HostListener,
ViewChild,
} from '@angular/core'
import { Card, Dimension } from '../widget-card/widget-card.component'
@Component({
selector: 'widget-list',
templateUrl: './widget-list.component.html',
styleUrls: ['./widget-list.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class WidgetListComponent {
@ViewChild('gridContent')
gridContent: ElementRef<HTMLElement> = {} as ElementRef<HTMLElement>
@HostListener('window:resize', ['$event'])
onResize() {
this.setContainerDimensions()
}
containerDimensions: Dimension = {} as Dimension
ngAfterViewInit() {
this.setContainerDimensions()
}
setContainerDimensions() {
this.containerDimensions.height = (<HTMLElement> (
this.gridContent.nativeElement
)).getBoundingClientRect().height
this.containerDimensions.width = (<HTMLElement> (
this.gridContent.nativeElement
)).getBoundingClientRect().width
}
cards: Card[] = [
{
title: 'Server Info',
icon: 'information-circle-outline',
color: 'var(--alt-green)',
description: 'View information about your server',
link: '/system/specs',
},
{
title: 'Browse',
icon: 'storefront-outline',
color: 'var(--alt-purple)',
description: 'Browse for services to install',
link: '/marketplace',
qp: { back: 'true' },
},
{
title: 'Create Backup',
icon: 'duplicate-outline',
color: 'var(--alt-blue)',
description: 'Back up StartOS and service data',
link: '/system/backup',
},
{
title: 'Monitor',
icon: 'pulse-outline',
color: 'var(--alt-orange)',
description: `View your system resource usage`,
link: '/system/metrics',
},
{
title: 'User Manual',
icon: 'map-outline',
color: 'var(--alt-yellow)',
description: 'Discover what StartOS can do',
link: 'https://docs.start9.com/0.3.5.x/user-manual/index',
},
{
title: 'Contact Support',
icon: 'chatbubbles-outline',
color: 'var(--alt-red)',
description: 'Get help from the Start9 community',
link: 'https://start9.com/contact',
},
]
}

View File

@@ -0,0 +1,29 @@
import { Injectable } from '@angular/core'
import { CanActivate, Router, CanActivateChild, UrlTree } from '@angular/router'
import { map } from 'rxjs/operators'
import { AuthService } from '../services/auth.service'
import { Observable } from 'rxjs'
@Injectable({
providedIn: 'root',
})
export class AuthGuard implements CanActivate, CanActivateChild {
constructor(
private readonly authService: AuthService,
private readonly router: Router,
) {}
canActivate(): Observable<boolean | UrlTree> {
return this.runAuthCheck()
}
canActivateChild(): Observable<boolean | UrlTree> {
return this.runAuthCheck()
}
private runAuthCheck(): Observable<boolean | UrlTree> {
return this.authService.isVerified$.pipe(
map(verified => verified || this.router.parseUrl('/login')),
)
}
}

View File

@@ -0,0 +1,21 @@
import { Injectable } from '@angular/core'
import { CanActivate, Router, UrlTree } from '@angular/router'
import { map } from 'rxjs/operators'
import { AuthService } from '../services/auth.service'
import { Observable } from 'rxjs'
@Injectable({
providedIn: 'root',
})
export class UnauthGuard implements CanActivate {
constructor(
private readonly authService: AuthService,
private readonly router: Router,
) {}
canActivate(): Observable<boolean | UrlTree> {
return this.authService.isVerified$.pipe(
map(verified => !verified || this.router.parseUrl('')),
)
}
}

View File

@@ -0,0 +1,13 @@
import { NgModule } from '@angular/core'
import { AbstractMarketplaceService } from '@start9labs/marketplace'
import { MarketplaceService } from 'src/app/services/marketplace.service'
@NgModule({
providers: [
{
provide: AbstractMarketplaceService,
useClass: MarketplaceService,
},
],
})
export class MarketplaceModule {}

View File

@@ -0,0 +1,12 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { ActionSuccessPage } from './action-success.page'
import { QrCodeModule } from 'ng-qrcode'
@NgModule({
declarations: [ActionSuccessPage],
imports: [CommonModule, IonicModule, QrCodeModule],
exports: [ActionSuccessPage],
})
export class ActionSuccessPageModule {}

View File

@@ -0,0 +1,35 @@
<ion-header>
<ion-toolbar>
<ion-title>Execution Complete</ion-title>
<ion-buttons slot="end">
<ion-button (click)="dismiss()" class="enter-click">
<ion-icon name="close"></ion-icon>
</ion-button>
</ion-buttons>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<h2 class="ion-padding">{{ actionRes.message }}</h2>
<div *ngIf="actionRes.value" class="ion-text-center" style="padding: 48px 0">
<div *ngIf="actionRes.qr" class="ion-padding-bottom">
<qr-code [value]="actionRes.value" size="240"></qr-code>
</div>
<p *ngIf="!actionRes.copyable">{{ actionRes.value }}</p>
<a
*ngIf="actionRes.copyable"
style="cursor: copy"
(click)="copy(actionRes.value)"
>
<b>{{ actionRes.value }}</b>
<sup>
<ion-icon
name="copy-outline"
style="padding-left: 6px; font-size: small"
></ion-icon>
</sup>
</a>
</div>
</ion-content>

View File

@@ -0,0 +1,39 @@
import { Component, Input } from '@angular/core'
import { ModalController, ToastController } from '@ionic/angular'
import { ActionResponse } from 'src/app/services/api/api.types'
import { copyToClipboard } from '@start9labs/shared'
@Component({
selector: 'action-success',
templateUrl: './action-success.page.html',
styleUrls: ['./action-success.page.scss'],
})
export class ActionSuccessPage {
@Input()
actionRes!: ActionResponse
constructor(
private readonly modalCtrl: ModalController,
private readonly toastCtrl: ToastController,
) {}
async copy(address: string) {
let message = ''
await copyToClipboard(address || '').then(success => {
message = success
? 'Copied to clipboard!'
: 'Failed to copy to clipboard.'
})
const toast = await this.toastCtrl.create({
header: message,
position: 'bottom',
duration: 1000,
})
await toast.present()
}
async dismiss() {
return this.modalCtrl.dismiss()
}
}

View File

@@ -0,0 +1,21 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { IonicModule } from '@ionic/angular'
import { AppConfigPage } from './app-config.page'
import { TextSpinnerComponentModule } from '@start9labs/shared'
import { FormObjectComponentModule } from 'src/app/components/form-object/form-object.component.module'
@NgModule({
declarations: [AppConfigPage],
imports: [
CommonModule,
FormsModule,
IonicModule,
TextSpinnerComponentModule,
FormObjectComponentModule,
ReactiveFormsModule,
],
exports: [AppConfigPage],
})
export class AppConfigPageModule {}

View File

@@ -0,0 +1,149 @@
<ion-header>
<ion-toolbar>
<ion-title>Config</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">
<!-- loading -->
<text-spinner
*ngIf="loading; else notLoading"
[text]="loadingText"
></text-spinner>
<!-- not loading -->
<ng-template #notLoading>
<ion-item *ngIf="loadingError; else noError">
<ion-label>
<ion-text color="danger">{{ loadingError }}</ion-text>
</ion-label>
</ion-item>
<ng-template #noError>
<ng-container *ngIf="configForm && !pkg.installed?.status?.configured">
<ng-container *ngIf="!original; else hasOriginal">
<h2
*ngIf="!configForm.dirty"
class="ion-padding-bottom header-details"
>
<ion-text color="success">
{{ pkg.manifest.title }} has been automatically configured with
recommended defaults. Make whatever changes you want, then click
"Save".
</ion-text>
</h2>
</ng-container>
<ng-template #hasOriginal>
<h2 *ngIf="hasNewOptions" class="ion-padding-bottom header-details">
<ion-text color="success">
New config options! To accept the default values, click "Save".
You may also customize these new options below.
</ion-text>
</h2>
</ng-template>
</ng-container>
<!-- auto-config -->
<ion-item
lines="none"
*ngIf="dependentInfo"
class="rec-item"
style="margin-bottom: 48px"
>
<ion-label>
<h2 style="display: flex; align-items: center">
<img
style="width: 18px; margin: 4px"
[src]="pkg['static-files'].icon"
[alt]="pkg.manifest.title"
/>
<ion-text
style="margin: 5px; font-family: 'Montserrat'; font-size: 18px"
>
{{ pkg.manifest.title }}
</ion-text>
</h2>
<p>
<ion-text color="dark">
The following modifications have been made to {{
pkg.manifest.title }} to satisfy {{ dependentInfo.title }}:
<ul>
<li *ngFor="let d of diff" [innerHtml]="d"></li>
</ul>
To accept these modifications, click "Save".
</ion-text>
</p>
</ion-label>
</ion-item>
<!-- no options -->
<ion-item *ngIf="!hasOptions">
<ion-label>
<p>
No config options for {{ pkg.manifest.title }} {{
pkg.manifest.version }}.
</p>
</ion-label>
</ion-item>
<!-- has config -->
<form
*ngIf="configForm && configSpec"
[formGroup]="configForm"
novalidate
>
<form-object
[objectSpec]="configSpec"
[formGroup]="configForm"
[current]="configForm.value"
[original]="original"
(hasNewOptions)="hasNewOptions = true"
></form-object>
</form>
</ng-template>
</ng-template>
</ion-content>
<ion-footer>
<ion-toolbar>
<ng-container *ngIf="!loading && !loadingError">
<ion-buttons
*ngIf="configForm && hasOptions"
slot="start"
class="ion-padding-start"
>
<ion-button fill="clear" (click)="resetDefaults()">
<ion-icon slot="start" name="refresh"></ion-icon>
Reset Defaults
</ion-button>
</ion-buttons>
<ion-buttons slot="end" class="ion-padding-end">
<ion-button
*ngIf="configForm"
fill="solid"
color="primary"
[disabled]="saving"
(click)="tryConfigure()"
class="enter-click btn-128"
[class.no-click]="saving"
>
Save
</ion-button>
<ion-button
*ngIf="!configForm"
fill="solid"
color="dark"
(click)="dismiss()"
class="enter-click btn-128"
>
Close
</ion-button>
</ion-buttons>
</ng-container>
</ion-toolbar>
</ion-footer>

View File

@@ -0,0 +1,12 @@
.notifier-item {
margin: 12px;
margin-top: 0px;
border-radius: 12px;
// kills the lines
--border-width: 0;
--inner-border-width: 0;
}
.header-details {
font-size: 20px;
}

View File

@@ -0,0 +1,344 @@
import { Component, Input } from '@angular/core'
import {
AlertController,
ModalController,
LoadingController,
IonicSafeString,
} from '@ionic/angular'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import {
ErrorToastService,
getErrorMessage,
isEmptyObject,
isObject,
} from '@start9labs/shared'
import { DependentInfo } from 'src/app/types/dependent-info'
import { ConfigSpec } from 'src/app/pkg-config/config-types'
import {
DataModel,
PackageDataEntry,
} from 'src/app/services/patch-db/data-model'
import { PatchDB } from 'patch-db-client'
import { UntypedFormGroup } from '@angular/forms'
import {
convertValuesRecursive,
FormService,
} from 'src/app/services/form.service'
import { compare, Operation, getValueByPointer } from 'fast-json-patch'
import { hasCurrentDeps } from 'src/app/util/has-deps'
import { getAllPackages, getPackage } from 'src/app/util/get-package-data'
import { Breakages } from 'src/app/services/api/api.types'
@Component({
selector: 'app-config',
templateUrl: './app-config.page.html',
styleUrls: ['./app-config.page.scss'],
})
export class AppConfigPage {
@Input() pkgId!: string
@Input() dependentInfo?: DependentInfo
pkg!: PackageDataEntry
loadingText = ''
configSpec?: ConfigSpec
configForm?: UntypedFormGroup
original?: object // only if existing config
diff?: string[] // only if dependent info
loading = true
hasNewOptions = false
saving = false
loadingError: string | IonicSafeString = ''
hasOptions = false
constructor(
private readonly embassyApi: ApiService,
private readonly errToast: ErrorToastService,
private readonly loadingCtrl: LoadingController,
private readonly alertCtrl: AlertController,
private readonly modalCtrl: ModalController,
private readonly formService: FormService,
private readonly patch: PatchDB<DataModel>,
) {}
async ngOnInit() {
try {
const pkg = await getPackage(this.patch, this.pkgId)
if (!pkg) return
this.pkg = pkg
if (!this.pkg.manifest.config) return
let newConfig: object | undefined
let patch: Operation[] | undefined
if (this.dependentInfo) {
this.loadingText = `Setting properties to accommodate ${this.dependentInfo.title}`
const {
'old-config': oc,
'new-config': nc,
spec: s,
} = await this.embassyApi.dryConfigureDependency({
'dependency-id': this.pkgId,
'dependent-id': this.dependentInfo.id,
})
this.original = oc
newConfig = nc
this.configSpec = s
patch = compare(this.original, newConfig)
} else {
this.loadingText = 'Loading Config'
const { config: c, spec: s } = await this.embassyApi.getPackageConfig({
id: this.pkgId,
})
this.original = c
this.configSpec = s
}
this.configForm = this.formService.createForm(
this.configSpec,
newConfig || this.original,
)
this.hasOptions = !!Object.values(this.configSpec).find(
valSpec => valSpec.type !== 'pointer',
)
if (patch) {
this.diff = this.getDiff(patch)
this.markDirty(patch)
}
} catch (e: any) {
this.loadingError = getErrorMessage(e)
} finally {
this.loading = false
}
}
resetDefaults() {
this.configForm = this.formService.createForm(this.configSpec!)
const patch = compare(this.original || {}, this.configForm.value)
this.markDirty(patch)
}
async dismiss() {
if (this.configForm?.dirty) {
this.presentAlertUnsaved()
} else {
this.modalCtrl.dismiss()
}
}
async tryConfigure() {
convertValuesRecursive(this.configSpec!, this.configForm!)
if (this.configForm!.invalid) {
document
.getElementsByClassName('validation-error')[0]
?.scrollIntoView({ behavior: 'smooth' })
return
}
this.saving = true
if (hasCurrentDeps(this.pkg)) {
this.dryConfigure()
} else {
this.configure()
}
}
private async dryConfigure() {
const loader = await this.loadingCtrl.create({
message: 'Checking dependent services...',
})
await loader.present()
try {
const breakages = await this.embassyApi.drySetPackageConfig({
id: this.pkgId,
config: this.configForm!.value,
})
if (isEmptyObject(breakages)) {
this.configure(loader)
} else {
await loader.dismiss()
const proceed = await this.presentAlertBreakages(breakages)
if (proceed) {
this.configure()
} else {
this.saving = false
}
}
} catch (e: any) {
this.errToast.present(e)
this.saving = false
loader.dismiss()
}
}
private async configure(loader?: HTMLIonLoadingElement) {
const message = 'Saving...'
if (loader) {
loader.message = message
} else {
loader = await this.loadingCtrl.create({ message })
await loader.present()
}
try {
await this.embassyApi.setPackageConfig({
id: this.pkgId,
config: this.configForm!.value,
})
this.modalCtrl.dismiss()
} catch (e: any) {
this.errToast.present(e)
} finally {
this.saving = false
loader.dismiss()
}
}
private async presentAlertBreakages(breakages: Breakages): Promise<boolean> {
let message: string =
'As a result of this change, the following services will no longer work properly and may crash:<ul>'
const localPkgs = await getAllPackages(this.patch)
const bullets = Object.keys(breakages).map(id => {
const title = localPkgs[id].manifest.title
return `<li><b>${title}</b></li>`
})
message = `${message}${bullets}</ul>`
return new Promise(async resolve => {
const alert = await this.alertCtrl.create({
header: 'Warning',
message,
buttons: [
{
text: 'Cancel',
role: 'cancel',
handler: () => {
resolve(false)
},
},
{
text: 'Continue',
handler: () => {
resolve(true)
},
cssClass: 'enter-click',
},
],
cssClass: 'alert-warning-message',
})
await alert.present()
})
}
private getDiff(patch: Operation[]): string[] {
return patch.map(op => {
let message: string
switch (op.op) {
case 'add':
message = `Added ${this.getNewValue(op.value)}`
break
case 'remove':
message = `Removed ${this.getOldValue(op.path)}`
break
case 'replace':
message = `Changed from ${this.getOldValue(
op.path,
)} to ${this.getNewValue(op.value)}`
break
default:
message = `Unknown operation`
}
let displayPath: string
const arrPath = op.path
.substring(1)
.split('/')
.map(node => {
const num = Number(node)
return isNaN(num) ? node : num
})
if (typeof arrPath[arrPath.length - 1] === 'number') {
arrPath.pop()
}
displayPath = arrPath.join(' &rarr; ')
return `${displayPath}: ${message}`
})
}
private getOldValue(path: any): string {
const val = getValueByPointer(this.original, path)
if (['string', 'number', 'boolean'].includes(typeof val)) {
return val
} else if (isObject(val)) {
return 'entry'
} else {
return 'list'
}
}
private getNewValue(val: any): string {
if (['string', 'number', 'boolean'].includes(typeof val)) {
return val
} else if (isObject(val)) {
return 'new entry'
} else {
return 'new list'
}
}
private markDirty(patch: Operation[]) {
patch.forEach(op => {
const arrPath = op.path
.substring(1)
.split('/')
.map(node => {
const num = Number(node)
return isNaN(num) ? node : num
})
if (op.op !== 'remove') this.configForm!.get(arrPath)?.markAsDirty()
if (typeof arrPath[arrPath.length - 1] === 'number') {
const prevPath = arrPath.slice(0, arrPath.length - 1)
this.configForm!.get(prevPath)?.markAsDirty()
}
})
}
private async presentAlertUnsaved() {
const alert = await this.alertCtrl.create({
header: 'Unsaved Changes',
message: 'You have unsaved changes. Are you sure you want to leave?',
buttons: [
{
text: 'Cancel',
role: 'cancel',
},
{
text: `Leave`,
handler: () => {
this.modalCtrl.dismiss()
},
cssClass: 'enter-click',
},
],
})
await alert.present()
}
}

View File

@@ -0,0 +1,14 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { FormsModule } from '@angular/forms'
import { AppRecoverSelectPage } from './app-recover-select.page'
import { ToOptionsPipe } from './to-options.pipe'
@NgModule({
declarations: [AppRecoverSelectPage, ToOptionsPipe],
imports: [CommonModule, IonicModule, FormsModule],
exports: [AppRecoverSelectPage],
})
export class AppRecoverSelectPageModule {}

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>

Some files were not shown because too many files have changed in this diff Show More