mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-26 10:21:52 +00:00
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:
31
web/projects/ui/ngsw-config.json
Normal file
31
web/projects/ui/ngsw-config.json
Normal 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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
87
web/projects/ui/src/app/app-routing.module.ts
Normal file
87
web/projects/ui/src/app/app-routing.module.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { PreloadAllModules, RouterModule, Routes } from '@angular/router'
|
||||
import { AuthGuard } from './guards/auth.guard'
|
||||
import { UnauthGuard } from './guards/unauth.guard'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
redirectTo: 'services',
|
||||
pathMatch: 'full',
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
path: 'login',
|
||||
canActivate: [UnauthGuard],
|
||||
loadChildren: () =>
|
||||
import('./pages/login/login.module').then(m => m.LoginPageModule),
|
||||
},
|
||||
{
|
||||
path: 'home',
|
||||
canActivate: [AuthGuard],
|
||||
loadChildren: () =>
|
||||
import('./pages/home/home.module').then(m => m.HomePageModule),
|
||||
},
|
||||
{
|
||||
path: 'system',
|
||||
canActivate: [AuthGuard],
|
||||
canActivateChild: [AuthGuard],
|
||||
loadChildren: () =>
|
||||
import('./pages/server-routes/server-routing.module').then(
|
||||
m => m.ServerRoutingModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'updates',
|
||||
canActivate: [AuthGuard],
|
||||
canActivateChild: [AuthGuard],
|
||||
loadChildren: () =>
|
||||
import('./pages/updates/updates.module').then(m => m.UpdatesPageModule),
|
||||
},
|
||||
{
|
||||
path: 'marketplace',
|
||||
canActivate: [AuthGuard],
|
||||
canActivateChild: [AuthGuard],
|
||||
loadChildren: () =>
|
||||
import('./pages/marketplace-routes/marketplace-routing.module').then(
|
||||
m => m.MarketplaceRoutingModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'notifications',
|
||||
canActivate: [AuthGuard],
|
||||
loadChildren: () =>
|
||||
import('./pages/notifications/notifications.module').then(
|
||||
m => m.NotificationsPageModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'services',
|
||||
canActivate: [AuthGuard],
|
||||
canActivateChild: [AuthGuard],
|
||||
loadChildren: () =>
|
||||
import('./pages/apps-routes/apps-routing.module').then(
|
||||
m => m.AppsRoutingModule,
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'developer',
|
||||
canActivate: [AuthGuard],
|
||||
canActivateChild: [AuthGuard],
|
||||
loadChildren: () =>
|
||||
import('./pages/developer-routes/developer-routing.module').then(
|
||||
m => m.DeveloperRoutingModule,
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forRoot(routes, {
|
||||
scrollPositionRestoration: 'enabled',
|
||||
preloadingStrategy: PreloadAllModules,
|
||||
initialNavigation: 'disabled',
|
||||
}),
|
||||
],
|
||||
exports: [RouterModule],
|
||||
})
|
||||
export class AppRoutingModule {}
|
||||
80
web/projects/ui/src/app/app.component.html
Normal file
80
web/projects/ui/src/app/app.component.html
Normal file
@@ -0,0 +1,80 @@
|
||||
<tui-root
|
||||
*ngIf="widgetDrawer$ | async as drawer"
|
||||
[tuiMode]="(theme$ | async) === 'Dark' ? 'onDark' : null"
|
||||
[style.--widgets-width.px]="drawer.open ? drawer.width : 0"
|
||||
>
|
||||
<ion-app appEnter>
|
||||
<ion-content>
|
||||
<ion-split-pane
|
||||
contentId="main-content"
|
||||
[disabled]="!(authService.isVerified$ | async)"
|
||||
(ionSplitPaneVisible)="splitPaneVisible($event)"
|
||||
>
|
||||
<ion-menu
|
||||
contentId="main-content"
|
||||
type="overlay"
|
||||
side="start"
|
||||
class="left-menu"
|
||||
>
|
||||
<ion-content color="light" scrollY="false" class="menu">
|
||||
<app-menu *ngIf="authService.isVerified$ | async"></app-menu>
|
||||
</ion-content>
|
||||
</ion-menu>
|
||||
|
||||
<ion-menu
|
||||
contentId="main-content"
|
||||
type="overlay"
|
||||
side="end"
|
||||
class="right-menu container"
|
||||
[class.container_offline]="offline$ | async"
|
||||
[class.right-menu_hidden]="!drawer.open"
|
||||
[style.--side-width.px]="drawer.width"
|
||||
>
|
||||
<div class="divider">
|
||||
<button
|
||||
class="widgets-button"
|
||||
[class.widgets-button_collapse]="drawer.width === 600"
|
||||
(click)="onResize(drawer)"
|
||||
></button>
|
||||
</div>
|
||||
<widgets *ngIf="drawer.open" [wide]="drawer.width === 600"></widgets>
|
||||
</ion-menu>
|
||||
|
||||
<ion-router-outlet
|
||||
[responsiveColViewport]="viewport"
|
||||
id="main-content"
|
||||
class="container"
|
||||
[class.container_offline]="offline$ | async"
|
||||
>
|
||||
<ion-content
|
||||
#viewport="viewport"
|
||||
responsiveColViewport
|
||||
class="ion-padding with-widgets"
|
||||
style="pointer-events: none; opacity: 0"
|
||||
></ion-content>
|
||||
</ion-router-outlet>
|
||||
</ion-split-pane>
|
||||
|
||||
<section appPreloader></section>
|
||||
</ion-content>
|
||||
<ion-footer>
|
||||
<footer appFooter></footer>
|
||||
</ion-footer>
|
||||
<ion-footer
|
||||
*ngIf="(authService.isVerified$ | async) && !(sidebarOpen$ | async)"
|
||||
>
|
||||
<connection-bar></connection-bar>
|
||||
</ion-footer>
|
||||
<toast-container></toast-container>
|
||||
</ion-app>
|
||||
</tui-root>
|
||||
<ng-container
|
||||
*ngIf="authService.isVerified$ | async"
|
||||
[ngSwitch]="theme$ | async"
|
||||
>
|
||||
<ng-container *ngSwitchCase="'Dark'">
|
||||
<tui-theme-night></tui-theme-night>
|
||||
<dark-theme></dark-theme>
|
||||
</ng-container>
|
||||
<light-theme *ngSwitchCase="'Light'"></light-theme>
|
||||
</ng-container>
|
||||
125
web/projects/ui/src/app/app.component.scss
Normal file
125
web/projects/ui/src/app/app.component.scss
Normal file
@@ -0,0 +1,125 @@
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
tui-root {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.left-menu {
|
||||
--side-max-width: 280px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
:host-context(body[data-theme='Light']) & {
|
||||
--ion-color-base: #F4F4F5 !important;
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
transition: filter 0.3s;
|
||||
|
||||
&_offline {
|
||||
filter: saturate(0.75) contrast(0.85);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 991.499px) {
|
||||
--widgets-width: 0px;
|
||||
}
|
||||
}
|
||||
|
||||
.right-menu {
|
||||
--side-max-width: 600px;
|
||||
|
||||
position: fixed;
|
||||
z-index: 1000;
|
||||
right: 0;
|
||||
left: auto;
|
||||
top: 74px;
|
||||
|
||||
// For some reason *ngIf is broken upon first login
|
||||
&_hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 100%;
|
||||
width: 10px;
|
||||
pointer-events: none;
|
||||
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
|
||||
background: #e2e2e2;
|
||||
|
||||
z-index: 10;
|
||||
opacity: 0.2;
|
||||
transition: opacity 0.3s;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
margin-top: -78px;
|
||||
left: 10px;
|
||||
width: 60px;
|
||||
height: 50px;
|
||||
border-bottom-left-radius: 14px;
|
||||
box-shadow: -14px 0 0 -1px #e2e2e2;
|
||||
}
|
||||
|
||||
&:after {
|
||||
margin-top: 28px;
|
||||
border-radius: 0;
|
||||
border-top-left-radius: 14px;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.widgets-button {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
font-size: 0;
|
||||
left: 100%;
|
||||
width: 16px;
|
||||
height: 60px;
|
||||
margin-top: -30px;
|
||||
border-top-right-radius: 10px;
|
||||
border-bottom-right-radius: 10px;
|
||||
background: inherit;
|
||||
pointer-events: auto;
|
||||
|
||||
&:before,
|
||||
&:after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 3px;
|
||||
width: 2px;
|
||||
height: 8px;
|
||||
background: black;
|
||||
transform: rotate(-45deg);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
&:before {
|
||||
margin-top: -5px;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
&_collapse:before {
|
||||
transform: rotate(-45deg);
|
||||
}
|
||||
|
||||
&_collapse:after {
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
}
|
||||
74
web/projects/ui/src/app/app.component.ts
Normal file
74
web/projects/ui/src/app/app.component.ts
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Component, inject, OnDestroy } from '@angular/core'
|
||||
import { combineLatest, map, merge, startWith } from 'rxjs'
|
||||
import { AuthService } from './services/auth.service'
|
||||
import { SplitPaneTracker } from './services/split-pane.service'
|
||||
import { PatchDataService } from './services/patch-data.service'
|
||||
import { PatchMonitorService } from './services/patch-monitor.service'
|
||||
import { ConnectionService } from './services/connection.service'
|
||||
import { Title } from '@angular/platform-browser'
|
||||
import {
|
||||
ClientStorageService,
|
||||
WidgetDrawer,
|
||||
} from './services/client-storage.service'
|
||||
import { ThemeSwitcherService } from './services/theme-switcher.service'
|
||||
import { THEME } from '@start9labs/shared'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { DataModel } from './services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
templateUrl: 'app.component.html',
|
||||
styleUrls: ['app.component.scss'],
|
||||
})
|
||||
export class AppComponent implements OnDestroy {
|
||||
readonly subscription = merge(this.patchData, this.patchMonitor).subscribe()
|
||||
readonly sidebarOpen$ = this.splitPane.sidebarOpen$
|
||||
readonly widgetDrawer$ = this.clientStorageService.widgetDrawer$
|
||||
readonly theme$ = inject(THEME)
|
||||
readonly offline$ = combineLatest([
|
||||
this.authService.isVerified$,
|
||||
this.connection.connected$,
|
||||
this.patch
|
||||
.watch$('server-info', 'status-info')
|
||||
.pipe(startWith({ restarting: false, 'shutting-down': false })),
|
||||
]).pipe(
|
||||
map(
|
||||
([verified, connected, status]) =>
|
||||
verified &&
|
||||
(!connected || status.restarting || status['shutting-down']),
|
||||
),
|
||||
)
|
||||
|
||||
constructor(
|
||||
private readonly titleService: Title,
|
||||
private readonly patchData: PatchDataService,
|
||||
private readonly patchMonitor: PatchMonitorService,
|
||||
private readonly splitPane: SplitPaneTracker,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
readonly authService: AuthService,
|
||||
readonly connection: ConnectionService,
|
||||
readonly clientStorageService: ClientStorageService,
|
||||
readonly themeSwitcher: ThemeSwitcherService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
this.patch
|
||||
.watch$('ui', 'name')
|
||||
.subscribe(name => this.titleService.setTitle(name || 'StartOS'))
|
||||
}
|
||||
|
||||
splitPaneVisible({ detail }: any) {
|
||||
this.splitPane.sidebarOpen$.next(detail.visible)
|
||||
}
|
||||
|
||||
onResize(drawer: WidgetDrawer) {
|
||||
this.clientStorageService.updateWidgetDrawer({
|
||||
...drawer,
|
||||
width: drawer.width === 400 ? 600 : 400,
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.subscription.unsubscribe()
|
||||
}
|
||||
}
|
||||
77
web/projects/ui/src/app/app.module.ts
Normal file
77
web/projects/ui/src/app/app.module.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
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 {}
|
||||
57
web/projects/ui/src/app/app.providers.ts
Normal file
57
web/projects/ui/src/app/app.providers.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
32
web/projects/ui/src/app/app/footer/footer.component.html
Normal file
32
web/projects/ui/src/app/app/footer/footer.component.html
Normal file
@@ -0,0 +1,32 @@
|
||||
<ion-toolbar
|
||||
*ngIf="progress$ | async as progress"
|
||||
color="light"
|
||||
[@heightCollapse]="animation"
|
||||
>
|
||||
<ion-list class="list">
|
||||
<!-- show progress -->
|
||||
<ng-container *ngIf="progress.size !== null; else calculating">
|
||||
<ion-list-header>
|
||||
<ion-label>
|
||||
Downloading: {{ getProgress(progress.size, progress.downloaded) }}%
|
||||
</ion-label>
|
||||
</ion-list-header>
|
||||
<ion-progress-bar
|
||||
class="progress"
|
||||
color="secondary"
|
||||
[value]="getProgress(progress.size, progress.downloaded) / 100"
|
||||
></ion-progress-bar>
|
||||
</ng-container>
|
||||
<!-- show calculating -->
|
||||
<ng-template #calculating>
|
||||
<ion-list-header>
|
||||
<ion-label>Calculating download size</ion-label>
|
||||
</ion-list-header>
|
||||
<ion-progress-bar
|
||||
class="progress"
|
||||
color="secondary"
|
||||
type="indeterminate"
|
||||
></ion-progress-bar>
|
||||
</ng-template>
|
||||
</ion-list>
|
||||
</ion-toolbar>
|
||||
9
web/projects/ui/src/app/app/footer/footer.component.scss
Normal file
9
web/projects/ui/src/app/app/footer/footer.component.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
.list {
|
||||
box-shadow: inset 0 1px var(--ion-color-dark);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.progress {
|
||||
width: auto;
|
||||
margin: 0 16px 16px 16px;
|
||||
}
|
||||
32
web/projects/ui/src/app/app/footer/footer.component.ts
Normal file
32
web/projects/ui/src/app/app/footer/footer.component.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
import { 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))
|
||||
}
|
||||
}
|
||||
12
web/projects/ui/src/app/app/footer/footer.module.ts
Normal file
12
web/projects/ui/src/app/app/footer/footer.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
|
||||
import { FooterComponent } from './footer.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, IonicModule],
|
||||
declarations: [FooterComponent],
|
||||
exports: [FooterComponent],
|
||||
})
|
||||
export class FooterModule {}
|
||||
64
web/projects/ui/src/app/app/menu/menu.component.html
Normal file
64
web/projects/ui/src/app/app/menu/menu.component.html
Normal file
@@ -0,0 +1,64 @@
|
||||
<a class="logo" routerLink="/home">
|
||||
<img alt="StartOS" src="assets/img/icon.png" />
|
||||
</a>
|
||||
<ion-item-group class="menu">
|
||||
<ion-menu-toggle *ngFor="let page of pages" auto-hide="false">
|
||||
<ion-item
|
||||
button
|
||||
class="link"
|
||||
routerLinkActive="link_selected"
|
||||
color="transparent"
|
||||
routerDirection="root"
|
||||
lines="none"
|
||||
detail="false"
|
||||
[routerLink]="page.url"
|
||||
>
|
||||
<ion-icon
|
||||
slot="start"
|
||||
class="icon label"
|
||||
routerLinkActive="label_selected"
|
||||
[name]="page.icon"
|
||||
></ion-icon>
|
||||
<ion-label class="label montserrat" routerLinkActive="label_selected">
|
||||
{{ page.title }}
|
||||
</ion-label>
|
||||
<ion-icon
|
||||
*ngIf="page.url === '/system' && (warning$ | async)"
|
||||
color="warning"
|
||||
size="small"
|
||||
name="warning"
|
||||
></ion-icon>
|
||||
<ion-icon
|
||||
*ngIf="page.url === '/system' && (showEOSUpdate$ | async)"
|
||||
color="success"
|
||||
size="small"
|
||||
name="rocket"
|
||||
></ion-icon>
|
||||
<ion-badge
|
||||
*ngIf="page.url === '/updates' && (updateCount$ | async) as updateCount"
|
||||
color="success"
|
||||
>
|
||||
{{ updateCount }}
|
||||
</ion-badge>
|
||||
<ion-badge
|
||||
*ngIf="
|
||||
page.url === '/notifications' &&
|
||||
(notificationCount$ | async) as notificaitonCount
|
||||
"
|
||||
color="danger"
|
||||
>
|
||||
{{ notificaitonCount }}
|
||||
</ion-badge>
|
||||
</ion-item>
|
||||
</ion-menu-toggle>
|
||||
</ion-item-group>
|
||||
<img
|
||||
appSnek
|
||||
class="snek"
|
||||
alt="Play Snek"
|
||||
src="assets/img/icons/snek.png"
|
||||
[appSnekHighScore]="snekScore$ | async"
|
||||
/>
|
||||
<ion-footer *ngIf="sidebarOpen$ | async" class="bottom">
|
||||
<connection-bar></connection-bar>
|
||||
</ion-footer>
|
||||
49
web/projects/ui/src/app/app/menu/menu.component.scss
Normal file
49
web/projects/ui/src/app/app/menu/menu.component.scss
Normal file
@@ -0,0 +1,49 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.logo {
|
||||
display: block;
|
||||
width: 36%;
|
||||
margin: 0 auto;
|
||||
padding: 16px 16px 0 16px;
|
||||
}
|
||||
|
||||
.menu {
|
||||
padding: 30px 0;
|
||||
}
|
||||
|
||||
.link {
|
||||
--border-radius: 0;
|
||||
|
||||
:host-context(body[data-theme='Light']) &_selected {
|
||||
--ion-color-base: #333;
|
||||
--ion-color-contrast: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--ion-color-dark-shade);
|
||||
|
||||
&_selected {
|
||||
color: var(--ion-color-dark);
|
||||
font-weight: bold;
|
||||
}
|
||||
}
|
||||
|
||||
.snek {
|
||||
position: absolute;
|
||||
bottom: 56px;
|
||||
right: 20px;
|
||||
width: 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
}
|
||||
132
web/projects/ui/src/app/app/menu/menu.component.ts
Normal file
132
web/projects/ui/src/app/app/menu/menu.component.ts
Normal 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,
|
||||
) {}
|
||||
}
|
||||
20
web/projects/ui/src/app/app/menu/menu.module.ts
Normal file
20
web/projects/ui/src/app/app/menu/menu.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { MenuComponent } from './menu.component'
|
||||
import { SnekModule } from '../snek/snek.module'
|
||||
import { ConnectionBarComponentModule } from 'src/app/components/connection-bar/connection-bar.component.module'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule,
|
||||
SnekModule,
|
||||
ConnectionBarComponentModule,
|
||||
],
|
||||
declarations: [MenuComponent],
|
||||
exports: [MenuComponent],
|
||||
})
|
||||
export class MenuModule {}
|
||||
@@ -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>
|
||||
99
web/projects/ui/src/app/app/preloader/preloader.component.ts
Normal file
99
web/projects/ui/src/app/app/preloader/preloader.component.ts
Normal 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
|
||||
}
|
||||
13
web/projects/ui/src/app/app/preloader/preloader.module.ts
Normal file
13
web/projects/ui/src/app/app/preloader/preloader.module.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { QrCodeModule } from 'ng-qrcode'
|
||||
import { PreloaderComponent } from './preloader.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, IonicModule, QrCodeModule],
|
||||
declarations: [PreloaderComponent],
|
||||
exports: [PreloaderComponent],
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
})
|
||||
export class PreloaderModule {}
|
||||
54
web/projects/ui/src/app/app/snek/snek.directive.ts
Normal file
54
web/projects/ui/src/app/app/snek/snek.directive.ts
Normal 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()
|
||||
}
|
||||
}
|
||||
11
web/projects/ui/src/app/app/snek/snek.module.ts
Normal file
11
web/projects/ui/src/app/app/snek/snek.module.ts
Normal 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 {}
|
||||
@@ -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>
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,4 @@
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: unset;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 {}
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<ion-toolbar
|
||||
*ngIf="connection$ | async as connection"
|
||||
class="connection-toolbar"
|
||||
[color]="connection.color"
|
||||
>
|
||||
<div class="inline" slot="start">
|
||||
<ion-icon [name]="connection.icon" class="icon"></ion-icon>
|
||||
<p style="margin: 8px 0; font-weight: 600">{{ connection.message }}</p>
|
||||
<ion-spinner
|
||||
*ngIf="connection.dots"
|
||||
name="dots"
|
||||
color="light"
|
||||
class="ion-margin-start"
|
||||
></ion-spinner>
|
||||
</div>
|
||||
</ion-toolbar>
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { ConnectionBarComponent } from './connection-bar.component'
|
||||
|
||||
@NgModule({
|
||||
declarations: [ConnectionBarComponent],
|
||||
imports: [CommonModule, IonicModule],
|
||||
exports: [ConnectionBarComponent],
|
||||
})
|
||||
export class ConnectionBarComponentModule {}
|
||||
@@ -0,0 +1,9 @@
|
||||
.connection-toolbar {
|
||||
padding: 0 24px;
|
||||
--min-height: 36px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 23px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { combineLatest, map, Observable, startWith } from 'rxjs'
|
||||
import { ConnectionService } from 'src/app/services/connection.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'connection-bar',
|
||||
templateUrl: './connection-bar.component.html',
|
||||
styleUrls: ['./connection-bar.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ConnectionBarComponent {
|
||||
private readonly websocket$ = this.connectionService.websocketConnected$
|
||||
|
||||
readonly connection$: Observable<{
|
||||
message: string
|
||||
color: string
|
||||
icon: string
|
||||
dots: boolean
|
||||
}> = combineLatest([
|
||||
this.connectionService.networkConnected$,
|
||||
this.websocket$.pipe(startWith(false)),
|
||||
this.patch
|
||||
.watch$('server-info', 'status-info')
|
||||
.pipe(startWith({ restarting: false, 'shutting-down': false })),
|
||||
]).pipe(
|
||||
map(([network, websocket, status]) => {
|
||||
if (!network)
|
||||
return {
|
||||
message: 'No Internet',
|
||||
color: 'danger',
|
||||
icon: 'cloud-offline-outline',
|
||||
dots: false,
|
||||
}
|
||||
if (!websocket)
|
||||
return {
|
||||
message: 'Connecting',
|
||||
color: 'warning',
|
||||
icon: 'cloud-offline-outline',
|
||||
dots: true,
|
||||
}
|
||||
if (status['shutting-down'])
|
||||
return {
|
||||
message: 'Shutting Down',
|
||||
color: 'dark',
|
||||
icon: 'power',
|
||||
dots: true,
|
||||
}
|
||||
if (status.restarting)
|
||||
return {
|
||||
message: 'Restarting',
|
||||
color: 'dark',
|
||||
icon: 'power',
|
||||
dots: true,
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'Connected',
|
||||
color: 'success',
|
||||
icon: 'cloud-done',
|
||||
dots: false,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
constructor(
|
||||
private readonly connectionService: ConnectionService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
}
|
||||
@@ -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"> (New)</ion-text>
|
||||
<ion-text color="success" *ngIf="data.newOptions"> (New Options)</ion-text>
|
||||
<ion-text color="warning" *ngIf="data.edited"> (Edited)</ion-text>
|
||||
|
||||
<span *ngIf="data.required"> *</span>
|
||||
@@ -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>
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
94
web/projects/ui/src/app/components/logs/logs.component.html
Normal file
94
web/projects/ui/src/app/components/logs/logs.component.html
Normal 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>
|
||||
@@ -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 {}
|
||||
@@ -0,0 +1,5 @@
|
||||
#container {
|
||||
padding-bottom: 16px;
|
||||
font-family: monospace;
|
||||
white-space: pre-line;
|
||||
}
|
||||
259
web/projects/ui/src/app/components/logs/logs.component.ts
Normal file
259
web/projects/ui/src/app/components/logs/logs.component.ts
Normal 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> ${convert.toHtml(entry.message)}`,
|
||||
)
|
||||
.join('<br />')
|
||||
}
|
||||
}
|
||||
1
web/projects/ui/src/app/components/qr/qr.component.html
Normal file
1
web/projects/ui/src/app/components/qr/qr.component.html
Normal file
@@ -0,0 +1 @@
|
||||
<qr-code [value]="text" size="400"></qr-code>
|
||||
12
web/projects/ui/src/app/components/qr/qr.component.module.ts
Normal file
12
web/projects/ui/src/app/components/qr/qr.component.module.ts
Normal 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 {}
|
||||
10
web/projects/ui/src/app/components/qr/qr.component.ts
Normal file
10
web/projects/ui/src/app/components/qr/qr.component.ts
Normal 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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 {}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 {}
|
||||
@@ -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) {}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 {}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<notifications-toast></notifications-toast>
|
||||
<refresh-alert></refresh-alert>
|
||||
<update-toast></update-toast>
|
||||
@@ -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 {}
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 {}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
]
|
||||
}
|
||||
29
web/projects/ui/src/app/guards/auth.guard.ts
Normal file
29
web/projects/ui/src/app/guards/auth.guard.ts
Normal 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')),
|
||||
)
|
||||
}
|
||||
}
|
||||
21
web/projects/ui/src/app/guards/unauth.guard.ts
Normal file
21
web/projects/ui/src/app/guards/unauth.guard.ts
Normal 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('')),
|
||||
)
|
||||
}
|
||||
}
|
||||
13
web/projects/ui/src/app/marketplace.module.ts
Normal file
13
web/projects/ui/src/app/marketplace.module.ts
Normal 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 {}
|
||||
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
149
web/projects/ui/src/app/modals/app-config/app-config.page.html
Normal file
149
web/projects/ui/src/app/modals/app-config/app-config.page.html
Normal 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>
|
||||
@@ -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;
|
||||
}
|
||||
344
web/projects/ui/src/app/modals/app-config/app-config.page.ts
Normal file
344
web/projects/ui/src/app/modals/app-config/app-config.page.ts
Normal 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(' → ')
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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
Reference in New Issue
Block a user