mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 12:11:56 +00:00
rename frontend to web
This commit is contained in:
89
web/projects/ui/src/app/app.component.html
Normal file
89
web/projects/ui/src/app/app.component.html
Normal file
@@ -0,0 +1,89 @@
|
||||
<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]="!(navigation$ | async)"
|
||||
(ionSplitPaneVisible)="splitPaneVisible($event)"
|
||||
>
|
||||
<ion-menu
|
||||
*ngIf="navigation$ | async"
|
||||
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="
|
||||
(navigation$ | async) &&
|
||||
(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; else defaultTheme"
|
||||
[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>
|
||||
<ng-template #defaultTheme>
|
||||
<tui-theme-night></tui-theme-night>
|
||||
<dark-theme></dark-theme>
|
||||
</ng-template>
|
||||
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);
|
||||
}
|
||||
}
|
||||
90
web/projects/ui/src/app/app.component.ts
Normal file
90
web/projects/ui/src/app/app.component.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { Component, inject, OnDestroy } from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
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'
|
||||
|
||||
function hasNavigation(url: string): boolean {
|
||||
return (
|
||||
!url.startsWith('/loading') &&
|
||||
!url.startsWith('/diagnostic') &&
|
||||
!url.startsWith('/portal')
|
||||
)
|
||||
}
|
||||
|
||||
@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 navigation$ = combineLatest([
|
||||
this.authService.isVerified$,
|
||||
this.router.events.pipe(map(() => hasNavigation(this.router.url))),
|
||||
]).pipe(map(([isVerified, hasNavigation]) => isVerified && hasNavigation))
|
||||
|
||||
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 router: Router,
|
||||
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()
|
||||
}
|
||||
}
|
||||
79
web/projects/ui/src/app/app.module.ts
Normal file
79
web/projects/ui/src/app/app.module.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
TuiAlertModule,
|
||||
TuiDialogModule,
|
||||
TuiModeModule,
|
||||
TuiRootModule,
|
||||
TuiThemeNightModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { HttpClientModule } from '@angular/common/http'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { BrowserAnimationsModule } from '@angular/platform-browser/animations'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { MonacoEditorModule } from '@materia-ui/ngx-monaco-editor'
|
||||
import {
|
||||
DarkThemeModule,
|
||||
SharedPipesModule,
|
||||
LightThemeModule,
|
||||
LoadingModule,
|
||||
ResponsiveColViewportDirective,
|
||||
EnterModule,
|
||||
MarkdownModule,
|
||||
} from '@start9labs/shared'
|
||||
|
||||
import { AppComponent } from './app.component'
|
||||
import { RoutingModule } from './routing.module'
|
||||
import { OSWelcomePageModule } from './common/os-welcome/os-welcome.module'
|
||||
import { QRComponentModule } from './common/qr/qr.module'
|
||||
import { PreloaderModule } from './app/preloader/preloader.module'
|
||||
import { FooterModule } from './app/footer/footer.module'
|
||||
import { MenuModule } from './app/menu/menu.module'
|
||||
import { APP_PROVIDERS } from './app.providers'
|
||||
import { PatchDbModule } from './services/patch-db/patch-db.module'
|
||||
import { ToastContainerModule } from './common/toast-container/toast-container.module'
|
||||
import { ConnectionBarComponentModule } from './app/connection-bar/connection-bar.component.module'
|
||||
import { WidgetsPageModule } from 'src/app/apps/ui/pages/widgets/widgets.module'
|
||||
import { ServiceWorkerModule } from '@angular/service-worker'
|
||||
import { environment } from '../environments/environment'
|
||||
|
||||
@NgModule({
|
||||
declarations: [AppComponent],
|
||||
imports: [
|
||||
HttpClientModule,
|
||||
BrowserAnimationsModule,
|
||||
IonicModule.forRoot({
|
||||
mode: 'md',
|
||||
}),
|
||||
RoutingModule,
|
||||
MenuModule,
|
||||
PreloaderModule,
|
||||
FooterModule,
|
||||
EnterModule,
|
||||
OSWelcomePageModule,
|
||||
MarkdownModule,
|
||||
MonacoEditorModule,
|
||||
SharedPipesModule,
|
||||
PatchDbModule,
|
||||
ToastContainerModule,
|
||||
ConnectionBarComponentModule,
|
||||
TuiRootModule,
|
||||
TuiDialogModule,
|
||||
TuiAlertModule,
|
||||
TuiModeModule,
|
||||
TuiThemeNightModule,
|
||||
WidgetsPageModule,
|
||||
ResponsiveColViewportDirective,
|
||||
DarkThemeModule,
|
||||
LightThemeModule,
|
||||
ServiceWorkerModule.register('ngsw-worker.js', {
|
||||
enabled: environment.useServiceWorker,
|
||||
// Register the ServiceWorker as soon as the application is stable
|
||||
// or after 30 seconds (whichever comes first).
|
||||
registrationStrategy: 'registerWhenStable:30000',
|
||||
}),
|
||||
LoadingModule,
|
||||
QRComponentModule,
|
||||
],
|
||||
providers: APP_PROVIDERS,
|
||||
bootstrap: [AppComponent],
|
||||
})
|
||||
export class AppModule {}
|
||||
99
web/projects/ui/src/app/app.providers.ts
Normal file
99
web/projects/ui/src/app/app.providers.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { APP_INITIALIZER, Provider } from '@angular/core'
|
||||
import { UntypedFormBuilder } from '@angular/forms'
|
||||
import { Router, RouteReuseStrategy } from '@angular/router'
|
||||
import { IonicRouteStrategy, IonNav } from '@ionic/angular'
|
||||
import { TUI_DATE_FORMAT, TUI_DATE_SEPARATOR } from '@taiga-ui/cdk'
|
||||
import {
|
||||
tuiNumberFormatProvider,
|
||||
tuiTextfieldOptionsProvider,
|
||||
} from '@taiga-ui/core'
|
||||
import { tuiButtonOptionsProvider } from '@taiga-ui/experimental'
|
||||
import {
|
||||
TUI_DATE_TIME_VALUE_TRANSFORMER,
|
||||
TUI_DATE_VALUE_TRANSFORMER,
|
||||
} from '@taiga-ui/kit'
|
||||
import { RELATIVE_URL, THEME, WorkspaceConfig } from '@start9labs/shared'
|
||||
import { AbstractMarketplaceService } from '@start9labs/marketplace'
|
||||
import { ApiService } from './services/api/embassy-api.service'
|
||||
import { MockApiService } from './services/api/embassy-mock-api.service'
|
||||
import { LiveApiService } from './services/api/embassy-live-api.service'
|
||||
import { AuthService } from './services/auth.service'
|
||||
import { ClientStorageService } from './services/client-storage.service'
|
||||
import { FilterPackagesPipe } from '../../../marketplace/src/pipes/filter-packages.pipe'
|
||||
import { ThemeSwitcherService } from './services/theme-switcher.service'
|
||||
import { DateTransformerService } from './services/date-transformer.service'
|
||||
import { DatetimeTransformerService } from './services/datetime-transformer.service'
|
||||
import { MarketplaceService } from './services/marketplace.service'
|
||||
import { RoutingStrategyService } from './apps/portal/services/routing-strategy.service'
|
||||
|
||||
const {
|
||||
useMocks,
|
||||
ui: { api },
|
||||
} = require('../../../../config.json') as WorkspaceConfig
|
||||
|
||||
export const APP_PROVIDERS: Provider[] = [
|
||||
FilterPackagesPipe,
|
||||
UntypedFormBuilder,
|
||||
IonNav,
|
||||
tuiNumberFormatProvider({ decimalSeparator: '.', thousandSeparator: '' }),
|
||||
tuiButtonOptionsProvider({ size: 'm' }),
|
||||
tuiTextfieldOptionsProvider({ hintOnDisabled: true }),
|
||||
{
|
||||
provide: TUI_DATE_FORMAT,
|
||||
useValue: 'MDY',
|
||||
},
|
||||
{
|
||||
provide: TUI_DATE_SEPARATOR,
|
||||
useValue: '/',
|
||||
},
|
||||
{
|
||||
provide: TUI_DATE_VALUE_TRANSFORMER,
|
||||
useClass: DateTransformerService,
|
||||
},
|
||||
{
|
||||
provide: TUI_DATE_TIME_VALUE_TRANSFORMER,
|
||||
useClass: DatetimeTransformerService,
|
||||
},
|
||||
{
|
||||
provide: RouteReuseStrategy,
|
||||
useClass: IonicRouteStrategy,
|
||||
},
|
||||
{
|
||||
provide: ApiService,
|
||||
useClass: useMocks ? MockApiService : LiveApiService,
|
||||
},
|
||||
{
|
||||
provide: APP_INITIALIZER,
|
||||
deps: [AuthService, ClientStorageService, Router],
|
||||
useFactory: appInitializer,
|
||||
multi: true,
|
||||
},
|
||||
{
|
||||
provide: RELATIVE_URL,
|
||||
useValue: `/${api.url}/${api.version}`,
|
||||
},
|
||||
{
|
||||
provide: THEME,
|
||||
useExisting: ThemeSwitcherService,
|
||||
},
|
||||
{
|
||||
provide: AbstractMarketplaceService,
|
||||
useClass: MarketplaceService,
|
||||
},
|
||||
{
|
||||
provide: RouteReuseStrategy,
|
||||
useExisting: RoutingStrategyService,
|
||||
},
|
||||
]
|
||||
|
||||
export function appInitializer(
|
||||
auth: AuthService,
|
||||
localStorage: ClientStorageService,
|
||||
router: Router,
|
||||
): () => void {
|
||||
return () => {
|
||||
auth.init()
|
||||
localStorage.init()
|
||||
router.initialNavigation()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<ion-toolbar
|
||||
*ngIf="connection$ | async as connection"
|
||||
class="connection-toolbar"
|
||||
[color]="connection.color"
|
||||
>
|
||||
<div class="inline" slot="start">
|
||||
<ion-icon [name]="connection.icon" class="icon"></ion-icon>
|
||||
<p style="margin: 8px 0; font-weight: 600">{{ connection.message }}</p>
|
||||
<ion-spinner
|
||||
*ngIf="connection.dots"
|
||||
name="dots"
|
||||
color="light"
|
||||
class="ion-margin-start"
|
||||
></ion-spinner>
|
||||
</div>
|
||||
</ion-toolbar>
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { ConnectionBarComponent } from './connection-bar.component'
|
||||
|
||||
@NgModule({
|
||||
declarations: [ConnectionBarComponent],
|
||||
imports: [CommonModule, IonicModule],
|
||||
exports: [ConnectionBarComponent],
|
||||
})
|
||||
export class ConnectionBarComponentModule {}
|
||||
@@ -0,0 +1,9 @@
|
||||
.connection-toolbar {
|
||||
padding: 0 24px;
|
||||
--min-height: 36px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: 23px;
|
||||
padding-right: 12px;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { combineLatest, map, Observable, startWith } from 'rxjs'
|
||||
import { ConnectionService } from 'src/app/services/connection.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'connection-bar',
|
||||
templateUrl: './connection-bar.component.html',
|
||||
styleUrls: ['./connection-bar.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ConnectionBarComponent {
|
||||
private readonly websocket$ = this.connectionService.websocketConnected$
|
||||
|
||||
readonly connection$: Observable<{
|
||||
message: string
|
||||
color: string
|
||||
icon: string
|
||||
dots: boolean
|
||||
}> = combineLatest([
|
||||
this.connectionService.networkConnected$,
|
||||
this.websocket$.pipe(startWith(false)),
|
||||
this.patch
|
||||
.watch$('server-info', 'status-info')
|
||||
.pipe(startWith({ restarting: false, 'shutting-down': false })),
|
||||
]).pipe(
|
||||
map(([network, websocket, status]) => {
|
||||
if (!network)
|
||||
return {
|
||||
message: 'No Internet',
|
||||
color: 'danger',
|
||||
icon: 'cloud-offline-outline',
|
||||
dots: false,
|
||||
}
|
||||
if (!websocket)
|
||||
return {
|
||||
message: 'Connecting',
|
||||
color: 'warning',
|
||||
icon: 'cloud-offline-outline',
|
||||
dots: true,
|
||||
}
|
||||
if (status['shutting-down'])
|
||||
return {
|
||||
message: 'Shutting Down',
|
||||
color: 'dark',
|
||||
icon: 'power',
|
||||
dots: true,
|
||||
}
|
||||
if (status.restarting)
|
||||
return {
|
||||
message: 'Restarting',
|
||||
color: 'dark',
|
||||
icon: 'power',
|
||||
dots: true,
|
||||
}
|
||||
|
||||
return {
|
||||
message: 'Connected',
|
||||
color: 'success',
|
||||
icon: 'cloud-done',
|
||||
dots: false,
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
constructor(
|
||||
private readonly connectionService: ConnectionService,
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
) {}
|
||||
}
|
||||
33
web/projects/ui/src/app/app/footer/footer.component.html
Normal file
33
web/projects/ui/src/app/app/footer/footer.component.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<ion-toolbar
|
||||
*ngIf="progress$ | async as progress"
|
||||
color="light"
|
||||
[@heightCollapse]="animation"
|
||||
>
|
||||
<ion-list class="list">
|
||||
<!-- show progress -->
|
||||
<ng-container *ngIf="progress.size !== null; else calculating">
|
||||
<ion-list-header>
|
||||
<ion-label
|
||||
>Downloading:
|
||||
{{ getProgress(progress.size, progress.downloaded) }}%</ion-label
|
||||
>
|
||||
</ion-list-header>
|
||||
<ion-progress-bar
|
||||
class="progress"
|
||||
color="secondary"
|
||||
[value]="getProgress(progress.size, progress.downloaded) / 100"
|
||||
></ion-progress-bar>
|
||||
</ng-container>
|
||||
<!-- show calculating -->
|
||||
<ng-template #calculating>
|
||||
<ion-list-header>
|
||||
<ion-label>Calculating download size</ion-label>
|
||||
</ion-list-header>
|
||||
<ion-progress-bar
|
||||
class="progress"
|
||||
color="secondary"
|
||||
type="indeterminate"
|
||||
></ion-progress-bar>
|
||||
</ng-template>
|
||||
</ion-list>
|
||||
</ion-toolbar>
|
||||
9
web/projects/ui/src/app/app/footer/footer.component.scss
Normal file
9
web/projects/ui/src/app/app/footer/footer.component.scss
Normal file
@@ -0,0 +1,9 @@
|
||||
.list {
|
||||
box-shadow: inset 0 1px var(--ion-color-dark);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.progress {
|
||||
width: auto;
|
||||
margin: 0 16px 16px 16px;
|
||||
}
|
||||
32
web/projects/ui/src/app/app/footer/footer.component.ts
Normal file
32
web/projects/ui/src/app/app/footer/footer.component.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { map } from 'rxjs'
|
||||
import { heightCollapse } from 'src/app/util/animations'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'footer[appFooter]',
|
||||
templateUrl: 'footer.component.html',
|
||||
styleUrls: ['footer.component.scss'],
|
||||
animations: [heightCollapse],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class FooterComponent {
|
||||
readonly progress$ = this.patch
|
||||
.watch$('server-info', 'status-info', 'update-progress')
|
||||
.pipe(map(a => a && { ...a }))
|
||||
|
||||
readonly animation = {
|
||||
value: '',
|
||||
params: {
|
||||
duration: 1000,
|
||||
delay: 50,
|
||||
},
|
||||
}
|
||||
|
||||
constructor(private readonly patch: PatchDB<DataModel>) {}
|
||||
|
||||
getProgress(size: number, downloaded: number): number {
|
||||
return Math.round((100 * downloaded) / (size || 1))
|
||||
}
|
||||
}
|
||||
12
web/projects/ui/src/app/app/footer/footer.module.ts
Normal file
12
web/projects/ui/src/app/app/footer/footer.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
|
||||
import { FooterComponent } from './footer.component'
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, IonicModule],
|
||||
declarations: [FooterComponent],
|
||||
exports: [FooterComponent],
|
||||
})
|
||||
export class FooterModule {}
|
||||
64
web/projects/ui/src/app/app/menu/menu.component.html
Normal file
64
web/projects/ui/src/app/app/menu/menu.component.html
Normal file
@@ -0,0 +1,64 @@
|
||||
<a class="logo" routerLink="/home">
|
||||
<img alt="StartOS" src="assets/img/icon.png" />
|
||||
</a>
|
||||
<ion-item-group class="menu">
|
||||
<ion-menu-toggle *ngFor="let page of pages" auto-hide="false">
|
||||
<ion-item
|
||||
button
|
||||
class="link"
|
||||
routerLinkActive="link_selected"
|
||||
color="transparent"
|
||||
routerDirection="root"
|
||||
lines="none"
|
||||
detail="false"
|
||||
[routerLink]="page.url"
|
||||
>
|
||||
<ion-icon
|
||||
slot="start"
|
||||
class="icon label"
|
||||
routerLinkActive="label_selected"
|
||||
[name]="page.icon"
|
||||
></ion-icon>
|
||||
<ion-label class="label montserrat" routerLinkActive="label_selected">
|
||||
{{ page.title }}
|
||||
</ion-label>
|
||||
<ion-icon
|
||||
*ngIf="page.url === '/system' && (warning$ | async)"
|
||||
color="warning"
|
||||
size="small"
|
||||
name="warning"
|
||||
></ion-icon>
|
||||
<ion-icon
|
||||
*ngIf="page.url === '/system' && (showEOSUpdate$ | async)"
|
||||
color="success"
|
||||
size="small"
|
||||
name="rocket"
|
||||
></ion-icon>
|
||||
<ion-badge
|
||||
*ngIf="page.url === '/updates' && (updateCount$ | async) as updateCount"
|
||||
color="success"
|
||||
>
|
||||
{{ updateCount }}
|
||||
</ion-badge>
|
||||
<ion-badge
|
||||
*ngIf="
|
||||
page.url === '/notifications' &&
|
||||
(notificationCount$ | async) as notificaitonCount
|
||||
"
|
||||
color="danger"
|
||||
>
|
||||
{{ notificaitonCount }}
|
||||
</ion-badge>
|
||||
</ion-item>
|
||||
</ion-menu-toggle>
|
||||
</ion-item-group>
|
||||
<img
|
||||
appSnek
|
||||
class="snek"
|
||||
alt="Play Snake"
|
||||
src="assets/img/icons/snek.png"
|
||||
[appSnekHighScore]="(snekScore$ | async) || 0"
|
||||
/>
|
||||
<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;
|
||||
}
|
||||
131
web/projects/ui/src/app/app/menu/menu.component.ts
Normal file
131
web/projects/ui/src/app/app/menu/menu.component.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Inject,
|
||||
} from '@angular/core'
|
||||
import { EOSService } from 'src/app/services/eos.service'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import {
|
||||
combineLatest,
|
||||
filter,
|
||||
first,
|
||||
map,
|
||||
merge,
|
||||
Observable,
|
||||
of,
|
||||
pairwise,
|
||||
startWith,
|
||||
switchMap,
|
||||
} from 'rxjs'
|
||||
import { AbstractMarketplaceService } from '@start9labs/marketplace'
|
||||
import { MarketplaceService } from 'src/app/services/marketplace.service'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { SplitPaneTracker } from 'src/app/services/split-pane.service'
|
||||
import { Emver, THEME } from '@start9labs/shared'
|
||||
import { ConnectionService } from 'src/app/services/connection.service'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-menu',
|
||||
templateUrl: 'menu.component.html',
|
||||
styleUrls: ['menu.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class MenuComponent {
|
||||
readonly pages = [
|
||||
{
|
||||
title: 'Services',
|
||||
url: '/services',
|
||||
icon: 'grid-outline',
|
||||
},
|
||||
{
|
||||
title: 'Marketplace',
|
||||
url: '/marketplace',
|
||||
icon: 'storefront-outline',
|
||||
},
|
||||
{
|
||||
title: 'Updates',
|
||||
url: '/updates',
|
||||
icon: 'globe-outline',
|
||||
},
|
||||
{
|
||||
title: 'Backups',
|
||||
url: '/backups',
|
||||
icon: 'save-outline',
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
url: '/notifications',
|
||||
icon: 'notifications-outline',
|
||||
},
|
||||
{
|
||||
title: 'System',
|
||||
url: '/system',
|
||||
icon: 'construct-outline',
|
||||
},
|
||||
]
|
||||
|
||||
readonly notificationCount$ = this.patch.watch$(
|
||||
'server-info',
|
||||
'unread-notification-count',
|
||||
)
|
||||
|
||||
readonly snekScore$ = this.patch.watch$('ui', 'gaming', 'snake', 'high-score')
|
||||
|
||||
readonly showEOSUpdate$ = this.eosService.showUpdate$
|
||||
|
||||
private readonly local$ = this.connectionService.connected$.pipe(
|
||||
filter(Boolean),
|
||||
switchMap(() => this.patch.watch$('package-data').pipe(first())),
|
||||
switchMap(outer =>
|
||||
this.patch.watch$('package-data').pipe(
|
||||
pairwise(),
|
||||
filter(([prev, curr]) =>
|
||||
Object.values(prev).some(p => {
|
||||
const c = curr[p.manifest.id]
|
||||
return !c || (p['install-progress'] && !c['install-progress'])
|
||||
}),
|
||||
),
|
||||
map(([_, curr]) => curr),
|
||||
startWith(outer),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
readonly updateCount$: Observable<number> = combineLatest([
|
||||
this.marketplaceService.getMarketplace$(true),
|
||||
this.local$,
|
||||
]).pipe(
|
||||
map(([marketplace, local]) =>
|
||||
Object.entries(marketplace).reduce((list, [_, store]) => {
|
||||
store?.packages.forEach(({ manifest: { id, version } }) => {
|
||||
if (this.emver.compare(version, local[id]?.manifest.version) === 1)
|
||||
list.add(id)
|
||||
})
|
||||
return list
|
||||
}, new Set<string>()),
|
||||
),
|
||||
map(list => list.size),
|
||||
)
|
||||
|
||||
readonly sidebarOpen$ = this.splitPane.sidebarOpen$
|
||||
|
||||
readonly theme$ = inject(THEME)
|
||||
|
||||
readonly warning$ = merge(
|
||||
of(this.config.isTorHttp()),
|
||||
this.patch.watch$('server-info', 'ntp-synced').pipe(map(synced => !synced)),
|
||||
)
|
||||
|
||||
constructor(
|
||||
private readonly patch: PatchDB<DataModel>,
|
||||
private readonly eosService: EOSService,
|
||||
@Inject(AbstractMarketplaceService)
|
||||
private readonly marketplaceService: MarketplaceService,
|
||||
private readonly splitPane: SplitPaneTracker,
|
||||
private readonly emver: Emver,
|
||||
private readonly connectionService: ConnectionService,
|
||||
private readonly config: ConfigService,
|
||||
) {}
|
||||
}
|
||||
20
web/projects/ui/src/app/app/menu/menu.module.ts
Normal file
20
web/projects/ui/src/app/app/menu/menu.module.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { MenuComponent } from './menu.component'
|
||||
import { SnekModule } from '../snek/snek.module'
|
||||
import { ConnectionBarComponentModule } from '../connection-bar/connection-bar.component.module'
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
IonicModule,
|
||||
RouterModule,
|
||||
SnekModule,
|
||||
ConnectionBarComponentModule,
|
||||
],
|
||||
declarations: [MenuComponent],
|
||||
exports: [MenuComponent],
|
||||
})
|
||||
export class MenuModule {}
|
||||
@@ -0,0 +1,82 @@
|
||||
<div style="display: none">
|
||||
<!-- Ionicons -->
|
||||
<ion-icon *ngFor="let icon of icons" [name]="icon"></ion-icon>
|
||||
|
||||
<!-- 3rd party components -->
|
||||
<qr-code value="hello"></qr-code>
|
||||
|
||||
<!-- Ionic components -->
|
||||
<ion-accordion></ion-accordion>
|
||||
<ion-accordion-group></ion-accordion-group>
|
||||
<ion-action-sheet></ion-action-sheet>
|
||||
<ion-alert></ion-alert>
|
||||
<ion-avatar></ion-avatar>
|
||||
<ion-back-button></ion-back-button>
|
||||
<ion-badge></ion-badge>
|
||||
<ion-button></ion-button>
|
||||
<ion-buttons></ion-buttons>
|
||||
<ion-card></ion-card>
|
||||
<ion-card-content></ion-card-content>
|
||||
<ion-card-header></ion-card-header>
|
||||
<ion-checkbox></ion-checkbox>
|
||||
<ion-content></ion-content>
|
||||
<ion-footer></ion-footer>
|
||||
<ion-grid></ion-grid>
|
||||
<ion-header></ion-header>
|
||||
<ion-popover></ion-popover>
|
||||
<ion-content>
|
||||
<ion-refresher slot="fixed"></ion-refresher>
|
||||
<ion-refresher-content pullingContent="lines"></ion-refresher-content>
|
||||
<ion-infinite-scroll></ion-infinite-scroll>
|
||||
<ion-infinite-scroll-content
|
||||
loadingSpinner="lines"
|
||||
></ion-infinite-scroll-content>
|
||||
</ion-content>
|
||||
<ion-input></ion-input>
|
||||
<ion-item></ion-item>
|
||||
<ion-item-divider></ion-item-divider>
|
||||
<ion-item-group></ion-item-group>
|
||||
<ion-label></ion-label>
|
||||
<ion-label style="font-weight: bold"></ion-label>
|
||||
<ion-list></ion-list>
|
||||
<ion-loading></ion-loading>
|
||||
<ion-modal></ion-modal>
|
||||
<ion-menu-button></ion-menu-button>
|
||||
<ion-note></ion-note>
|
||||
<ion-progress-bar></ion-progress-bar>
|
||||
<ion-radio></ion-radio>
|
||||
<ion-row></ion-row>
|
||||
<ion-searchbar></ion-searchbar>
|
||||
<ion-segment></ion-segment>
|
||||
<ion-segment-button></ion-segment-button>
|
||||
<ion-select></ion-select>
|
||||
<ion-select-option></ion-select-option>
|
||||
<ion-spinner name="lines"></ion-spinner>
|
||||
<ion-text></ion-text>
|
||||
<ion-text><strong>load bold font</strong></ion-text>
|
||||
<ion-title></ion-title>
|
||||
<ion-toast></ion-toast>
|
||||
<ion-toggle></ion-toggle>
|
||||
<ion-toolbar></ion-toolbar>
|
||||
|
||||
<!-- images -->
|
||||
<img src="assets/img/icon.png" />
|
||||
<img src="assets/img/community-store.png" />
|
||||
<img src="assets/img/icons/snek.png" />
|
||||
<img src="assets/img/icons/wifi-1.png" />
|
||||
<img src="assets/img/icons/wifi-2.png" />
|
||||
<img src="assets/img/icons/wifi-3.png" />
|
||||
</div>
|
||||
|
||||
<div style="visibility: hidden; height: 0">
|
||||
<!-- fonts -->
|
||||
<p style="font-family: Courier New">a</p>
|
||||
<p style="font-family: Courier New; font-weight: bold">a</p>
|
||||
<p style="font-family: Montserrat">a</p>
|
||||
<p style="font-family: Montserrat; font-weight: bold">a</p>
|
||||
<p style="font-family: Montserrat; font-weight: 100">a</p>
|
||||
<p style="font-family: Open Sans">a</p>
|
||||
<p style="font-family: Open Sans; font-weight: bold">a</p>
|
||||
<p style="font-family: Open Sans; font-weight: 600">a</p>
|
||||
<p style="font-family: Open Sans; font-weight: 100">a</p>
|
||||
</div>
|
||||
106
web/projects/ui/src/app/app/preloader/preloader.component.ts
Normal file
106
web/projects/ui/src/app/app/preloader/preloader.component.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
|
||||
// TODO: Turn into DI token if this is needed someplace else too
|
||||
const ICONS = [
|
||||
'add',
|
||||
'alarm-outline',
|
||||
'alert-outline',
|
||||
'alert-circle-outline',
|
||||
'aperture-outline',
|
||||
'archive-outline',
|
||||
'arrow-back',
|
||||
'arrow-forward',
|
||||
'arrow-up',
|
||||
'brush-outline',
|
||||
'bookmark-outline',
|
||||
'cellular-outline',
|
||||
'chatbubbles-outline',
|
||||
'checkmark',
|
||||
'chevron-down',
|
||||
'chevron-up',
|
||||
'chevron-forward',
|
||||
'close',
|
||||
'close-circle-outline',
|
||||
'cloud-outline',
|
||||
'cloud-done',
|
||||
'cloud-done-outline',
|
||||
'cloud-download-outline',
|
||||
'cloud-offline-outline',
|
||||
'cloud-upload-outline',
|
||||
'code-outline',
|
||||
'color-wand-outline',
|
||||
'construct-outline',
|
||||
'copy-outline',
|
||||
'desktop-outline',
|
||||
'download-outline',
|
||||
'duplicate-outline',
|
||||
'earth-outline',
|
||||
'ellipsis-horizontal',
|
||||
'eye-off-outline',
|
||||
'eye-outline',
|
||||
'file-tray-stacked-outline',
|
||||
'finger-print-outline',
|
||||
'flash-outline',
|
||||
'flask-outline',
|
||||
'flash-off-outline',
|
||||
'folder-open-outline',
|
||||
'globe-outline',
|
||||
'grid-outline',
|
||||
'hammer-outline',
|
||||
'help-circle-outline',
|
||||
'hammer-outline',
|
||||
'information-circle-outline',
|
||||
'key-outline',
|
||||
'list-outline',
|
||||
'log-out-outline',
|
||||
'logo-bitcoin',
|
||||
'mail-outline',
|
||||
'map-outline',
|
||||
'medkit-outline',
|
||||
'notifications-outline',
|
||||
'open-outline',
|
||||
'options-outline',
|
||||
'pencil',
|
||||
'phone-portrait-outline',
|
||||
'play-circle-outline',
|
||||
'play-outline',
|
||||
'power',
|
||||
'pricetag-outline',
|
||||
'pulse',
|
||||
'push-outline',
|
||||
'qr-code-outline',
|
||||
'radio-outline',
|
||||
'receipt-outline',
|
||||
'refresh',
|
||||
'reload',
|
||||
'remove',
|
||||
'remove-circle-outline',
|
||||
'remove-outline',
|
||||
'repeat-outline',
|
||||
'ribbon-outline',
|
||||
'rocket-outline',
|
||||
'save-outline',
|
||||
'server-outline',
|
||||
'settings-outline',
|
||||
'shield-outline',
|
||||
'shuffle-outline',
|
||||
'stop-outline',
|
||||
'stopwatch-outline',
|
||||
'storefront-outline',
|
||||
'swap-vertical',
|
||||
'terminal-outline',
|
||||
'trail-sign-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 {}
|
||||
8
web/projects/ui/src/app/app/snek/snake.page.html
Normal file
8
web/projects/ui/src/app/app/snek/snake.page.html
Normal file
@@ -0,0 +1,8 @@
|
||||
<div class="canvas-center">
|
||||
<canvas id="game"></canvas>
|
||||
</div>
|
||||
<footer class="footer">
|
||||
<strong>Score: {{ score }}</strong>
|
||||
<span>High Score: {{ highScore }}</span>
|
||||
<button tuiButton (click)="dismiss()">Save and Quit</button>
|
||||
</footer>
|
||||
14
web/projects/ui/src/app/app/snek/snake.page.scss
Normal file
14
web/projects/ui/src/app/app/snek/snake.page.scss
Normal file
@@ -0,0 +1,14 @@
|
||||
.canvas-center {
|
||||
min-height: 50vh;
|
||||
padding-top: 20px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-top: 32px;
|
||||
}
|
||||
273
web/projects/ui/src/app/app/snek/snake.page.ts
Normal file
273
web/projects/ui/src/app/app/snek/snake.page.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
import {
|
||||
AfterViewInit,
|
||||
Component,
|
||||
HostListener,
|
||||
Inject,
|
||||
OnDestroy,
|
||||
} from '@angular/core'
|
||||
import { pauseFor } from '@start9labs/shared'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
|
||||
@Component({
|
||||
selector: 'snake',
|
||||
templateUrl: './snake.page.html',
|
||||
styleUrls: ['./snake.page.scss'],
|
||||
})
|
||||
export class SnakePage implements AfterViewInit, OnDestroy {
|
||||
highScore = this.dialog.data.highScore
|
||||
|
||||
score = 0
|
||||
|
||||
private readonly speed = 45
|
||||
private readonly width = 40
|
||||
private readonly height = 26
|
||||
private grid = NaN
|
||||
|
||||
private readonly startingLength = 4
|
||||
|
||||
private xDown?: number
|
||||
private yDown?: number
|
||||
private canvas!: HTMLCanvasElement
|
||||
private image!: HTMLImageElement
|
||||
private context!: CanvasRenderingContext2D
|
||||
|
||||
private snake: any
|
||||
private bitcoin: { x: number; y: number } = { x: NaN, y: NaN }
|
||||
|
||||
private moveQueue: String[] = []
|
||||
private destroyed = false
|
||||
|
||||
constructor(
|
||||
@Inject(DOCUMENT) private readonly document: Document,
|
||||
@Inject(POLYMORPHEUS_CONTEXT)
|
||||
private readonly dialog: TuiDialogContext<number, { highScore: number }>,
|
||||
) {}
|
||||
|
||||
dismiss() {
|
||||
this.dialog.completeWith(this.highScore)
|
||||
}
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
keyEvent(e: KeyboardEvent) {
|
||||
this.moveQueue.push(e.key)
|
||||
}
|
||||
|
||||
@HostListener('touchstart', ['$event'])
|
||||
touchStart(e: TouchEvent) {
|
||||
this.handleTouchStart(e)
|
||||
}
|
||||
|
||||
@HostListener('touchmove', ['$event'])
|
||||
touchMove(e: TouchEvent) {
|
||||
this.handleTouchMove(e)
|
||||
}
|
||||
|
||||
@HostListener('window:resize')
|
||||
sizeChange() {
|
||||
this.init()
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.destroyed = true
|
||||
}
|
||||
|
||||
ngAfterViewInit() {
|
||||
this.init()
|
||||
|
||||
this.image = new Image()
|
||||
this.image.onload = () => {
|
||||
requestAnimationFrame(async () => await this.loop())
|
||||
}
|
||||
this.image.src = '../../../../../../assets/img/icons/bitcoin.svg'
|
||||
}
|
||||
|
||||
init() {
|
||||
this.canvas = this.document.querySelector('canvas#game')!
|
||||
this.canvas.style.border = '1px solid #e0e0e0'
|
||||
this.context = this.canvas.getContext('2d')!
|
||||
const container = this.document.querySelector('.canvas-center')!
|
||||
this.grid = Math.min(
|
||||
Math.floor(container.clientWidth / this.width),
|
||||
Math.floor(container.clientHeight / this.height),
|
||||
)
|
||||
this.snake = {
|
||||
x: this.grid * (Math.floor(this.width / 2) - this.startingLength),
|
||||
y: this.grid * Math.floor(this.height / 2),
|
||||
// snake velocity. moves one grid length every frame in either the x or y direction
|
||||
dx: this.grid,
|
||||
dy: 0,
|
||||
// keep track of all grids the snake body occupies
|
||||
cells: [],
|
||||
// length of the snake. grows when eating an bitcoin
|
||||
maxCells: this.startingLength,
|
||||
}
|
||||
this.bitcoin = {
|
||||
x: this.getRandomInt(0, this.width) * this.grid,
|
||||
y: this.getRandomInt(0, this.height) * this.grid,
|
||||
}
|
||||
|
||||
this.canvas.width = this.grid * this.width
|
||||
this.canvas.height = this.grid * this.height
|
||||
this.context.imageSmoothingEnabled = false
|
||||
}
|
||||
|
||||
getTouches(evt: TouchEvent) {
|
||||
return evt.touches
|
||||
}
|
||||
|
||||
handleTouchStart(evt: TouchEvent) {
|
||||
const firstTouch = this.getTouches(evt)[0]
|
||||
this.xDown = firstTouch.clientX
|
||||
this.yDown = firstTouch.clientY
|
||||
}
|
||||
|
||||
handleTouchMove(evt: TouchEvent) {
|
||||
if (!this.xDown || !this.yDown) {
|
||||
return
|
||||
}
|
||||
|
||||
var xUp = evt.touches[0].clientX
|
||||
var yUp = evt.touches[0].clientY
|
||||
|
||||
var xDiff = this.xDown - xUp
|
||||
var yDiff = this.yDown - yUp
|
||||
|
||||
if (Math.abs(xDiff) > Math.abs(yDiff)) {
|
||||
/*most significant*/
|
||||
if (xDiff > 0) {
|
||||
this.moveQueue.push('ArrowLeft')
|
||||
} else {
|
||||
this.moveQueue.push('ArrowRight')
|
||||
}
|
||||
} else {
|
||||
if (yDiff > 0) {
|
||||
this.moveQueue.push('ArrowUp')
|
||||
} else {
|
||||
this.moveQueue.push('ArrowDown')
|
||||
}
|
||||
}
|
||||
/* reset values */
|
||||
this.xDown = undefined
|
||||
this.yDown = undefined
|
||||
}
|
||||
|
||||
// game loop
|
||||
async loop() {
|
||||
if (this.destroyed) return
|
||||
|
||||
await pauseFor(this.speed)
|
||||
|
||||
requestAnimationFrame(async () => await this.loop())
|
||||
|
||||
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height)
|
||||
|
||||
// move snake by its velocity
|
||||
this.snake.x += this.snake.dx
|
||||
this.snake.y += this.snake.dy
|
||||
|
||||
if (this.moveQueue.length) {
|
||||
const move = this.moveQueue.shift()
|
||||
// left arrow key
|
||||
if (move === 'ArrowLeft' && this.snake.dx === 0) {
|
||||
this.snake.dx = -this.grid
|
||||
this.snake.dy = 0
|
||||
}
|
||||
// up arrow key
|
||||
else if (move === 'ArrowUp' && this.snake.dy === 0) {
|
||||
this.snake.dy = -this.grid
|
||||
this.snake.dx = 0
|
||||
}
|
||||
// right arrow key
|
||||
else if (move === 'ArrowRight' && this.snake.dx === 0) {
|
||||
this.snake.dx = this.grid
|
||||
this.snake.dy = 0
|
||||
}
|
||||
// down arrow key
|
||||
else if (move === 'ArrowDown' && this.snake.dy === 0) {
|
||||
this.snake.dy = this.grid
|
||||
this.snake.dx = 0
|
||||
}
|
||||
}
|
||||
|
||||
// edge death
|
||||
if (
|
||||
this.snake.x < 0 ||
|
||||
this.snake.y < 0 ||
|
||||
this.snake.x >= this.canvas.width ||
|
||||
this.snake.y >= this.canvas.height
|
||||
) {
|
||||
this.death()
|
||||
}
|
||||
|
||||
// keep track of where snake has been. front of the array is always the head
|
||||
this.snake.cells.unshift({ x: this.snake.x, y: this.snake.y })
|
||||
|
||||
// remove cells as we move away from them
|
||||
if (this.snake.cells.length > this.snake.maxCells) {
|
||||
this.snake.cells.pop()
|
||||
}
|
||||
|
||||
// draw bitcoin
|
||||
this.context.fillStyle = '#ff4961'
|
||||
this.context.drawImage(
|
||||
this.image,
|
||||
this.bitcoin.x - 1,
|
||||
this.bitcoin.y - 1,
|
||||
this.grid + 2,
|
||||
this.grid + 2,
|
||||
)
|
||||
|
||||
// draw snake one cell at a time
|
||||
this.context.fillStyle = '#2fdf75'
|
||||
|
||||
const firstCell = this.snake.cells[0]
|
||||
|
||||
for (let index = 0; index < this.snake.cells.length; index++) {
|
||||
const cell = this.snake.cells[index]
|
||||
|
||||
// drawing 1 px smaller than the grid creates a grid effect in the snake body so you can see how long it is
|
||||
this.context.fillRect(cell.x, cell.y, this.grid - 1, this.grid - 1)
|
||||
|
||||
// snake ate bitcoin
|
||||
if (cell.x === this.bitcoin.x && cell.y === this.bitcoin.y) {
|
||||
this.score++
|
||||
this.highScore = Math.max(this.score, this.highScore)
|
||||
this.snake.maxCells++
|
||||
|
||||
this.bitcoin.x = this.getRandomInt(0, this.width) * this.grid
|
||||
this.bitcoin.y = this.getRandomInt(0, this.height) * this.grid
|
||||
}
|
||||
|
||||
if (index > 0) {
|
||||
// check collision with all cells after this one (modified bubble sort)
|
||||
// snake occupies same space as a body part. reset game
|
||||
if (
|
||||
firstCell.x === this.snake.cells[index].x &&
|
||||
firstCell.y === this.snake.cells[index].y
|
||||
) {
|
||||
this.death()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
death() {
|
||||
this.snake.x =
|
||||
this.grid * (Math.floor(this.width / 2) - this.startingLength)
|
||||
this.snake.y = this.grid * Math.floor(this.height / 2)
|
||||
this.snake.cells = []
|
||||
this.snake.maxCells = this.startingLength
|
||||
this.snake.dx = this.grid
|
||||
this.snake.dy = 0
|
||||
|
||||
this.bitcoin.x = this.getRandomInt(0, 25) * this.grid
|
||||
this.bitcoin.y = this.getRandomInt(0, 25) * this.grid
|
||||
this.score = 0
|
||||
}
|
||||
|
||||
getRandomInt(min: number, max: number) {
|
||||
return Math.floor(Math.random() * (max - min)) + min
|
||||
}
|
||||
}
|
||||
50
web/projects/ui/src/app/app/snek/snek.directive.ts
Normal file
50
web/projects/ui/src/app/app/snek/snek.directive.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Directive, HostListener, Input } from '@angular/core'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { filter } from 'rxjs'
|
||||
import { PolymorpheusComponent } from '@tinkoff/ng-polymorpheus'
|
||||
import { SnakePage } from './snake.page'
|
||||
|
||||
@Directive({
|
||||
selector: 'img[appSnek]',
|
||||
})
|
||||
export class SnekDirective {
|
||||
@Input()
|
||||
appSnekHighScore = 0
|
||||
|
||||
constructor(
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly embassyApi: ApiService,
|
||||
) {}
|
||||
|
||||
@HostListener('click')
|
||||
async onClick() {
|
||||
this.dialogs
|
||||
.open<number>(new PolymorpheusComponent(SnakePage), {
|
||||
label: 'Snake!',
|
||||
closeable: false,
|
||||
dismissible: false,
|
||||
data: {
|
||||
highScore: this.appSnekHighScore,
|
||||
},
|
||||
})
|
||||
.pipe(filter(score => score > this.appSnekHighScore))
|
||||
.subscribe(async score => {
|
||||
const loader = this.loader.open('Saving high score...').subscribe()
|
||||
|
||||
try {
|
||||
await this.embassyApi.setDbValue<number>(
|
||||
['gaming', 'snake', 'high-score'],
|
||||
score,
|
||||
)
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
14
web/projects/ui/src/app/app/snek/snek.module.ts
Normal file
14
web/projects/ui/src/app/app/snek/snek.module.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
|
||||
import { SnekDirective } from './snek.directive'
|
||||
import { SnakePage } from './snake.page'
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, IonicModule, TuiButtonModule],
|
||||
declarations: [SnekDirective, SnakePage],
|
||||
exports: [SnekDirective, SnakePage],
|
||||
})
|
||||
export class SnekModule {}
|
||||
32
web/projects/ui/src/app/apps/diagnostic/diagnostic.module.ts
Normal file
32
web/projects/ui/src/app/apps/diagnostic/diagnostic.module.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { WorkspaceConfig } from '@start9labs/shared'
|
||||
import { DiagnosticService } from './services/diagnostic.service'
|
||||
import { MockDiagnosticService } from './services/mock-diagnostic.service'
|
||||
import { LiveDiagnosticService } from './services/live-diagnostic.service'
|
||||
|
||||
const { useMocks } = require('../../../../../../config.json') as WorkspaceConfig
|
||||
|
||||
const ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
loadChildren: () =>
|
||||
import('./home/home.module').then(m => m.HomePageModule),
|
||||
},
|
||||
{
|
||||
path: 'logs',
|
||||
loadChildren: () =>
|
||||
import('./logs/logs.module').then(m => m.LogsPageModule),
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [RouterModule.forChild(ROUTES)],
|
||||
providers: [
|
||||
{
|
||||
provide: DiagnosticService,
|
||||
useClass: useMocks ? MockDiagnosticService : LiveDiagnosticService,
|
||||
},
|
||||
],
|
||||
})
|
||||
export class DiagnosticModule {}
|
||||
18
web/projects/ui/src/app/apps/diagnostic/home/home.module.ts
Normal file
18
web/projects/ui/src/app/apps/diagnostic/home/home.module.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { HomePage } from './home.page'
|
||||
|
||||
const ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: HomePage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, TuiButtonModule, RouterModule.forChild(ROUTES)],
|
||||
declarations: [HomePage],
|
||||
})
|
||||
export class HomePageModule {}
|
||||
53
web/projects/ui/src/app/apps/diagnostic/home/home.page.html
Normal file
53
web/projects/ui/src/app/apps/diagnostic/home/home.page.html
Normal file
@@ -0,0 +1,53 @@
|
||||
<ng-container *ngIf="!restarted; else refresh">
|
||||
<h1 class="title">StartOS - Diagnostic Mode</h1>
|
||||
|
||||
<ng-container *ngIf="error">
|
||||
<h2 class="subtitle">StartOS launch error:</h2>
|
||||
<code class="code warning">
|
||||
<p>{{ error.problem }}</p>
|
||||
<p *ngIf="error.details">{{ error.details }}</p>
|
||||
</code>
|
||||
|
||||
<a tuiButton routerLink="logs">View Logs</a>
|
||||
|
||||
<h2 class="subtitle">Possible solutions:</h2>
|
||||
<code class="code"><p>{{ error.solution }}</p></code>
|
||||
|
||||
<div class="buttons">
|
||||
<button tuiButton (click)="restart()">Restart Server</button>
|
||||
|
||||
<button
|
||||
*ngIf="error.code === 15 || error.code === 25"
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
(click)="forgetDrive()"
|
||||
>
|
||||
{{ error.code === 15 ? 'Setup Current Drive' : 'Enter Recovery Mode'}}
|
||||
</button>
|
||||
|
||||
<button
|
||||
tuiButton
|
||||
appearance="secondary-warning"
|
||||
(click)="presentAlertSystemRebuild()"
|
||||
>
|
||||
System Rebuild
|
||||
</button>
|
||||
|
||||
<button
|
||||
tuiButton
|
||||
appearance="secondary-destructive"
|
||||
(click)="presentAlertRepairDisk()"
|
||||
>
|
||||
Repair Drive
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #refresh>
|
||||
<h1 class="title">Server is restarting</h1>
|
||||
<h2 class="subtitle">
|
||||
Wait for the server to restart, then refresh this page.
|
||||
</h2>
|
||||
<button tuiButton (click)="refreshPage()">Refresh</button>
|
||||
</ng-template>
|
||||
35
web/projects/ui/src/app/apps/diagnostic/home/home.page.scss
Normal file
35
web/projects/ui/src/app/apps/diagnostic/home/home.page.scss
Normal file
@@ -0,0 +1,35 @@
|
||||
:host {
|
||||
display: block;
|
||||
padding: 32px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
padding-bottom: 24px;
|
||||
font-size: calc(2vw + 14px);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
padding-top: 16px;
|
||||
padding-bottom: 16px;
|
||||
font-size: calc(1vw + 12px);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.code {
|
||||
display: block;
|
||||
color: var(--tui-success-fill);
|
||||
background: rgb(69, 69, 69);
|
||||
padding: 1px 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--tui-warning-fill);
|
||||
}
|
||||
|
||||
.buttons {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
194
web/projects/ui/src/app/apps/diagnostic/home/home.page.ts
Normal file
194
web/projects/ui/src/app/apps/diagnostic/home/home.page.ts
Normal file
@@ -0,0 +1,194 @@
|
||||
import { Component, Inject } from '@angular/core'
|
||||
import { WINDOW } from '@ng-web-apis/common'
|
||||
import { LoadingService } from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { filter } from 'rxjs'
|
||||
import { DiagnosticService } from '../services/diagnostic.service'
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
templateUrl: 'home.page.html',
|
||||
styleUrls: ['home.page.scss'],
|
||||
})
|
||||
export class HomePage {
|
||||
restarted = false
|
||||
error?: {
|
||||
code: number
|
||||
problem: string
|
||||
solution: string
|
||||
details?: string
|
||||
}
|
||||
|
||||
constructor(
|
||||
private readonly loader: LoadingService,
|
||||
private readonly api: DiagnosticService,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
@Inject(WINDOW) private readonly window: Window,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
const error = await this.api.getError()
|
||||
// incorrect drive
|
||||
if (error.code === 15) {
|
||||
this.error = {
|
||||
code: 15,
|
||||
problem: 'Unknown storage drive detected',
|
||||
solution:
|
||||
'To use a different storage drive, replace the current one and click RESTART SERVER below. To use the current storage drive, click USE CURRENT DRIVE below, then follow instructions. No data will be erased during this process.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
// no drive
|
||||
} else if (error.code === 20) {
|
||||
this.error = {
|
||||
code: 20,
|
||||
problem: 'Storage drive not found',
|
||||
solution:
|
||||
'Insert your StartOS storage drive and click RESTART SERVER below.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
// drive corrupted
|
||||
} else if (error.code === 25) {
|
||||
this.error = {
|
||||
code: 25,
|
||||
problem:
|
||||
'Storage drive corrupted. This could be the result of data corruption or physical damage.',
|
||||
solution:
|
||||
'It may or may not be possible to re-use this drive by reformatting and recovering from backup. To enter recovery mode, click ENTER RECOVERY MODE below, then follow instructions. No data will be erased during this step.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
// filesystem I/O error - disk needs repair
|
||||
} else if (error.code === 2) {
|
||||
this.error = {
|
||||
code: 2,
|
||||
problem: 'Filesystem I/O error.',
|
||||
solution:
|
||||
'Repairing the disk could help resolve this issue. Please DO NOT unplug the drive or server during this time or the situation will become worse.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
// disk management error - disk needs repair
|
||||
} else if (error.code === 48) {
|
||||
this.error = {
|
||||
code: 48,
|
||||
problem: 'Disk management error.',
|
||||
solution:
|
||||
'Repairing the disk could help resolve this issue. Please DO NOT unplug the drive or server during this time or the situation will become worse.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
} else {
|
||||
this.error = {
|
||||
code: error.code,
|
||||
problem: error.message,
|
||||
solution: 'Please contact support.',
|
||||
details: error.data?.details,
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
async restart(): Promise<void> {
|
||||
const loader = this.loader.open('').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.restart()
|
||||
this.restarted = true
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
async forgetDrive(): Promise<void> {
|
||||
const loader = this.loader.open('').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.forgetDrive()
|
||||
await this.api.restart()
|
||||
this.restarted = true
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
async presentAlertSystemRebuild() {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
no: 'Cancel',
|
||||
yes: 'Rebuild',
|
||||
content:
|
||||
'<p>This action will tear down all service containers and rebuild them from scratch. No data will be deleted.</p><p>A system rebuild can be useful if your system gets into a bad state, and it should only be performed if you are experiencing general performance or reliability issues.</p><p>It may take up to an hour to complete. During this time, you will lose all connectivity to your Start9 server.</p>',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => {
|
||||
try {
|
||||
this.systemRebuild()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async presentAlertRepairDisk() {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
no: 'Cancel',
|
||||
yes: 'Repair',
|
||||
content:
|
||||
'<p>This action should only be executed if directed by a Start9 support specialist.</p><p>If anything happens to the device during the reboot, such as losing power or unplugging the drive, the filesystem <i>will</i> be in an unrecoverable state. Please proceed with caution.</p>',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => {
|
||||
try {
|
||||
this.repairDisk()
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
refreshPage(): void {
|
||||
this.window.location.reload()
|
||||
}
|
||||
|
||||
private async systemRebuild(): Promise<void> {
|
||||
const loader = this.loader.open('').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.systemRebuild()
|
||||
await this.api.restart()
|
||||
this.restarted = true
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async repairDisk(): Promise<void> {
|
||||
const loader = this.loader.open('').subscribe()
|
||||
|
||||
try {
|
||||
await this.api.repairDisk()
|
||||
await this.api.restart()
|
||||
this.restarted = true
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
18
web/projects/ui/src/app/apps/diagnostic/logs/logs.module.ts
Normal file
18
web/projects/ui/src/app/apps/diagnostic/logs/logs.module.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Routes, RouterModule } from '@angular/router'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { LogsPage } from './logs.page'
|
||||
|
||||
const ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: LogsPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [CommonModule, IonicModule, RouterModule.forChild(ROUTES)],
|
||||
declarations: [LogsPage],
|
||||
})
|
||||
export class LogsPageModule {}
|
||||
57
web/projects/ui/src/app/apps/diagnostic/logs/logs.page.html
Normal file
57
web/projects/ui/src/app/apps/diagnostic/logs/logs.page.html
Normal file
@@ -0,0 +1,57 @@
|
||||
<ion-header>
|
||||
<ion-toolbar>
|
||||
<ion-buttons slot="start">
|
||||
<ion-back-button defaultHref="/"></ion-back-button>
|
||||
</ion-buttons>
|
||||
<ion-title>Logs</ion-title>
|
||||
</ion-toolbar>
|
||||
</ion-header>
|
||||
|
||||
<ion-content
|
||||
[scrollEvents]="true"
|
||||
(ionScrollEnd)="scrollEnd()"
|
||||
class="ion-padding"
|
||||
>
|
||||
<ion-infinite-scroll
|
||||
id="scroller"
|
||||
*ngIf="!loading && needInfinite"
|
||||
position="top"
|
||||
threshold="0"
|
||||
(ionInfinite)="doInfinite($event)"
|
||||
>
|
||||
<ion-infinite-scroll-content
|
||||
loadingSpinner="lines"
|
||||
></ion-infinite-scroll-content>
|
||||
</ion-infinite-scroll>
|
||||
|
||||
<div id="container">
|
||||
<div id="template" style="white-space: pre-line"></div>
|
||||
</div>
|
||||
|
||||
<div id="bottom-div"></div>
|
||||
|
||||
<div
|
||||
[ngStyle]="{
|
||||
'position': 'fixed',
|
||||
'bottom': '50px',
|
||||
'right': isOnBottom ? '-52px' : '30px',
|
||||
'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()"
|
||||
strong
|
||||
>
|
||||
<ion-icon name="chevron-down"></ion-icon>
|
||||
</ion-button>
|
||||
</div>
|
||||
</ion-content>
|
||||
94
web/projects/ui/src/app/apps/diagnostic/logs/logs.page.ts
Normal file
94
web/projects/ui/src/app/apps/diagnostic/logs/logs.page.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { Component, ViewChild } from '@angular/core'
|
||||
import { IonContent } from '@ionic/angular'
|
||||
import { ErrorService, toLocalIsoString } from '@start9labs/shared'
|
||||
import { DiagnosticService } from '../services/diagnostic.service'
|
||||
|
||||
const Convert = require('ansi-to-html')
|
||||
const convert = new Convert({
|
||||
bg: 'transparent',
|
||||
})
|
||||
|
||||
@Component({
|
||||
selector: 'logs',
|
||||
templateUrl: './logs.page.html',
|
||||
})
|
||||
export class LogsPage {
|
||||
@ViewChild(IonContent) private content?: IonContent
|
||||
loading = true
|
||||
needInfinite = true
|
||||
startCursor?: string
|
||||
limit = 200
|
||||
isOnBottom = true
|
||||
|
||||
constructor(
|
||||
private readonly api: DiagnosticService,
|
||||
private readonly errorService: ErrorService,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.getLogs()
|
||||
this.loading = false
|
||||
}
|
||||
|
||||
scrollEnd() {
|
||||
const bottomDiv = document.getElementById('bottom-div')
|
||||
this.isOnBottom =
|
||||
!!bottomDiv &&
|
||||
bottomDiv.getBoundingClientRect().top - 420 < window.innerHeight
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
this.content?.scrollToBottom(500)
|
||||
}
|
||||
|
||||
async doInfinite(e: any): Promise<void> {
|
||||
await this.getLogs()
|
||||
e.target.complete()
|
||||
}
|
||||
|
||||
private async getLogs() {
|
||||
try {
|
||||
const { 'start-cursor': startCursor, entries } = await this.api.getLogs({
|
||||
cursor: this.startCursor,
|
||||
before: !!this.startCursor,
|
||||
limit: this.limit,
|
||||
})
|
||||
|
||||
if (!entries.length) return
|
||||
|
||||
this.startCursor = startCursor
|
||||
|
||||
const container = document.getElementById('container')
|
||||
const newLogs = document.getElementById('template')?.cloneNode(true)
|
||||
|
||||
if (!(newLogs instanceof HTMLElement)) return
|
||||
|
||||
newLogs.innerHTML = entries
|
||||
.map(
|
||||
entry =>
|
||||
`<b>${toLocalIsoString(
|
||||
new Date(entry.timestamp),
|
||||
)}</b> ${convert.toHtml(entry.message)}`,
|
||||
)
|
||||
.join('\n')
|
||||
|
||||
const beforeContainerHeight = container?.scrollHeight || 0
|
||||
container?.prepend(newLogs)
|
||||
const afterContainerHeight = container?.scrollHeight || 0
|
||||
|
||||
// scroll down
|
||||
setTimeout(() => {
|
||||
this.content?.scrollToPoint(
|
||||
0,
|
||||
afterContainerHeight - beforeContainerHeight,
|
||||
)
|
||||
}, 50)
|
||||
|
||||
if (entries.length < this.limit) {
|
||||
this.needInfinite = false
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { LogsRes, ServerLogsReq } from '@start9labs/shared'
|
||||
|
||||
export abstract class DiagnosticService {
|
||||
abstract getError(): Promise<GetErrorRes>
|
||||
abstract restart(): Promise<void>
|
||||
abstract forgetDrive(): Promise<void>
|
||||
abstract repairDisk(): Promise<void>
|
||||
abstract systemRebuild(): Promise<void>
|
||||
abstract getLogs(params: ServerLogsReq): Promise<LogsRes>
|
||||
}
|
||||
|
||||
export interface GetErrorRes {
|
||||
code: number
|
||||
message: string
|
||||
data: { details: string }
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import {
|
||||
HttpService,
|
||||
isRpcError,
|
||||
RpcError,
|
||||
RPCOptions,
|
||||
} from '@start9labs/shared'
|
||||
import { LogsRes, ServerLogsReq } from '@start9labs/shared'
|
||||
import { DiagnosticService, GetErrorRes } from './diagnostic.service'
|
||||
|
||||
@Injectable()
|
||||
export class LiveDiagnosticService implements DiagnosticService {
|
||||
constructor(private readonly http: HttpService) {}
|
||||
|
||||
async getError(): Promise<GetErrorRes> {
|
||||
return this.rpcRequest<GetErrorRes>({
|
||||
method: 'diagnostic.error',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async restart(): Promise<void> {
|
||||
return this.rpcRequest<void>({
|
||||
method: 'diagnostic.restart',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async forgetDrive(): Promise<void> {
|
||||
return this.rpcRequest<void>({
|
||||
method: 'diagnostic.disk.forget',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async repairDisk(): Promise<void> {
|
||||
return this.rpcRequest<void>({
|
||||
method: 'diagnostic.disk.repair',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async systemRebuild(): Promise<void> {
|
||||
return this.rpcRequest<void>({
|
||||
method: 'diagnostic.rebuild',
|
||||
params: {},
|
||||
})
|
||||
}
|
||||
|
||||
async getLogs(params: ServerLogsReq): Promise<LogsRes> {
|
||||
return this.rpcRequest<LogsRes>({
|
||||
method: 'diagnostic.logs',
|
||||
params,
|
||||
})
|
||||
}
|
||||
|
||||
private async rpcRequest<T>(opts: RPCOptions): Promise<T> {
|
||||
const res = await this.http.rpcRequest<T>(opts)
|
||||
|
||||
const rpcRes = res.body
|
||||
|
||||
if (isRpcError(rpcRes)) {
|
||||
throw new RpcError(rpcRes.error)
|
||||
}
|
||||
|
||||
return rpcRes.result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { pauseFor } from '@start9labs/shared'
|
||||
import { LogsRes, ServerLogsReq, Log } from '@start9labs/shared'
|
||||
import { DiagnosticService, GetErrorRes } from './diagnostic.service'
|
||||
|
||||
@Injectable()
|
||||
export class MockDiagnosticService implements DiagnosticService {
|
||||
async getError(): Promise<GetErrorRes> {
|
||||
await pauseFor(1000)
|
||||
return {
|
||||
code: 15,
|
||||
message: 'Unknown server',
|
||||
data: { details: 'Some details about the error here' },
|
||||
}
|
||||
}
|
||||
|
||||
async restart(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
|
||||
async forgetDrive(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
|
||||
async repairDisk(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
|
||||
async systemRebuild(): Promise<void> {
|
||||
await pauseFor(1000)
|
||||
}
|
||||
|
||||
async getLogs(params: ServerLogsReq): Promise<LogsRes> {
|
||||
await pauseFor(1000)
|
||||
let entries: Log[]
|
||||
if (Math.random() < 0.2) {
|
||||
entries = packageLogs
|
||||
} else {
|
||||
const arrLength = params.limit
|
||||
? Math.ceil(params.limit / packageLogs.length)
|
||||
: 10
|
||||
entries = new Array(arrLength)
|
||||
.fill(packageLogs)
|
||||
.reduce((acc, val) => acc.concat(val), [])
|
||||
}
|
||||
return {
|
||||
entries,
|
||||
'start-cursor': 'startCursor',
|
||||
'end-cursor': 'endCursor',
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const packageLogs = [
|
||||
{
|
||||
timestamp: '2019-12-26T14:20:30.872Z',
|
||||
message: '****** START *****',
|
||||
},
|
||||
{
|
||||
timestamp: '2019-12-26T14:21:30.872Z',
|
||||
message: 'ServerLogs ServerLogs ServerLogs ServerLogs ServerLogs',
|
||||
},
|
||||
{
|
||||
timestamp: '2019-12-26T14:22:30.872Z',
|
||||
message: '****** FINISH *****',
|
||||
},
|
||||
]
|
||||
17
web/projects/ui/src/app/apps/loading/loading.module.ts
Normal file
17
web/projects/ui/src/app/apps/loading/loading.module.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { InitializingModule } from '@start9labs/shared'
|
||||
import { LoadingPage } from './loading.page'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: LoadingPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [InitializingModule, RouterModule.forChild(routes)],
|
||||
declarations: [LoadingPage],
|
||||
})
|
||||
export class LoadingPageModule {}
|
||||
4
web/projects/ui/src/app/apps/loading/loading.page.html
Normal file
4
web/projects/ui/src/app/apps/loading/loading.page.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<app-initializing
|
||||
class="ion-page"
|
||||
(finished)="navCtrl.navigateForward('/login')"
|
||||
></app-initializing>
|
||||
19
web/projects/ui/src/app/apps/loading/loading.page.ts
Normal file
19
web/projects/ui/src/app/apps/loading/loading.page.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { NavController } from '@ionic/angular'
|
||||
import {
|
||||
provideSetupLogsService,
|
||||
provideSetupService,
|
||||
} from '@start9labs/shared'
|
||||
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
|
||||
@Component({
|
||||
templateUrl: 'loading.page.html',
|
||||
providers: [
|
||||
provideSetupService(ApiService),
|
||||
provideSetupLogsService(ApiService),
|
||||
],
|
||||
})
|
||||
export class LoadingPage {
|
||||
readonly navCtrl = inject(NavController)
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<div class="center-container">
|
||||
<ng-container *ngIf="!caTrusted; else trusted">
|
||||
<ion-card id="untrusted" class="text-center">
|
||||
<ion-icon name="lock-closed-outline" class="wiz-icon"></ion-icon>
|
||||
<h1>Trust Your Root CA</h1>
|
||||
<p>
|
||||
Download and trust your server's Root Certificate Authority to establish
|
||||
a secure (HTTPS) connection. You will need to repeat this on every
|
||||
device you use to connect to your server.
|
||||
</p>
|
||||
<ol>
|
||||
<li>
|
||||
<b>Bookmark this page</b>
|
||||
- Save this page so you can access it later. You can also find the
|
||||
address in the
|
||||
<code>StartOS-info.html</code>
|
||||
file downloaded at the end of initial setup.
|
||||
</li>
|
||||
<li>
|
||||
<b>Download your server's Root CA</b>
|
||||
- Your server uses its Root CA to generate SSL/TLS certificates for
|
||||
itself and installed services. These certificates are then used to
|
||||
encrypt network traffic with your client devices.
|
||||
<br />
|
||||
<ion-button
|
||||
strong
|
||||
size="small"
|
||||
shape="round"
|
||||
color="tertiary"
|
||||
(click)="download()"
|
||||
>
|
||||
Download
|
||||
<ion-icon slot="end" name="download-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</li>
|
||||
<li>
|
||||
<b>Trust your server's Root CA</b>
|
||||
- Follow instructions for your OS. By trusting your server's Root CA,
|
||||
your device can verify the authenticity of encrypted communications
|
||||
with your server.
|
||||
<br />
|
||||
<ion-button
|
||||
strong
|
||||
size="small"
|
||||
shape="round"
|
||||
color="primary"
|
||||
href="https://docs.start9.com/0.3.5.x/user-manual/trust-ca#establishing-trust"
|
||||
target="_blank"
|
||||
noreferrer
|
||||
>
|
||||
View Instructions
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</li>
|
||||
<li>
|
||||
<b>Test</b>
|
||||
- Refresh the page. If refreshing the page does not work, you may need
|
||||
to quit and re-open your browser, then revisit this page.
|
||||
<br />
|
||||
<ion-button
|
||||
strong
|
||||
size="small"
|
||||
shape="round"
|
||||
class="refresh"
|
||||
(click)="refresh()"
|
||||
>
|
||||
Refresh
|
||||
<ion-icon slot="end" name="refresh"></ion-icon>
|
||||
</ion-button>
|
||||
</li>
|
||||
</ol>
|
||||
<ion-button fill="clear" (click)="launchHttps()" [disabled]="caTrusted">
|
||||
Skip
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-button>
|
||||
<span class="skip_detail">(not recommended)</span>
|
||||
</ion-card>
|
||||
</ng-container>
|
||||
|
||||
<ng-template #trusted>
|
||||
<ion-card id="trusted" class="text-center">
|
||||
<ion-icon
|
||||
name="shield-checkmark-outline"
|
||||
class="wiz-icon"
|
||||
color="success"
|
||||
></ion-icon>
|
||||
<h1>Root CA Trusted!</h1>
|
||||
<p>
|
||||
You have successfully trusted your server's Root CA and may now log in
|
||||
securely.
|
||||
</p>
|
||||
<ion-button strong (click)="launchHttps()" color="tertiary" shape="round">
|
||||
Go to login
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-card>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
<a
|
||||
id="install-cert"
|
||||
href="/eos/local.crt"
|
||||
[download]="
|
||||
config.isLocal() ? document.location.hostname + '.crt' : 'startos.crt'
|
||||
"
|
||||
></a>
|
||||
@@ -0,0 +1,83 @@
|
||||
#trusted {
|
||||
max-width: 40%;
|
||||
}
|
||||
|
||||
#untrusted {
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.center-container {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
ion-card {
|
||||
color: var(--ion-color-dark);
|
||||
background: #414141;
|
||||
box-shadow: 0 4px 4px rgba(17, 17, 17, 0.144);
|
||||
border-radius: 35px;
|
||||
padding: 1.5rem;
|
||||
width: 100%;
|
||||
|
||||
h1 {
|
||||
font-weight: bold;
|
||||
font-size: 1.5rem;
|
||||
padding-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 21px;
|
||||
line-height: 25px;
|
||||
margin-bottom: 30px;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
ol {
|
||||
font-size: 17px;
|
||||
line-height: 25px;
|
||||
text-align: left;
|
||||
|
||||
li {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
ion-button {
|
||||
margin-top: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.refresh {
|
||||
--background: var(--ion-color-success-shade);
|
||||
}
|
||||
|
||||
.wiz-icon {
|
||||
font-size: 64px;
|
||||
}
|
||||
|
||||
.skip_detail {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
margin-top: -13px;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
#trusted, #untrusted {
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 701px) and (max-width: 1200px) {
|
||||
#trusted, #untrusted {
|
||||
max-width: 75%;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Component, Inject } from '@angular/core'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { RELATIVE_URL } from '@start9labs/shared'
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
import { WINDOW } from '@ng-web-apis/common'
|
||||
|
||||
@Component({
|
||||
selector: 'ca-wizard',
|
||||
templateUrl: './ca-wizard.component.html',
|
||||
styleUrls: ['./ca-wizard.component.scss'],
|
||||
})
|
||||
export class CAWizardComponent {
|
||||
caTrusted = false
|
||||
|
||||
constructor(
|
||||
private readonly api: ApiService,
|
||||
public readonly config: ConfigService,
|
||||
@Inject(RELATIVE_URL) private readonly relativeUrl: string,
|
||||
@Inject(DOCUMENT) public readonly document: Document,
|
||||
@Inject(WINDOW) private readonly windowRef: Window,
|
||||
) {}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.testHttps().catch(e =>
|
||||
console.warn('Failed Https connection attempt'),
|
||||
)
|
||||
}
|
||||
|
||||
download() {
|
||||
this.document.getElementById('install-cert')?.click()
|
||||
}
|
||||
|
||||
refresh() {
|
||||
this.document.location.reload()
|
||||
}
|
||||
|
||||
launchHttps() {
|
||||
const host = this.config.getHost()
|
||||
this.windowRef.open(`https://${host}`, '_self')
|
||||
}
|
||||
|
||||
private async testHttps() {
|
||||
const url = `https://${this.document.location.host}${this.relativeUrl}`
|
||||
await this.api.echo({ message: 'ping' }, url).then(() => {
|
||||
this.caTrusted = true
|
||||
})
|
||||
}
|
||||
}
|
||||
30
web/projects/ui/src/app/apps/login/login.module.ts
Normal file
30
web/projects/ui/src/app/apps/login/login.module.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { IonicModule } from '@ionic/angular'
|
||||
import { LoginPage } from './login.page'
|
||||
import { CAWizardComponent } from './ca-wizard/ca-wizard.component'
|
||||
import { SharedPipesModule } from '@start9labs/shared'
|
||||
import { TuiHintModule, TuiTooltipModule } from '@taiga-ui/core'
|
||||
|
||||
const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: LoginPage,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
IonicModule,
|
||||
SharedPipesModule,
|
||||
RouterModule.forChild(routes),
|
||||
TuiTooltipModule,
|
||||
TuiHintModule,
|
||||
],
|
||||
declarations: [LoginPage, CAWizardComponent],
|
||||
})
|
||||
export class LoginPageModule {}
|
||||
93
web/projects/ui/src/app/apps/login/login.page.html
Normal file
93
web/projects/ui/src/app/apps/login/login.page.html
Normal file
@@ -0,0 +1,93 @@
|
||||
<ion-content class="content">
|
||||
<!-- Local HTTP -->
|
||||
<ng-container *ngIf="config.isLanHttp(); else notLanHttp">
|
||||
<ca-wizard></ca-wizard>
|
||||
</ng-container>
|
||||
|
||||
<!-- not Local HTTP -->
|
||||
<ng-template #notLanHttp>
|
||||
<div *ngIf="config.isTorHttp()" class="banner">
|
||||
<ion-item color="warning">
|
||||
<ion-icon slot="start" name="warning-outline"></ion-icon>
|
||||
<ion-label>
|
||||
<h2 style="font-weight: bold">Http detected</h2>
|
||||
<p style="font-weight: 600">
|
||||
Tor is faster over https. Your Root CA must be trusted.
|
||||
<a
|
||||
href="https://docs.start9.com/0.3.5.x/user-manual/trust-ca"
|
||||
target="_blank"
|
||||
noreferrer
|
||||
style="color: black"
|
||||
>
|
||||
View instructions
|
||||
</a>
|
||||
</p>
|
||||
</ion-label>
|
||||
<ion-button slot="end" color="light" (click)="launchHttps()">
|
||||
Open Https
|
||||
<ion-icon slot="end" name="open-outline"></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
</div>
|
||||
|
||||
<ion-grid class="grid">
|
||||
<ion-row class="row">
|
||||
<ion-col>
|
||||
<ion-card>
|
||||
<img
|
||||
alt="StartOS Icon"
|
||||
class="header-icon"
|
||||
src="assets/img/icon.png"
|
||||
/>
|
||||
<ion-card-header>
|
||||
<ion-card-title class="title">Login to StartOS</ion-card-title>
|
||||
</ion-card-header>
|
||||
<ion-card-content class="ion-margin">
|
||||
<form (submit)="submit()">
|
||||
<ion-item color="dark" fill="solid">
|
||||
<ion-icon
|
||||
slot="start"
|
||||
size="small"
|
||||
color="base"
|
||||
name="key-outline"
|
||||
style="margin-right: 16px"
|
||||
></ion-icon>
|
||||
<ion-input
|
||||
name="password"
|
||||
placeholder="Password"
|
||||
[type]="unmasked ? 'text' : 'password'"
|
||||
[(ngModel)]="password"
|
||||
(ionChange)="error = ''"
|
||||
></ion-input>
|
||||
<ion-button
|
||||
slot="end"
|
||||
fill="clear"
|
||||
color="dark"
|
||||
(click)="unmasked = !unmasked"
|
||||
>
|
||||
<ion-icon
|
||||
slot="icon-only"
|
||||
size="small"
|
||||
[name]="unmasked ? 'eye-off-outline' : 'eye-outline'"
|
||||
></ion-icon>
|
||||
</ion-button>
|
||||
</ion-item>
|
||||
<p class="error ion-text-center">
|
||||
<ion-text color="danger">{{ error }}</ion-text>
|
||||
</p>
|
||||
<ion-button
|
||||
class="login-button"
|
||||
type="submit"
|
||||
expand="block"
|
||||
color="tertiary"
|
||||
>
|
||||
Login
|
||||
</ion-button>
|
||||
</form>
|
||||
</ion-card-content>
|
||||
</ion-card>
|
||||
</ion-col>
|
||||
</ion-row>
|
||||
</ion-grid>
|
||||
</ng-template>
|
||||
</ion-content>
|
||||
76
web/projects/ui/src/app/apps/login/login.page.scss
Normal file
76
web/projects/ui/src/app/apps/login/login.page.scss
Normal file
@@ -0,0 +1,76 @@
|
||||
.content {
|
||||
--background: #333333;
|
||||
}
|
||||
|
||||
.grid {
|
||||
height: 100%;
|
||||
max-width: 540px;
|
||||
}
|
||||
|
||||
.row {
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.banner {
|
||||
position: absolute;
|
||||
padding: 20px;
|
||||
width: 100%;
|
||||
display: inline-block;
|
||||
|
||||
ion-item {
|
||||
max-width: 800px;
|
||||
margin: auto;
|
||||
}
|
||||
}
|
||||
|
||||
ion-card {
|
||||
background: #414141;
|
||||
box-shadow: 0 4px 4px rgba(17, 17, 17, 0.144);
|
||||
border-radius: 35px;
|
||||
min-height: 16rem;
|
||||
contain: unset;
|
||||
overflow: unset;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
ion-item {
|
||||
--background: transparent;
|
||||
--border-radius: 0px;
|
||||
}
|
||||
|
||||
.title {
|
||||
padding-top: 55px;
|
||||
color: #e0e0e0;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
&-icon {
|
||||
width: 100px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-left: -50px;
|
||||
top: -17%;
|
||||
z-index: 100;
|
||||
}
|
||||
}
|
||||
|
||||
.login-button {
|
||||
height: 45px;
|
||||
width: 120px;
|
||||
--border-radius: 50px;
|
||||
margin: 0 auto;
|
||||
margin-top: 27px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.item-interactive {
|
||||
--highlight-background: #5260ff !important;
|
||||
}
|
||||
|
||||
.error {
|
||||
display: block;
|
||||
padding-top: 4px;
|
||||
}
|
||||
69
web/projects/ui/src/app/apps/login/login.page.ts
Normal file
69
web/projects/ui/src/app/apps/login/login.page.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Component, Inject } from '@angular/core'
|
||||
import { getPlatforms } from '@ionic/angular'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { AuthService } from 'src/app/services/auth.service'
|
||||
import { Router } from '@angular/router'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { LoadingService } from '@start9labs/shared'
|
||||
import { TuiDestroyService } from '@taiga-ui/cdk'
|
||||
import { takeUntil } from 'rxjs'
|
||||
import { DOCUMENT } from '@angular/common'
|
||||
import { WINDOW } from '@ng-web-apis/common'
|
||||
|
||||
@Component({
|
||||
selector: 'login',
|
||||
templateUrl: './login.page.html',
|
||||
styleUrls: ['./login.page.scss'],
|
||||
providers: [TuiDestroyService],
|
||||
})
|
||||
export class LoginPage {
|
||||
password = ''
|
||||
unmasked = false
|
||||
error = ''
|
||||
|
||||
constructor(
|
||||
private readonly destroy$: TuiDestroyService,
|
||||
private readonly router: Router,
|
||||
private readonly authService: AuthService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly api: ApiService,
|
||||
public readonly config: ConfigService,
|
||||
@Inject(DOCUMENT) public readonly document: Document,
|
||||
@Inject(WINDOW) private readonly windowRef: Window,
|
||||
) {}
|
||||
|
||||
launchHttps() {
|
||||
const host = this.config.getHost()
|
||||
this.windowRef.open(`https://${host}`, '_self')
|
||||
}
|
||||
|
||||
async submit() {
|
||||
this.error = ''
|
||||
|
||||
const loader = this.loader
|
||||
.open('Logging in...')
|
||||
.pipe(takeUntil(this.destroy$))
|
||||
.subscribe()
|
||||
|
||||
try {
|
||||
this.document.cookie = ''
|
||||
if (this.password.length > 64) {
|
||||
this.error = 'Password must be less than 65 characters'
|
||||
return
|
||||
}
|
||||
await this.api.login({
|
||||
password: this.password,
|
||||
metadata: { platforms: getPlatforms() },
|
||||
})
|
||||
|
||||
this.password = ''
|
||||
this.authService.setVerified()
|
||||
this.router.navigate([''], { replaceUrl: true })
|
||||
} catch (e: any) {
|
||||
// code 7 is for incorrect password
|
||||
this.error = e.code === 7 ? 'Invalid Password' : e.message
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
<tui-data-list>
|
||||
<h3 class="title"><ng-content></ng-content></h3>
|
||||
<tui-opt-group
|
||||
*ngFor="let group of actions | keyvalue : asIsOrder"
|
||||
[label]="group.key.toUpperCase()"
|
||||
>
|
||||
<button
|
||||
*ngFor="let action of group.value"
|
||||
tuiOption
|
||||
class="item"
|
||||
(click)="action.action()"
|
||||
>
|
||||
<tui-svg class="icon" [src]="action.icon"></tui-svg>
|
||||
{{ action.label }}
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
</tui-data-list>
|
||||
@@ -0,0 +1,16 @@
|
||||
.title {
|
||||
margin: 0;
|
||||
padding: 0 0.5rem 0.25rem;
|
||||
white-space: nowrap;
|
||||
font: var(--tui-font-text-l);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.item {
|
||||
justify-content: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
opacity: var(--tui-disabled-opacity);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { TuiDataListModule, TuiSvgModule } from '@taiga-ui/core'
|
||||
import { CommonModule } from '@angular/common'
|
||||
|
||||
export interface Action {
|
||||
icon: string
|
||||
label: string
|
||||
action: () => void
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-actions',
|
||||
templateUrl: './actions.component.html',
|
||||
styleUrls: ['./actions.component.scss'],
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [TuiDataListModule, TuiSvgModule, CommonModule],
|
||||
})
|
||||
export class ActionsComponent {
|
||||
@Input()
|
||||
actions: Record<string, readonly Action[]> = {}
|
||||
|
||||
asIsOrder(a: any, b: any) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<span class="link">
|
||||
<tui-badged-content [style.--tui-radius.rem]="1.5">
|
||||
<tui-badge-notification *ngIf="badge" size="m" tuiSlot="top">
|
||||
{{ badge }}
|
||||
</tui-badge-notification>
|
||||
<tui-svg
|
||||
*ngIf="icon?.startsWith('tuiIcon'); else url"
|
||||
class="icon"
|
||||
[src]="icon"
|
||||
></tui-svg>
|
||||
<ng-template #url>
|
||||
<img alt="" class="icon" [src]="icon" />
|
||||
</ng-template>
|
||||
</tui-badged-content>
|
||||
<label ticker class="title">{{ title }}</label>
|
||||
</span>
|
||||
<span *ngIf="isService" class="side">
|
||||
<tui-hosted-dropdown
|
||||
#dropdown
|
||||
[content]="content"
|
||||
(click.stop.prevent)="(0)"
|
||||
(pointerdown.stop)="(0)"
|
||||
>
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="outline"
|
||||
size="xs"
|
||||
iconLeft="tuiIconMoreHorizontal"
|
||||
[style.border-radius.%]="100"
|
||||
>
|
||||
Actions
|
||||
</button>
|
||||
<ng-template #content>
|
||||
<app-actions
|
||||
[actions]="actions"
|
||||
(click)="dropdown.openChange.next(false)"
|
||||
>
|
||||
{{ title }}
|
||||
</app-actions>
|
||||
</ng-template>
|
||||
</tui-hosted-dropdown>
|
||||
</span>
|
||||
@@ -0,0 +1,49 @@
|
||||
:host {
|
||||
display: flex;
|
||||
height: 5.5rem;
|
||||
width: 12.5rem;
|
||||
border-radius: var(--tui-radius-l);
|
||||
overflow: hidden;
|
||||
box-shadow: 0 0.25rem 0.25rem rgb(0 0 0 / 25%);
|
||||
// TODO: Theme
|
||||
background: rgb(111 109 109);
|
||||
}
|
||||
|
||||
.link {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
gap: 0.25rem;
|
||||
padding: 0 0.5rem;
|
||||
font: var(--tui-font-text-m);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
border-radius: 100%;
|
||||
color: var(--tui-text-01-night);
|
||||
}
|
||||
|
||||
tui-svg.icon {
|
||||
transform: scale(1.5);
|
||||
}
|
||||
|
||||
.title {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.side {
|
||||
width: 3rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 0.25rem 0.25rem rgb(0 0 0 / 25%);
|
||||
// TODO: Theme
|
||||
background: #4b4a4a;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
HostListener,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import {
|
||||
TuiBadgedContentModule,
|
||||
TuiBadgeNotificationModule,
|
||||
TuiButtonModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import { TickerModule } from '@start9labs/shared'
|
||||
import {
|
||||
TuiDataListModule,
|
||||
TuiHostedDropdownModule,
|
||||
TuiSvgModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { NavigationService } from '../../services/navigation.service'
|
||||
import { Action, ActionsComponent } from '../actions/actions.component'
|
||||
import { toRouterLink } from '../../utils/to-router-link'
|
||||
|
||||
@Component({
|
||||
selector: '[appCard]',
|
||||
templateUrl: 'card.component.html',
|
||||
styleUrls: ['card.component.scss'],
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
RouterLink,
|
||||
TuiButtonModule,
|
||||
TuiHostedDropdownModule,
|
||||
TuiDataListModule,
|
||||
TuiSvgModule,
|
||||
TickerModule,
|
||||
TuiBadgedContentModule,
|
||||
TuiBadgeNotificationModule,
|
||||
ActionsComponent,
|
||||
],
|
||||
})
|
||||
export class CardComponent {
|
||||
private readonly navigation = inject(NavigationService)
|
||||
|
||||
@Input({ required: true })
|
||||
id!: string
|
||||
|
||||
@Input({ required: true })
|
||||
icon!: string
|
||||
|
||||
@Input({ required: true })
|
||||
title!: string
|
||||
|
||||
@Input()
|
||||
actions: Record<string, readonly Action[]> = {}
|
||||
|
||||
@Input()
|
||||
badge: number | null = null
|
||||
|
||||
get isService(): boolean {
|
||||
return !this.id.includes('/')
|
||||
}
|
||||
|
||||
@HostListener('click')
|
||||
onClick() {
|
||||
const { id, icon, title } = this
|
||||
const routerLink = toRouterLink(id)
|
||||
|
||||
this.navigation.addTab({ icon, title, routerLink })
|
||||
}
|
||||
|
||||
@HostListener('pointerdown.prevent')
|
||||
onDown() {
|
||||
// Prevents Firefox from starting a native drag
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import {
|
||||
Directive,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { tuiGetActualTarget, tuiIsElement, tuiPx } from '@taiga-ui/cdk'
|
||||
import { DrawerComponent } from './drawer.component'
|
||||
import { DesktopService } from '../../services/desktop.service'
|
||||
import { TuiAlertService } from '@taiga-ui/core'
|
||||
|
||||
/**
|
||||
* This directive is responsible for drag and drop of the drawer item.
|
||||
* It saves item to desktop when dropped.
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[drawerItem]',
|
||||
standalone: true,
|
||||
host: {
|
||||
'[style.userSelect]': '"none"',
|
||||
'[style.touchAction]': '"none"',
|
||||
},
|
||||
})
|
||||
export class DrawerItemDirective {
|
||||
private readonly alerts = inject(TuiAlertService)
|
||||
private readonly desktop = inject(DesktopService)
|
||||
private readonly drawer = inject(DrawerComponent)
|
||||
private readonly element: HTMLElement = inject(ElementRef).nativeElement
|
||||
|
||||
private x = NaN
|
||||
private y = NaN
|
||||
|
||||
@Input()
|
||||
drawerItem = ''
|
||||
|
||||
@HostListener('pointerdown.silent', ['$event'])
|
||||
onStart(event: PointerEvent): void {
|
||||
const target = tuiGetActualTarget(event)
|
||||
const { x, y, pointerId } = event
|
||||
const { left, top } = this.element.getBoundingClientRect()
|
||||
|
||||
if (tuiIsElement(target)) {
|
||||
target.releasePointerCapture(pointerId)
|
||||
}
|
||||
|
||||
this.drawer.open = false
|
||||
this.onPointer(x - left, y - top)
|
||||
}
|
||||
|
||||
@HostListener('document:pointerup.silent')
|
||||
onPointer(x = NaN, y = NaN): void {
|
||||
// Some other element is dragged
|
||||
if (Number.isNaN(this.x) && Number.isNaN(x)) return
|
||||
|
||||
this.x = x
|
||||
this.y = y
|
||||
this.process(NaN, NaN)
|
||||
}
|
||||
|
||||
@HostListener('document:pointermove.silent', ['$event.x', '$event.y'])
|
||||
onMove(x: number, y: number): void {
|
||||
// This element is not dragged
|
||||
if (Number.isNaN(this.x)) return
|
||||
// This element is already on the desktop
|
||||
if (this.desktop.items.includes(this.drawerItem)) {
|
||||
this.onPointer()
|
||||
this.alerts
|
||||
.open('This item is already added', { status: 'warning' })
|
||||
.subscribe()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.process(x, y)
|
||||
this.desktop.add('')
|
||||
}
|
||||
|
||||
private process(x: number, y: number) {
|
||||
const { style } = this.element
|
||||
const { items } = this.desktop
|
||||
const dragged = !Number.isNaN(this.x + x)
|
||||
|
||||
style.pointerEvents = dragged ? 'none' : ''
|
||||
style.position = dragged ? 'fixed' : ''
|
||||
style.top = dragged ? tuiPx(y - this.y) : ''
|
||||
style.left = dragged ? tuiPx(x - this.x) : ''
|
||||
|
||||
if (dragged || !items.includes('')) {
|
||||
return
|
||||
}
|
||||
|
||||
this.desktop.items = items.map(item => item || this.drawerItem)
|
||||
this.desktop.reorder(this.desktop.order)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<div class="content" (tuiActiveZoneChange)="open = $event">
|
||||
<button class="toggle" (click)="open = !open" (mousedown.prevent)="(0)">
|
||||
<tui-svg src="tuiIconArrowUpCircleLarge" class="icon"></tui-svg>
|
||||
Toggle drawer
|
||||
</button>
|
||||
<tui-input
|
||||
class="search"
|
||||
tuiTextfieldAppearance="drawer"
|
||||
tuiTextfieldSize="m"
|
||||
tuiTextfieldIconLeft="tuiIconSearchLarge"
|
||||
[tuiTextfieldLabelOutside]="true"
|
||||
[(ngModel)]="search"
|
||||
>
|
||||
Enter service name
|
||||
</tui-input>
|
||||
<tui-scrollbar class="scrollbar">
|
||||
<h2 class="title">System Utilities</h2>
|
||||
<div class="items">
|
||||
<a
|
||||
*ngFor="
|
||||
let item of system | keyvalue | tuiFilter : bySearch : search;
|
||||
empty: empty
|
||||
"
|
||||
appCard
|
||||
[badge]="item.key | toNotifications | async"
|
||||
[drawerItem]="item.key"
|
||||
[id]="item.key"
|
||||
[title]="item.value.title"
|
||||
[icon]="item.value.icon"
|
||||
[routerLink]="item.key"
|
||||
(click)="open = false"
|
||||
></a>
|
||||
</div>
|
||||
<h2 class="title">Installed services</h2>
|
||||
<div class="items">
|
||||
<a
|
||||
*ngFor="
|
||||
let item of (services$ | async) || [] | tuiFilter : bySearch : search;
|
||||
empty: empty
|
||||
"
|
||||
appCard
|
||||
[drawerItem]="item.manifest.id"
|
||||
[id]="item.manifest.id"
|
||||
[icon]="item.icon"
|
||||
[title]="item.manifest.title"
|
||||
[routerLink]="getLink(item.manifest.id)"
|
||||
(click)="open = false"
|
||||
></a>
|
||||
</div>
|
||||
<ng-template #empty>Nothing found</ng-template>
|
||||
</tui-scrollbar>
|
||||
</div>
|
||||
@@ -0,0 +1,77 @@
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
:host {
|
||||
@include transition(top);
|
||||
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: calc(100% - 10.25rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// TODO: Theme
|
||||
background: #2d2d2d;
|
||||
color: #fff;
|
||||
|
||||
&._open {
|
||||
top: 10.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
position: absolute;
|
||||
top: -2.5rem;
|
||||
height: 2.5rem;
|
||||
width: 25rem;
|
||||
max-width: 100vw;
|
||||
left: 50%;
|
||||
background: inherit;
|
||||
color: inherit;
|
||||
text-align: center;
|
||||
font-size: 0;
|
||||
transform: translateX(-50%);
|
||||
border-top-left-radius: var(--tui-radius-xl);
|
||||
border-top-right-radius: var(--tui-radius-xl);
|
||||
}
|
||||
|
||||
.icon {
|
||||
@include transition(transform);
|
||||
|
||||
:host._open & {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.scrollbar {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.search {
|
||||
width: 25rem;
|
||||
margin: 6rem auto 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 4rem 0 1.25rem;
|
||||
text-align: center;
|
||||
text-transform: uppercase;
|
||||
font: var(--tui-font-text-xl);
|
||||
}
|
||||
|
||||
.items {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
padding: 2rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
HostBinding,
|
||||
inject,
|
||||
} from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { RouterLink } from '@angular/router'
|
||||
import {
|
||||
TUI_DEFAULT_MATCHER,
|
||||
TuiActiveZoneModule,
|
||||
TuiFilterPipeModule,
|
||||
TuiForModule,
|
||||
} from '@taiga-ui/cdk'
|
||||
import {
|
||||
TuiScrollbarModule,
|
||||
TuiSvgModule,
|
||||
TuiTextfieldControllerModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiInputModule } from '@taiga-ui/kit'
|
||||
import { CardComponent } from '../card/card.component'
|
||||
import { ServicesService } from '../../services/services.service'
|
||||
import { toRouterLink } from '../../utils/to-router-link'
|
||||
import { DrawerItemDirective } from './drawer-item.directive'
|
||||
import { SYSTEM_UTILITIES } from '../../constants/system-utilities'
|
||||
import { ToNotificationsPipe } from '../../pipes/to-notifications'
|
||||
|
||||
@Component({
|
||||
selector: 'app-drawer',
|
||||
templateUrl: 'drawer.component.html',
|
||||
styleUrls: ['drawer.component.scss'],
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
RouterLink,
|
||||
TuiSvgModule,
|
||||
TuiScrollbarModule,
|
||||
TuiActiveZoneModule,
|
||||
TuiInputModule,
|
||||
TuiTextfieldControllerModule,
|
||||
TuiForModule,
|
||||
TuiFilterPipeModule,
|
||||
CardComponent,
|
||||
DrawerItemDirective,
|
||||
ToNotificationsPipe,
|
||||
],
|
||||
})
|
||||
export class DrawerComponent {
|
||||
@HostBinding('class._open')
|
||||
open = false
|
||||
|
||||
search = ''
|
||||
|
||||
readonly system = SYSTEM_UTILITIES
|
||||
readonly services$ = inject(ServicesService)
|
||||
|
||||
readonly bySearch = (item: any, search: string): boolean =>
|
||||
search.length < 2 ||
|
||||
TUI_DEFAULT_MATCHER(item.manifest?.title || item.value?.title || '', search)
|
||||
|
||||
getLink(id: string): string {
|
||||
return toRouterLink(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<tui-hosted-dropdown
|
||||
[content]="content"
|
||||
[tuiDropdownMaxHeight]="9999"
|
||||
(click.stop.prevent)="(0)"
|
||||
(pointerdown.stop)="(0)"
|
||||
>
|
||||
<button tuiIconButton appearance="">
|
||||
<img style="max-width: 62%" src="assets/img/icon.png" alt="StartOS" />
|
||||
</button>
|
||||
<ng-template #content>
|
||||
<tui-data-list>
|
||||
<h3 class="title">StartOS</h3>
|
||||
<button tuiOption class="item" (click)="({})">
|
||||
<tui-svg src="tuiIconInfo"></tui-svg>
|
||||
About this server
|
||||
</button>
|
||||
<tui-opt-group>
|
||||
<button tuiOption class="item" (click)="({})">
|
||||
<tui-svg src="tuiIconBookOpen"></tui-svg>
|
||||
User Manual
|
||||
<tui-svg class="external" src="tuiIconArrowUpRight"></tui-svg>
|
||||
</button>
|
||||
<button tuiOption class="item" (click)="({})">
|
||||
<tui-svg src="tuiIconHeadphones"></tui-svg>
|
||||
Contact Support
|
||||
<tui-svg class="external" src="tuiIconArrowUpRight"></tui-svg>
|
||||
</button>
|
||||
<button tuiOption class="item" (click)="({})">
|
||||
<tui-svg src="tuiIconDollarSign"></tui-svg>
|
||||
Donate to Start9
|
||||
<tui-svg class="external" src="tuiIconArrowUpRight"></tui-svg>
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
<tui-opt-group>
|
||||
<button tuiOption class="item" (click)="({})">
|
||||
<tui-svg src="tuiIconTool"></tui-svg>
|
||||
System Rebuild
|
||||
</button>
|
||||
<button tuiOption class="item" (click)="({})">
|
||||
<tui-svg src="tuiIconRefreshCw"></tui-svg>
|
||||
Restart
|
||||
</button>
|
||||
<button tuiOption class="item" (click)="({})">
|
||||
<tui-svg src="tuiIconPower"></tui-svg>
|
||||
Shutdown
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
<tui-opt-group>
|
||||
<button tuiOption class="item" (click)="logout()">
|
||||
<tui-svg src="tuiIconLogOut"></tui-svg>
|
||||
Logout
|
||||
</button>
|
||||
</tui-opt-group>
|
||||
</tui-data-list>
|
||||
</ng-template>
|
||||
</tui-hosted-dropdown>
|
||||
@@ -0,0 +1,17 @@
|
||||
.item {
|
||||
justify-content: flex-start;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0;
|
||||
padding: 0 0.5rem 0.25rem;
|
||||
white-space: nowrap;
|
||||
font: var(--tui-font-text-l);
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.external {
|
||||
margin-left: auto;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import {
|
||||
TuiDataListModule,
|
||||
TuiHostedDropdownModule,
|
||||
TuiSvgModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { AuthService } from 'src/app/services/auth.service'
|
||||
|
||||
@Component({
|
||||
selector: 'header-menu',
|
||||
templateUrl: 'header-menu.component.html',
|
||||
styleUrls: ['header-menu.component.scss'],
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
TuiHostedDropdownModule,
|
||||
TuiDataListModule,
|
||||
TuiSvgModule,
|
||||
TuiButtonModule,
|
||||
],
|
||||
})
|
||||
export class HeaderMenuComponent {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly auth = inject(AuthService)
|
||||
|
||||
logout() {
|
||||
this.api.logout({}).catch(e => console.error('Failed to log out', e))
|
||||
this.auth.setUnverified()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<ng-content></ng-content>
|
||||
<div class="toolbar">
|
||||
<button tuiIconButton iconLeft="tuiIconCloudLarge" appearance="success">
|
||||
Connection
|
||||
</button>
|
||||
<tui-badged-content [style.--tui-radius.%]="50">
|
||||
<tui-badge-notification tuiSlot="bottom" size="s">4</tui-badge-notification>
|
||||
<button tuiIconButton iconLeft="tuiIconBellLarge" appearance="warning">
|
||||
Notifications
|
||||
</button>
|
||||
</tui-badged-content>
|
||||
<header-menu></header-menu>
|
||||
</div>
|
||||
@@ -0,0 +1,14 @@
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 4.5rem;
|
||||
padding: 0 1rem 0 2rem;
|
||||
font-size: 1.5rem;
|
||||
// TODO: Theme
|
||||
background: rgb(51 51 51 / 84%);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
margin-left: auto;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
import {
|
||||
TuiDataListModule,
|
||||
TuiHostedDropdownModule,
|
||||
TuiSvgModule,
|
||||
} from '@taiga-ui/core'
|
||||
import {
|
||||
TuiBadgedContentModule,
|
||||
TuiBadgeNotificationModule,
|
||||
TuiButtonModule,
|
||||
} from '@taiga-ui/experimental'
|
||||
import { HeaderMenuComponent } from './header-menu/header-menu.component'
|
||||
|
||||
@Component({
|
||||
selector: 'header[appHeader]',
|
||||
templateUrl: 'header.component.html',
|
||||
styleUrls: ['header.component.scss'],
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [
|
||||
TuiBadgedContentModule,
|
||||
TuiBadgeNotificationModule,
|
||||
TuiButtonModule,
|
||||
TuiHostedDropdownModule,
|
||||
TuiDataListModule,
|
||||
TuiSvgModule,
|
||||
HeaderMenuComponent,
|
||||
],
|
||||
})
|
||||
export class HeaderComponent {}
|
||||
@@ -0,0 +1,34 @@
|
||||
<a
|
||||
class="tab"
|
||||
routerLink="desktop"
|
||||
routerLinkActive="tab_active"
|
||||
[routerLinkActiveOptions]="{ exact: true }"
|
||||
>
|
||||
<tui-svg src="tuiIconHomeLarge" class="icon"></tui-svg>
|
||||
</a>
|
||||
<a
|
||||
*ngFor="let tab of tabs$ | async"
|
||||
#rla="routerLinkActive"
|
||||
class="tab"
|
||||
routerLinkActive="tab_active"
|
||||
[routerLink]="tab.routerLink"
|
||||
>
|
||||
<tui-svg
|
||||
*ngIf="tab.icon.startsWith('tuiIcon'); else url"
|
||||
class="icon"
|
||||
[src]="tab.icon"
|
||||
></tui-svg>
|
||||
<ng-template #url>
|
||||
<img class="icon" [src]="tab.icon" [alt]="tab.title" />
|
||||
</ng-template>
|
||||
<button
|
||||
tuiIconButton
|
||||
size="xs"
|
||||
iconLeft="tuiIconClose"
|
||||
appearance="icon"
|
||||
class="close"
|
||||
(click.stop.prevent)="removeTab(tab.routerLink, rla.isActive)"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</a>
|
||||
@@ -0,0 +1,42 @@
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
:host {
|
||||
@include scrollbar-hidden;
|
||||
|
||||
height: 3rem;
|
||||
display: flex;
|
||||
// TODO: Theme
|
||||
background: rgb(97 95 95 / 84%);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.tab {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 7.5rem;
|
||||
|
||||
&_active {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
// TODO: Theme
|
||||
background: #373a3f;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border-radius: 100%;
|
||||
color: var(--tui-base-08);
|
||||
}
|
||||
|
||||
.close {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { Router, RouterModule } from '@angular/router'
|
||||
import { TuiSvgModule } from '@taiga-ui/core'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { NavigationService } from '../../services/navigation.service'
|
||||
import { NavigationItem } from '../../types/navigation-item'
|
||||
|
||||
@Component({
|
||||
selector: 'nav[appNavigation]',
|
||||
templateUrl: 'navigation.component.html',
|
||||
styleUrls: ['navigation.component.scss'],
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
imports: [CommonModule, RouterModule, TuiButtonModule, TuiSvgModule],
|
||||
})
|
||||
export class NavigationComponent {
|
||||
private readonly router = inject(Router)
|
||||
private readonly navigation = inject(NavigationService)
|
||||
|
||||
readonly tabs$ = this.navigation.getTabs()
|
||||
|
||||
removeTab(routerLink: string, active: boolean) {
|
||||
this.navigation.removeTab(routerLink)
|
||||
|
||||
if (active) this.router.navigate(['/portal/desktop'])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Component, Input } from '@angular/core'
|
||||
import { TuiRepeatTimesModule } from '@taiga-ui/cdk'
|
||||
|
||||
@Component({
|
||||
selector: 'skeleton-list',
|
||||
template: `
|
||||
<div *tuiRepeatTimes="let index of rows" class="g-action">
|
||||
<div
|
||||
class="tui-skeleton"
|
||||
style="--tui-skeleton-radius: 100%; width: 2.5rem; height: 2.5rem"
|
||||
[hidden]="!showAvatar"
|
||||
></div>
|
||||
<div class="tui-skeleton" style="width: 12rem; height: 0.75rem"></div>
|
||||
<div
|
||||
class="tui-skeleton"
|
||||
style="width: 5rem; height: 0.75rem; margin-left: auto"
|
||||
></div>
|
||||
</div>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [TuiRepeatTimesModule],
|
||||
})
|
||||
export class SkeletonListComponent {
|
||||
@Input() rows = 3
|
||||
@Input() showAvatar = false
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
export const SYSTEM_UTILITIES: Record<string, { icon: string; title: string }> =
|
||||
{
|
||||
'/portal/system/backups': {
|
||||
icon: 'tuiIconSaveLarge',
|
||||
title: 'Backups',
|
||||
},
|
||||
'/portal/system/updates': {
|
||||
icon: 'tuiIconGlobeLarge',
|
||||
title: 'Updates',
|
||||
},
|
||||
'/portal/system/sideload': {
|
||||
icon: 'tuiIconUploadLarge',
|
||||
title: 'Sideload',
|
||||
},
|
||||
'/portal/system/snek': {
|
||||
icon: 'assets/img/icon_transparent.png',
|
||||
title: 'Snek',
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
import { NavigationItem } from '../types/navigation-item'
|
||||
import { toNavigationItem } from '../utils/to-navigation-item'
|
||||
|
||||
@Pipe({
|
||||
name: 'toNavigationItem',
|
||||
standalone: true,
|
||||
})
|
||||
export class ToNavigationItemPipe implements PipeTransform {
|
||||
transform(
|
||||
packages: Record<string, PackageDataEntry>,
|
||||
id: string,
|
||||
): NavigationItem | null {
|
||||
return id ? toNavigationItem(id, packages) : null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { inject, Pipe, PipeTransform } from '@angular/core'
|
||||
import { NotificationsService } from '../services/notifications.service'
|
||||
import { Observable } from 'rxjs'
|
||||
|
||||
@Pipe({
|
||||
name: 'toNotifications',
|
||||
standalone: true,
|
||||
})
|
||||
export class ToNotificationsPipe implements PipeTransform {
|
||||
readonly notifications = inject(NotificationsService)
|
||||
|
||||
transform(id: string): Observable<number> {
|
||||
return this.notifications.getNotifications(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<header appHeader>My server</header>
|
||||
<nav appNavigation></nav>
|
||||
<main>
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
<app-drawer></app-drawer>
|
||||
10
web/projects/ui/src/app/apps/portal/portal.component.scss
Normal file
10
web/projects/ui/src/app/apps/portal/portal.component.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
:host {
|
||||
// TODO: Theme
|
||||
background: url(/assets/img/background_dark.jpeg);
|
||||
background-size: cover;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
15
web/projects/ui/src/app/apps/portal/portal.component.ts
Normal file
15
web/projects/ui/src/app/apps/portal/portal.component.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { ChangeDetectionStrategy, Component } from '@angular/core'
|
||||
import { tuiDropdownOptionsProvider } from '@taiga-ui/core'
|
||||
|
||||
@Component({
|
||||
templateUrl: 'portal.component.html',
|
||||
styleUrls: ['portal.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
providers: [
|
||||
// TODO: Move to global
|
||||
tuiDropdownOptionsProvider({
|
||||
appearance: 'start-os',
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class PortalComponent {}
|
||||
47
web/projects/ui/src/app/apps/portal/portal.module.ts
Normal file
47
web/projects/ui/src/app/apps/portal/portal.module.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { HeaderComponent } from './components/header/header.component'
|
||||
import { PortalComponent } from './portal.component'
|
||||
import { NavigationComponent } from './components/navigation/navigation.component'
|
||||
import { DrawerComponent } from './components/drawer/drawer.component'
|
||||
|
||||
const ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: PortalComponent,
|
||||
children: [
|
||||
{
|
||||
redirectTo: 'desktop',
|
||||
pathMatch: 'full',
|
||||
path: '',
|
||||
},
|
||||
{
|
||||
path: 'desktop',
|
||||
loadChildren: () =>
|
||||
import('./routes/desktop/desktop.module').then(m => m.DesktopModule),
|
||||
},
|
||||
{
|
||||
path: 'service',
|
||||
loadChildren: () =>
|
||||
import('./routes/service/service.module').then(m => m.ServiceModule),
|
||||
},
|
||||
{
|
||||
path: 'system',
|
||||
loadChildren: () =>
|
||||
import('./routes/system/system.module').then(m => m.SystemModule),
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
RouterModule.forChild(ROUTES),
|
||||
HeaderComponent,
|
||||
NavigationComponent,
|
||||
DrawerComponent,
|
||||
],
|
||||
declarations: [PortalComponent],
|
||||
exports: [PortalComponent],
|
||||
})
|
||||
export class PortalModule {}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { inject, Injectable } from '@angular/core'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import {
|
||||
endWith,
|
||||
ignoreElements,
|
||||
Observable,
|
||||
shareReplay,
|
||||
startWith,
|
||||
take,
|
||||
tap,
|
||||
} from 'rxjs'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { DesktopService } from '../../services/desktop.service'
|
||||
|
||||
/**
|
||||
* This service loads initial values for desktop items
|
||||
* and is used to show loading indicator.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class DektopLoadingService extends Observable<boolean> {
|
||||
private readonly desktop = inject(DesktopService)
|
||||
private readonly patch = inject(PatchDB<DataModel>)
|
||||
private readonly loading = this.patch.watch$('ui', 'desktop').pipe(
|
||||
take(1),
|
||||
tap(items => (this.desktop.items = items.filter(Boolean))),
|
||||
ignoreElements(),
|
||||
startWith(true),
|
||||
endWith(false),
|
||||
shareReplay(1),
|
||||
)
|
||||
|
||||
constructor() {
|
||||
super(subscriber => this.loading.subscribe(subscriber))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import {
|
||||
Directive,
|
||||
ElementRef,
|
||||
HostBinding,
|
||||
inject,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
} from '@angular/core'
|
||||
import { TuiTilesComponent } from '@taiga-ui/kit'
|
||||
|
||||
/**
|
||||
* This directive is responsible for creating empty placeholder
|
||||
* on the desktop when item is dragged from the drawer
|
||||
*/
|
||||
@Directive({
|
||||
selector: '[desktopItem]',
|
||||
standalone: true,
|
||||
})
|
||||
export class DesktopItemDirective implements OnInit, OnDestroy {
|
||||
private readonly element: Element = inject(ElementRef).nativeElement
|
||||
private readonly tiles = inject(TuiTilesComponent)
|
||||
|
||||
@Input()
|
||||
desktopItem = ''
|
||||
|
||||
@HostBinding('class._empty')
|
||||
get empty(): boolean {
|
||||
return !this.desktopItem
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
if (this.empty) this.tiles.element = this.element
|
||||
}
|
||||
|
||||
// TODO: Remove after Taiga UI updated to 3.40.0
|
||||
ngOnDestroy() {
|
||||
if (this.tiles.element === this.element) this.tiles.element = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<tui-loader
|
||||
*ngIf="loading$ | async; else data"
|
||||
size="xl"
|
||||
class="loader"
|
||||
></tui-loader>
|
||||
<ng-template #data>
|
||||
<tui-svg
|
||||
*ngIf="tile?.element"
|
||||
class="remove"
|
||||
src="tuiIconTrash2Large"
|
||||
@tuiFadeIn
|
||||
@tuiScaleIn
|
||||
(pointerup)="onRemove()"
|
||||
></tui-svg>
|
||||
<div dragScroller tuiFade="vertical" class="fade">
|
||||
<tui-tiles
|
||||
*ngIf="packages$ | async as packages"
|
||||
class="tiles"
|
||||
@tuiParentStop
|
||||
[debounce]="500"
|
||||
[order]="desktop.order"
|
||||
(orderChange)="onReorder($event)"
|
||||
>
|
||||
<tui-tile
|
||||
*ngFor="let item of desktop.items; let index = index"
|
||||
class="item"
|
||||
[style.order]="desktop.order.get(index)"
|
||||
[desktopItem]="item"
|
||||
>
|
||||
<a
|
||||
*ngIf="packages | toNavigationItem : item as navigationItem"
|
||||
tuiTileHandle
|
||||
appCard
|
||||
@tuiFadeIn
|
||||
[id]="item"
|
||||
[badge]="item | toNotifications | async"
|
||||
[title]="navigationItem.title"
|
||||
[icon]="navigationItem.icon"
|
||||
[routerLink]="navigationItem.routerLink"
|
||||
></a>
|
||||
</tui-tile>
|
||||
</tui-tiles>
|
||||
</div>
|
||||
</ng-template>
|
||||
@@ -0,0 +1,68 @@
|
||||
@import '@taiga-ui/core/styles/taiga-ui-local';
|
||||
|
||||
:host {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-content: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
max-width: calc(100vw - 4rem);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.loader {
|
||||
height: 10rem;
|
||||
width: 10rem;
|
||||
}
|
||||
|
||||
.fade {
|
||||
@include scrollbar-hidden();
|
||||
|
||||
width: 100%;
|
||||
height: calc(100% - 4rem);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.tiles {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
grid-template-columns: repeat(auto-fit, 12.5rem);
|
||||
grid-auto-rows: min-content;
|
||||
gap: 2rem;
|
||||
margin: auto;
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
grid-column: 1;
|
||||
height: 1rem;
|
||||
order: 999;
|
||||
}
|
||||
}
|
||||
|
||||
.remove {
|
||||
@include transition(background);
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: calc(50% - 3rem);
|
||||
width: 6rem;
|
||||
height: 6rem;
|
||||
border-radius: 100%;
|
||||
background: var(--tui-base-02);
|
||||
z-index: 10;
|
||||
|
||||
&:hover {
|
||||
background: var(--tui-base-01);
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
height: 5.5rem;
|
||||
|
||||
&._dragged,
|
||||
&._empty {
|
||||
border-radius: var(--tui-radius-l);
|
||||
box-shadow: inset 0 0 0 0.5rem var(--tui-clear-active);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
inject,
|
||||
QueryList,
|
||||
ViewChild,
|
||||
ViewChildren,
|
||||
} from '@angular/core'
|
||||
import { EMPTY_QUERY, TUI_PARENT_STOP } from '@taiga-ui/cdk'
|
||||
import { tuiFadeIn, tuiScaleIn } from '@taiga-ui/core'
|
||||
import { TuiTileComponent, TuiTilesComponent } from '@taiga-ui/kit'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { DataModel } from 'src/app/services/patch-db/data-model'
|
||||
import { DesktopService } from '../../services/desktop.service'
|
||||
import { DektopLoadingService } from './dektop-loading.service'
|
||||
|
||||
@Component({
|
||||
templateUrl: 'desktop.component.html',
|
||||
styleUrls: ['desktop.component.scss'],
|
||||
animations: [TUI_PARENT_STOP, tuiScaleIn, tuiFadeIn],
|
||||
})
|
||||
export class DesktopComponent {
|
||||
@ViewChildren(TuiTileComponent, { read: ElementRef })
|
||||
private readonly tiles: QueryList<ElementRef> = EMPTY_QUERY
|
||||
|
||||
readonly desktop = inject(DesktopService)
|
||||
readonly loading$ = inject(DektopLoadingService)
|
||||
readonly packages$ = inject(PatchDB<DataModel>).watch$('package-data')
|
||||
|
||||
@ViewChild(TuiTilesComponent)
|
||||
readonly tile?: TuiTilesComponent
|
||||
|
||||
onRemove() {
|
||||
const element = this.tile?.element
|
||||
const index = this.tiles
|
||||
.toArray()
|
||||
.map(({ nativeElement }) => nativeElement)
|
||||
.indexOf(element)
|
||||
|
||||
this.desktop.remove(this.desktop.items[index])
|
||||
}
|
||||
|
||||
onReorder(order: Map<number, number>) {
|
||||
this.desktop.reorder(order)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { NgModule } from '@angular/core'
|
||||
import { RouterModule, Routes } from '@angular/router'
|
||||
import { DragScrollerDirective } from '@start9labs/shared'
|
||||
import { TuiLoaderModule, TuiSvgModule } from '@taiga-ui/core'
|
||||
import { TuiFadeModule } from '@taiga-ui/experimental'
|
||||
import { TuiTilesModule } from '@taiga-ui/kit'
|
||||
import { DesktopComponent } from './desktop.component'
|
||||
import { CardComponent } from '../../components/card/card.component'
|
||||
import { ToNavigationItemPipe } from '../../pipes/to-navigation-item'
|
||||
import { ToNotificationsPipe } from '../../pipes/to-notifications'
|
||||
import { DesktopItemDirective } from './desktop-item.directive'
|
||||
|
||||
const ROUTES: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: DesktopComponent,
|
||||
},
|
||||
]
|
||||
|
||||
@NgModule({
|
||||
imports: [
|
||||
CommonModule,
|
||||
CardComponent,
|
||||
DesktopItemDirective,
|
||||
TuiSvgModule,
|
||||
TuiLoaderModule,
|
||||
TuiTilesModule,
|
||||
ToNavigationItemPipe,
|
||||
RouterModule.forChild(ROUTES),
|
||||
TuiFadeModule,
|
||||
DragScrollerDirective,
|
||||
ToNotificationsPipe,
|
||||
],
|
||||
declarations: [DesktopComponent],
|
||||
exports: [DesktopComponent],
|
||||
})
|
||||
export class DesktopModule {}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { CopyService } from '@start9labs/shared'
|
||||
import { TuiDialogContext } from '@taiga-ui/core'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { QrCodeModule } from 'ng-qrcode'
|
||||
import { ActionResponse } from 'src/app/services/api/api.types'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
{{ context.data.message }}
|
||||
<ng-container *ngIf="context.data.value">
|
||||
<qr-code
|
||||
*ngIf="context.data.qr"
|
||||
size="240"
|
||||
[value]="context.data.value"
|
||||
></qr-code>
|
||||
<p>
|
||||
{{ context.data.value }}
|
||||
<button
|
||||
*ngIf="context.data.copyable"
|
||||
tuiIconButton
|
||||
appearance="flat"
|
||||
iconLeft="tuiIconCopyLarge"
|
||||
(click)="copyService.copy(context.data.value)"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
</p>
|
||||
</ng-container>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
qr-code {
|
||||
margin: 1rem auto;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
p {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [CommonModule, QrCodeModule, TuiButtonModule],
|
||||
})
|
||||
export class ServiceActionSuccessComponent {
|
||||
readonly copyService = inject(CopyService)
|
||||
readonly context =
|
||||
inject<TuiDialogContext<void, ActionResponse>>(POLYMORPHEUS_CONTEXT)
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { TuiSvgModule } from '@taiga-ui/core'
|
||||
|
||||
interface ActionItem {
|
||||
readonly icon: string
|
||||
readonly name: string
|
||||
readonly description: string
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: '[action]',
|
||||
template: `
|
||||
<tui-svg [src]="action.icon"></tui-svg>
|
||||
<div>
|
||||
<strong>{{ action.name }}</strong>
|
||||
<div>{{ action.description }}</div>
|
||||
</div>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [TuiSvgModule],
|
||||
})
|
||||
export class ServiceActionComponent {
|
||||
@Input({ required: true })
|
||||
action!: ActionItem
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { ErrorService, LoadingService } from '@start9labs/shared'
|
||||
import { TuiDialogService } from '@taiga-ui/core'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { TUI_PROMPT } from '@taiga-ui/kit'
|
||||
import { filter } from 'rxjs'
|
||||
import {
|
||||
InterfaceInfo,
|
||||
PackageMainStatus,
|
||||
PackagePlus,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { FormDialogService } from 'src/app/services/form-dialog.service'
|
||||
import { hasCurrentDeps } from 'src/app/util/has-deps'
|
||||
import { ServiceConfigModal } from '../modals/config.component'
|
||||
import { PackageConfigData } from '../types/package-config-data'
|
||||
|
||||
@Component({
|
||||
selector: 'service-actions',
|
||||
template: `
|
||||
<button
|
||||
*ngIf="isRunning"
|
||||
tuiButton
|
||||
appearance="secondary-destructive"
|
||||
icon="tuiIconSquare"
|
||||
(click)="tryStop()"
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
|
||||
<button
|
||||
*ngIf="isRunning"
|
||||
tuiButton
|
||||
appearance="secondary"
|
||||
icon="tuiIconRotateCw"
|
||||
(click)="tryRestart()"
|
||||
>
|
||||
Restart
|
||||
</button>
|
||||
|
||||
<button
|
||||
*ngIf="isStopped && isConfigured"
|
||||
tuiButton
|
||||
icon="tuiIconPlay"
|
||||
(click)="tryStart()"
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
|
||||
<button
|
||||
*ngIf="!isConfigured"
|
||||
tuiButton
|
||||
appearance="secondary-warning"
|
||||
icon="tuiIconTool"
|
||||
(click)="presentModalConfig()"
|
||||
>
|
||||
Configure
|
||||
</button>
|
||||
`,
|
||||
styles: [':host { display: flex; gap: 1rem }'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [CommonModule, TuiButtonModule],
|
||||
})
|
||||
export class ServiceActionsComponent {
|
||||
@Input({ required: true })
|
||||
service!: PackagePlus
|
||||
|
||||
constructor(
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly formDialog: FormDialogService,
|
||||
) {}
|
||||
|
||||
private get id(): string {
|
||||
return this.service.pkg.manifest.id
|
||||
}
|
||||
|
||||
get interfaceInfo(): Record<string, InterfaceInfo> {
|
||||
return this.service.pkg.installed!['interfaceInfo']
|
||||
}
|
||||
|
||||
get isConfigured(): boolean {
|
||||
return this.service.pkg.installed!.status.configured
|
||||
}
|
||||
|
||||
get isRunning(): boolean {
|
||||
return (
|
||||
this.service.pkg.installed?.status.main.status ===
|
||||
PackageMainStatus.Running
|
||||
)
|
||||
}
|
||||
|
||||
get isStopped(): boolean {
|
||||
return (
|
||||
this.service.pkg.installed?.status.main.status ===
|
||||
PackageMainStatus.Stopped
|
||||
)
|
||||
}
|
||||
|
||||
presentModalConfig(): void {
|
||||
this.formDialog.open<PackageConfigData>(ServiceConfigModal, {
|
||||
label: `${this.service.pkg.manifest.title} configuration`,
|
||||
data: { pkgId: this.id },
|
||||
})
|
||||
}
|
||||
|
||||
async tryStart(): Promise<void> {
|
||||
const pkg = this.service.pkg
|
||||
if (Object.values(this.service.dependencies).some(dep => !!dep.errorText)) {
|
||||
const depErrMsg = `${pkg.manifest.title} has unmet dependencies. It will not work as expected.`
|
||||
const proceed = await this.presentAlertStart(depErrMsg)
|
||||
|
||||
if (!proceed) return
|
||||
}
|
||||
|
||||
const alertMsg = pkg.manifest.alerts.start
|
||||
|
||||
if (alertMsg) {
|
||||
const proceed = await this.presentAlertStart(alertMsg)
|
||||
|
||||
if (!proceed) return
|
||||
}
|
||||
|
||||
this.start()
|
||||
}
|
||||
|
||||
async tryStop(): Promise<void> {
|
||||
const { title, alerts } = this.service.pkg.manifest
|
||||
|
||||
let content = alerts.stop || ''
|
||||
if (hasCurrentDeps(this.service.pkg)) {
|
||||
const depMessage = `Services that depend on ${title} will no longer work properly and may crash`
|
||||
content = content ? `${content}.\n\n${depMessage}` : depMessage
|
||||
}
|
||||
|
||||
if (content) {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content,
|
||||
yes: 'Stop',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => this.stop())
|
||||
} else {
|
||||
this.stop()
|
||||
}
|
||||
}
|
||||
|
||||
async tryRestart(): Promise<void> {
|
||||
if (hasCurrentDeps(this.service.pkg)) {
|
||||
this.dialogs
|
||||
.open(TUI_PROMPT, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content: `Services that depend on ${this.service.pkg.manifest} may temporarily experiences issues`,
|
||||
yes: 'Restart',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.pipe(filter(Boolean))
|
||||
.subscribe(() => this.restart())
|
||||
} else {
|
||||
this.restart()
|
||||
}
|
||||
}
|
||||
|
||||
private async start(): Promise<void> {
|
||||
const loader = this.loader.open(`Starting...`).subscribe()
|
||||
|
||||
try {
|
||||
await this.embassyApi.startPackage({ id: this.id })
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async stop(): Promise<void> {
|
||||
const loader = this.loader.open(`Stopping...`).subscribe()
|
||||
|
||||
try {
|
||||
await this.embassyApi.stopPackage({ id: this.id })
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async restart(): Promise<void> {
|
||||
const loader = this.loader.open(`Restarting...`).subscribe()
|
||||
|
||||
try {
|
||||
await this.embassyApi.restartPackage({ id: this.id })
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async presentAlertStart(content: string): Promise<boolean> {
|
||||
return new Promise(async resolve => {
|
||||
this.dialogs
|
||||
.open<boolean>(TUI_PROMPT, {
|
||||
label: 'Warning',
|
||||
size: 's',
|
||||
data: {
|
||||
content,
|
||||
yes: 'Continue',
|
||||
no: 'Cancel',
|
||||
},
|
||||
})
|
||||
.subscribe(response => resolve(response))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { TuiSvgModule } from '@taiga-ui/core'
|
||||
import { AdditionalItem, FALLBACK_URL } from '../pipes/to-additional.pipe'
|
||||
|
||||
@Component({
|
||||
selector: '[additionalItem]',
|
||||
template: `
|
||||
<div [style.flex]="1">
|
||||
<strong>{{ additionalItem.name }}</strong>
|
||||
<div>{{ additionalItem.description }}</div>
|
||||
</div>
|
||||
<tui-svg *ngIf="icon" [src]="icon"></tui-svg>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host._disabled {
|
||||
pointer-events: none;
|
||||
opacity: var(--tui-disabled-opacity);
|
||||
}
|
||||
`,
|
||||
],
|
||||
host: {
|
||||
rel: 'noreferrer',
|
||||
target: '_blank',
|
||||
'[class._disabled]': 'disabled',
|
||||
'[attr.href]':
|
||||
'additionalItem.description.startsWith("http") ? additionalItem.description : null',
|
||||
},
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [CommonModule, TuiSvgModule],
|
||||
})
|
||||
export class ServiceAdditionalItemComponent {
|
||||
@Input({ required: true })
|
||||
additionalItem!: AdditionalItem
|
||||
|
||||
get disabled(): boolean {
|
||||
return this.additionalItem.description === FALLBACK_URL
|
||||
}
|
||||
|
||||
get icon(): string | undefined {
|
||||
return this.additionalItem.description.startsWith('http')
|
||||
? 'tuiIconExternalLinkLarge'
|
||||
: this.additionalItem.icon
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
import { ToAdditionalPipe } from '../pipes/to-additional.pipe'
|
||||
import { ServiceAdditionalItemComponent } from './additional-item.component'
|
||||
|
||||
@Component({
|
||||
selector: 'service-additional',
|
||||
template: `
|
||||
<h3 class="g-title">Additional Info</h3>
|
||||
<ng-container *ngFor="let additional of service | toAdditional">
|
||||
<a
|
||||
*ngIf="additional.description.startsWith('http'); else button"
|
||||
class="g-action"
|
||||
[additionalItem]="additional"
|
||||
></a>
|
||||
<ng-template #button>
|
||||
<button
|
||||
class="g-action"
|
||||
[style.pointer-events]="!additional.icon ? 'none' : null"
|
||||
[additionalItem]="additional"
|
||||
(click)="additional.action?.()"
|
||||
></button>
|
||||
</ng-template>
|
||||
</ng-container>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [CommonModule, ToAdditionalPipe, ServiceAdditionalItemComponent],
|
||||
})
|
||||
export class ServiceAdditionalComponent {
|
||||
@Input({ required: true })
|
||||
service!: PackageDataEntry
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
Input,
|
||||
OnChanges,
|
||||
} from '@angular/core'
|
||||
import { compare, getValueByPointer, Operation } from 'fast-json-patch'
|
||||
import { isObject } from '@start9labs/shared'
|
||||
import { tuiIsNumber } from '@taiga-ui/cdk'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { TuiNotificationModule } from '@taiga-ui/core'
|
||||
|
||||
@Component({
|
||||
selector: 'config-dep',
|
||||
template: `
|
||||
<tui-notification>
|
||||
<h3 style="margin: 0 0 0.5rem; font-size: 1.25rem;">
|
||||
{{ package }}
|
||||
</h3>
|
||||
The following modifications have been made to {{ package }} to satisfy
|
||||
{{ dep }}:
|
||||
<ul>
|
||||
<li *ngFor="let d of diff" [innerHTML]="d"></li>
|
||||
</ul>
|
||||
To accept these modifications, click "Save".
|
||||
</tui-notification>
|
||||
`,
|
||||
standalone: true,
|
||||
imports: [CommonModule, TuiNotificationModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class ConfigDepComponent implements OnChanges {
|
||||
@Input()
|
||||
package = ''
|
||||
|
||||
@Input()
|
||||
dep = ''
|
||||
|
||||
@Input()
|
||||
original: object = {}
|
||||
|
||||
@Input()
|
||||
value: object = {}
|
||||
|
||||
diff: string[] = []
|
||||
|
||||
ngOnChanges() {
|
||||
this.diff = compare(this.original, this.value).map(
|
||||
op => `${this.getPath(op)}: ${this.getMessage(op)}`,
|
||||
)
|
||||
}
|
||||
|
||||
private getPath(operation: Operation): string {
|
||||
const path = operation.path
|
||||
.substring(1)
|
||||
.split('/')
|
||||
.map(node => {
|
||||
const num = Number(node)
|
||||
return isNaN(num) ? node : num
|
||||
})
|
||||
|
||||
if (tuiIsNumber(path[path.length - 1])) {
|
||||
path.pop()
|
||||
}
|
||||
|
||||
return path.join(' → ')
|
||||
}
|
||||
|
||||
private getMessage(operation: Operation): string {
|
||||
switch (operation.op) {
|
||||
case 'add':
|
||||
return `Added ${this.getNewValue(operation.value)}`
|
||||
case 'remove':
|
||||
return `Removed ${this.getOldValue(operation.path)}`
|
||||
case 'replace':
|
||||
return `Changed from ${this.getOldValue(
|
||||
operation.path,
|
||||
)} to ${this.getNewValue(operation.value)}`
|
||||
default:
|
||||
return `Unknown operation`
|
||||
}
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { CopyService } from '@start9labs/shared'
|
||||
import { mask } from 'src/app/util/mask'
|
||||
import { TuiLabelModule } from '@taiga-ui/core'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
|
||||
@Component({
|
||||
selector: 'service-credential',
|
||||
template: `
|
||||
<label [style.flex]="1" [tuiLabel]="label">
|
||||
{{ masked ? mask : value }}
|
||||
</label>
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="flat"
|
||||
[iconLeft]="masked ? 'tuiIconEyeLarge' : 'tuiIconEyeOffLarge'"
|
||||
(click)="masked = !masked"
|
||||
>
|
||||
Toggle
|
||||
</button>
|
||||
<button
|
||||
tuiIconButton
|
||||
appearance="flat"
|
||||
iconLeft="tuiIconCopyLarge"
|
||||
(click)="copyService.copy(value)"
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
display: flex;
|
||||
padding: 0.5rem 0;
|
||||
|
||||
&:not(:last-of-type) {
|
||||
box-shadow: 0 1px var(--tui-clear);
|
||||
}
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [TuiButtonModule, TuiLabelModule],
|
||||
})
|
||||
export class ServiceCredentialComponent {
|
||||
@Input()
|
||||
label = ''
|
||||
|
||||
@Input()
|
||||
value = ''
|
||||
|
||||
masked = true
|
||||
|
||||
readonly copyService = inject(CopyService)
|
||||
|
||||
get mask(): string {
|
||||
return mask(this.value, 64)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { ServiceDependencyComponent } from './dependency.component'
|
||||
import { DependencyInfo } from '../types/dependency-info'
|
||||
|
||||
@Component({
|
||||
selector: 'service-dependencies',
|
||||
template: `
|
||||
<h3 class="g-title">Dependencies</h3>
|
||||
<button
|
||||
*ngFor="let dep of dependencies"
|
||||
class="g-action"
|
||||
[serviceDependency]="dep"
|
||||
(click)="dep.action()"
|
||||
></button>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [CommonModule, ServiceDependencyComponent],
|
||||
})
|
||||
export class ServiceDependenciesComponent {
|
||||
@Input({ required: true })
|
||||
dependencies: readonly DependencyInfo[] = []
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { EmverPipesModule } from '@start9labs/shared'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { TuiSvgModule } from '@taiga-ui/core'
|
||||
import { DependencyInfo } from '../types/dependency-info'
|
||||
|
||||
@Component({
|
||||
selector: '[serviceDependency]',
|
||||
template: `
|
||||
<img [src]="dep.icon" alt="" />
|
||||
<span [style.flex]="1">
|
||||
<strong>
|
||||
<tui-svg
|
||||
*ngIf="dep.errorText"
|
||||
src="tuiIconAlertTriangle"
|
||||
[style.color]="color"
|
||||
></tui-svg>
|
||||
{{ dep.title }}
|
||||
</strong>
|
||||
<div>{{ dep.version | displayEmver }}</div>
|
||||
<div [style.color]="color">
|
||||
{{ dep.errorText || 'Satisfied' }}
|
||||
</div>
|
||||
</span>
|
||||
<div *ngIf="dep.actionText">
|
||||
{{ dep.actionText }}
|
||||
<tui-svg src="tuiIconArrowRight"></tui-svg>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
img {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 100%;
|
||||
}
|
||||
|
||||
tui-svg {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [EmverPipesModule, CommonModule, TuiSvgModule],
|
||||
})
|
||||
export class ServiceDependencyComponent {
|
||||
@Input({ required: true, alias: 'serviceDependency' })
|
||||
dep!: DependencyInfo
|
||||
|
||||
get color(): string {
|
||||
return this.dep.errorText
|
||||
? 'var(--tui-warning-fill)'
|
||||
: 'var(--tui-success-fill)'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { TuiLoaderModule, TuiSvgModule } from '@taiga-ui/core'
|
||||
import {
|
||||
HealthCheckResult,
|
||||
HealthResult,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Component({
|
||||
selector: 'service-health-check',
|
||||
template: `
|
||||
<tui-loader
|
||||
*ngIf="loading; else svg"
|
||||
[class.tui-skeleton]="!connected"
|
||||
[inheritColor]="!check.result"
|
||||
></tui-loader>
|
||||
<ng-template #svg>
|
||||
<tui-svg
|
||||
[src]="icon"
|
||||
[class.tui-skeleton]="!connected"
|
||||
[style.color]="color"
|
||||
></tui-svg>
|
||||
</ng-template>
|
||||
<div>
|
||||
<strong [class.tui-skeleton]="!connected">{{ check.name }}</strong>
|
||||
<div [class.tui-skeleton]="!connected" [style.color]="color">
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:first-letter {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
tui-loader {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [CommonModule, TuiLoaderModule, TuiSvgModule],
|
||||
})
|
||||
export class ServiceHealthCheckComponent {
|
||||
@Input({ required: true })
|
||||
check!: HealthCheckResult
|
||||
|
||||
@Input()
|
||||
connected = false
|
||||
|
||||
get loading(): boolean {
|
||||
const { result } = this.check
|
||||
|
||||
return (
|
||||
!result ||
|
||||
result === HealthResult.Starting ||
|
||||
result === HealthResult.Loading
|
||||
)
|
||||
}
|
||||
|
||||
get icon(): string {
|
||||
switch (this.check.result) {
|
||||
case HealthResult.Success:
|
||||
return 'tuiIconCheckLarge'
|
||||
case HealthResult.Failure:
|
||||
return 'tuiIconAlertTriangleLarge'
|
||||
default:
|
||||
return 'tuiIconMinusLarge'
|
||||
}
|
||||
}
|
||||
|
||||
get color(): string {
|
||||
switch (this.check.result) {
|
||||
case HealthResult.Success:
|
||||
return 'var(--tui-success-fill)'
|
||||
case HealthResult.Failure:
|
||||
return 'var(--tui-warning-fill)'
|
||||
case HealthResult.Starting:
|
||||
case HealthResult.Loading:
|
||||
return 'var(--tui-primary)'
|
||||
default:
|
||||
return 'var(--tui-text-02)'
|
||||
}
|
||||
}
|
||||
|
||||
get message(): string {
|
||||
if (!this.check.result) {
|
||||
return 'Awaiting result...'
|
||||
}
|
||||
|
||||
const prefix =
|
||||
this.check.result !== HealthResult.Failure &&
|
||||
this.check.result !== HealthResult.Loading
|
||||
? this.check.result
|
||||
: ''
|
||||
|
||||
switch (this.check.result) {
|
||||
case HealthResult.Failure:
|
||||
return prefix + this.check.error
|
||||
case HealthResult.Starting:
|
||||
return `${prefix}...`
|
||||
case HealthResult.Success:
|
||||
return `${prefix}: ${this.check.message}`
|
||||
case HealthResult.Loading:
|
||||
return prefix + this.check.message
|
||||
default:
|
||||
return prefix
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { HealthCheckResult } from 'src/app/services/patch-db/data-model'
|
||||
import { ConnectionService } from 'src/app/services/connection.service'
|
||||
import { ServiceHealthCheckComponent } from './health-check.component'
|
||||
|
||||
@Component({
|
||||
selector: 'service-health-checks',
|
||||
template: `
|
||||
<h3 class="g-title">Health Checks</h3>
|
||||
<service-health-check
|
||||
*ngFor="let check of checks"
|
||||
class="g-action"
|
||||
[check]="check"
|
||||
[connected]="!!(connected$ | async)"
|
||||
></service-health-check>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [CommonModule, ServiceHealthCheckComponent],
|
||||
})
|
||||
export class ServiceHealthChecksComponent {
|
||||
@Input({ required: true })
|
||||
checks: readonly HealthCheckResult[] = []
|
||||
|
||||
readonly connected$ = inject(ConnectionService).connected$
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { DOCUMENT, CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
inject,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { TuiSvgModule } from '@taiga-ui/core'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { ConfigService } from 'src/app/services/config.service'
|
||||
import { InterfaceInfo } from 'src/app/services/patch-db/data-model'
|
||||
import { ExtendedInterfaceInfo } from '../pipes/interface-info.pipe'
|
||||
|
||||
@Component({
|
||||
selector: 'a[serviceInterface]',
|
||||
template: `
|
||||
<tui-svg [src]="info.icon" [style.color]="info.color"></tui-svg>
|
||||
<div [style.flex]="1">
|
||||
<strong>{{ info.name }}</strong>
|
||||
<div>{{ info.description }}</div>
|
||||
<div [style.color]="info.color">{{ info.typeDetail }}</div>
|
||||
</div>
|
||||
<button
|
||||
*ngIf="info.type === 'ui'"
|
||||
tuiIconButton
|
||||
appearance="flat"
|
||||
iconLeft="tuiIconExternalLinkLarge"
|
||||
[style.border-radius.%]="100"
|
||||
(click.stop.prevent)="launchUI(info)"
|
||||
[disabled]="disabled"
|
||||
></button>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [TuiButtonModule, CommonModule, TuiSvgModule],
|
||||
})
|
||||
export class ServiceInterfaceComponent {
|
||||
private readonly document = inject(DOCUMENT)
|
||||
private readonly config = inject(ConfigService)
|
||||
|
||||
@Input({ required: true, alias: 'serviceInterface' })
|
||||
info!: ExtendedInterfaceInfo
|
||||
|
||||
@Input()
|
||||
disabled = false
|
||||
|
||||
launchUI(info: InterfaceInfo) {
|
||||
this.document.defaultView?.open(
|
||||
this.config.launchableAddress(info),
|
||||
'_blank',
|
||||
'noreferrer',
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NgForOf } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { PackagePlus } from 'src/app/services/patch-db/data-model'
|
||||
import {
|
||||
PackageStatus,
|
||||
PrimaryStatus,
|
||||
} from 'src/app/services/pkg-status-rendering.service'
|
||||
import { InterfaceInfoPipe } from '../pipes/interface-info.pipe'
|
||||
import { ServiceInterfaceComponent } from './interface.component'
|
||||
import { RouterLink } from '@angular/router'
|
||||
|
||||
@Component({
|
||||
selector: 'service-interfaces',
|
||||
template: `
|
||||
<h3 class="g-title">Interfaces</h3>
|
||||
<a
|
||||
*ngFor="let info of service.pkg | interfaceInfo"
|
||||
class="g-action"
|
||||
[serviceInterface]="info"
|
||||
[disabled]="!isRunning(service.status)"
|
||||
[routerLink]="info.routerLink"
|
||||
></a>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [NgForOf, RouterLink, InterfaceInfoPipe, ServiceInterfaceComponent],
|
||||
})
|
||||
export class ServiceInterfacesComponent {
|
||||
@Input({ required: true })
|
||||
service!: PackagePlus
|
||||
|
||||
isRunning({ primary }: PackageStatus): boolean {
|
||||
return primary === PrimaryStatus.Running
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { TuiSvgModule } from '@taiga-ui/core'
|
||||
import { ServiceMenu } from '../pipes/to-menu.pipe'
|
||||
|
||||
@Component({
|
||||
selector: '[serviceMenuItem]',
|
||||
template: `
|
||||
<tui-svg [src]="menu.icon"></tui-svg>
|
||||
<div [style.flex]="1">
|
||||
<strong>{{ menu.name }}</strong>
|
||||
<div>
|
||||
{{ menu.description }}
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
<tui-svg src="tuiIconChevronRightLarge"></tui-svg>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [TuiSvgModule],
|
||||
})
|
||||
export class ServiceMenuItemComponent {
|
||||
@Input({ required: true, alias: 'serviceMenuItem' })
|
||||
menu!: ServiceMenu
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
import { ToMenuPipe } from '../pipes/to-menu.pipe'
|
||||
import { ServiceMenuItemComponent } from './menu-item.component'
|
||||
|
||||
@Component({
|
||||
selector: 'service-menu',
|
||||
template: `
|
||||
<h3 class="g-title">Menu</h3>
|
||||
<button
|
||||
*ngFor="let menu of service | toMenu"
|
||||
class="g-action"
|
||||
[serviceMenuItem]="menu"
|
||||
(click)="menu.action()"
|
||||
>
|
||||
<div *ngIf="menu.name === 'Outbound Proxy'" [style.color]="color">
|
||||
{{ proxy }}
|
||||
</div>
|
||||
</button>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [CommonModule, ToMenuPipe, ServiceMenuItemComponent],
|
||||
})
|
||||
export class ServiceMenuComponent {
|
||||
@Input({ required: true })
|
||||
service!: PackageDataEntry
|
||||
|
||||
get color(): string {
|
||||
return this.service.installed?.outboundProxy
|
||||
? 'var(--tui-success-fill)'
|
||||
: 'var(--tui-warning-fill)'
|
||||
}
|
||||
|
||||
get proxy(): string {
|
||||
switch (this.service.installed?.outboundProxy) {
|
||||
case 'primary':
|
||||
return 'System Primary'
|
||||
case 'mirror':
|
||||
return 'Mirror P2P'
|
||||
default:
|
||||
return this.service.installed?.outboundProxy?.proxyId || 'None'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ChangeDetectionStrategy, Component, Input } from '@angular/core'
|
||||
import { TuiProgressModule } from '@taiga-ui/kit'
|
||||
|
||||
@Component({
|
||||
selector: '[progress]',
|
||||
template: `
|
||||
<ng-content></ng-content>
|
||||
: {{ progress }}%
|
||||
<progress
|
||||
tuiProgressBar
|
||||
new
|
||||
size="xs"
|
||||
[style.color]="
|
||||
progress === 100 ? 'var(--tui-positive)' : 'var(--tui-link)'
|
||||
"
|
||||
[value]="progress / 100"
|
||||
></progress>
|
||||
`,
|
||||
styles: [':host { line-height: 2rem }'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [TuiProgressModule],
|
||||
})
|
||||
export class ServiceProgressComponent {
|
||||
@Input({ required: true })
|
||||
progress = 0
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import {
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
HostBinding,
|
||||
Input,
|
||||
} from '@angular/core'
|
||||
import { InstallProgress } from 'src/app/services/patch-db/data-model'
|
||||
import { StatusRendering } from 'src/app/services/pkg-status-rendering.service'
|
||||
import { InstallProgressPipeModule } from 'src/app/common/install-progress/install-progress.module'
|
||||
|
||||
@Component({
|
||||
selector: 'service-status',
|
||||
template: `
|
||||
<strong *ngIf="!installProgress; else installing">
|
||||
{{ connected ? rendering.display : 'Unknown' }}
|
||||
<!-- @TODO should show 'this may take a while' if sigterm-timeout is > 30s -->
|
||||
<span *ngIf="rendering.showDots" class="loading-dots"></span>
|
||||
</strong>
|
||||
<ng-template #installing>
|
||||
<strong *ngIf="installProgress | installProgressDisplay as progress">
|
||||
Installing
|
||||
<span class="loading-dots"></span>
|
||||
{{ progress }}
|
||||
</strong>
|
||||
</ng-template>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
font-size: x-large;
|
||||
margin: 1em 0;
|
||||
display: block;
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [CommonModule, InstallProgressPipeModule],
|
||||
})
|
||||
export class ServiceStatusComponent {
|
||||
@Input({ required: true })
|
||||
rendering!: StatusRendering
|
||||
|
||||
@Input()
|
||||
installProgress?: InstallProgress
|
||||
|
||||
@Input()
|
||||
connected = false
|
||||
|
||||
@HostBinding('style.color')
|
||||
get color(): string {
|
||||
if (!this.connected) return 'var(--tui-text-02)'
|
||||
|
||||
switch (this.rendering.color) {
|
||||
case 'danger':
|
||||
return 'var(--tui-error-fill)'
|
||||
case 'warning':
|
||||
return 'var(--tui-warning-fill)'
|
||||
case 'success':
|
||||
return 'var(--tui-success-fill)'
|
||||
case 'primary':
|
||||
return 'var(--tui-info-fill)'
|
||||
default:
|
||||
return 'var(--tui-text-02)'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Component, Inject, ViewChild } from '@angular/core'
|
||||
import {
|
||||
ErrorService,
|
||||
getErrorMessage,
|
||||
isEmptyObject,
|
||||
LoadingService,
|
||||
} from '@start9labs/shared'
|
||||
import { InputSpec } from '@start9labs/start-sdk/lib/config/configTypes'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import {
|
||||
TuiDialogContext,
|
||||
TuiDialogService,
|
||||
TuiLoaderModule,
|
||||
TuiModeModule,
|
||||
TuiNotificationModule,
|
||||
} from '@taiga-ui/core'
|
||||
import { TUI_PROMPT, TuiPromptData } from '@taiga-ui/kit'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { compare, Operation } from 'fast-json-patch'
|
||||
import { PatchDB } from 'patch-db-client'
|
||||
import { endWith, firstValueFrom, Subscription } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import {
|
||||
DataModel,
|
||||
PackageDataEntry,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
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'
|
||||
import { InvalidService } from 'src/app/common/form/invalid.service'
|
||||
import { ActionButton, FormPage } from 'src/app/apps/ui/modals/form/form.page'
|
||||
import { FormPageModule } from 'src/app/apps/ui/modals/form/form.module'
|
||||
import { PackageConfigData } from '../types/package-config-data'
|
||||
import { ConfigDepComponent } from '../components/config-dep.component'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<tui-loader
|
||||
*ngIf="loadingText"
|
||||
size="l"
|
||||
[textContent]="loadingText"
|
||||
></tui-loader>
|
||||
|
||||
<tui-notification
|
||||
*ngIf="!loadingText && (loadingError || !pkg)"
|
||||
status="error"
|
||||
>
|
||||
<div [innerHTML]="loadingError"></div>
|
||||
</tui-notification>
|
||||
|
||||
<ng-container *ngIf="!loadingText && !loadingError && pkg">
|
||||
<tui-notification *ngIf="success" status="success">
|
||||
{{ pkg.manifest.title }} has been automatically configured with
|
||||
recommended defaults. Make whatever changes you want, then click "Save".
|
||||
</tui-notification>
|
||||
|
||||
<config-dep
|
||||
*ngIf="dependentInfo && value && original"
|
||||
[package]="pkg.manifest.title"
|
||||
[dep]="dependentInfo.title"
|
||||
[original]="original"
|
||||
[value]="value"
|
||||
></config-dep>
|
||||
|
||||
<tui-notification *ngIf="!pkg.installed?.['has-config']" status="warning">
|
||||
No config options for {{ pkg.manifest.title }}
|
||||
{{ pkg.manifest.version }}.
|
||||
</tui-notification>
|
||||
|
||||
<form-page
|
||||
tuiMode="onDark"
|
||||
[spec]="spec"
|
||||
[value]="value || {}"
|
||||
[buttons]="buttons"
|
||||
[patch]="patch"
|
||||
>
|
||||
<button
|
||||
tuiButton
|
||||
appearance="flat"
|
||||
type="reset"
|
||||
[style.margin-right]="'auto'"
|
||||
>
|
||||
Reset Defaults
|
||||
</button>
|
||||
</form-page>
|
||||
</ng-container>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
tui-notification {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormPageModule,
|
||||
TuiLoaderModule,
|
||||
TuiNotificationModule,
|
||||
TuiButtonModule,
|
||||
TuiModeModule,
|
||||
ConfigDepComponent,
|
||||
],
|
||||
providers: [InvalidService],
|
||||
})
|
||||
export class ServiceConfigModal {
|
||||
@ViewChild(FormPage)
|
||||
private readonly form?: FormPage<Record<string, any>>
|
||||
|
||||
readonly pkgId = this.context.data.pkgId
|
||||
readonly dependentInfo = this.context.data.dependentInfo
|
||||
|
||||
loadingError = ''
|
||||
loadingText = this.dependentInfo
|
||||
? `Setting properties to accommodate ${this.dependentInfo.title}`
|
||||
: 'Loading Config'
|
||||
|
||||
pkg?: PackageDataEntry
|
||||
spec: InputSpec = {}
|
||||
patch: Operation[] = []
|
||||
buttons: ActionButton<any>[] = [
|
||||
{
|
||||
text: 'Save',
|
||||
handler: value => this.save(value),
|
||||
},
|
||||
]
|
||||
|
||||
original: object | null = null
|
||||
value: object | null = null
|
||||
|
||||
constructor(
|
||||
@Inject(POLYMORPHEUS_CONTEXT)
|
||||
private readonly context: TuiDialogContext<void, PackageConfigData>,
|
||||
private readonly dialogs: TuiDialogService,
|
||||
private readonly errorService: ErrorService,
|
||||
private readonly loader: LoadingService,
|
||||
private readonly embassyApi: ApiService,
|
||||
private readonly patchDb: PatchDB<DataModel>,
|
||||
) {}
|
||||
|
||||
get success(): boolean {
|
||||
return (
|
||||
!!this.form &&
|
||||
!this.form.form.dirty &&
|
||||
!this.original &&
|
||||
!this.pkg?.installed?.status?.configured
|
||||
)
|
||||
}
|
||||
|
||||
async ngOnInit() {
|
||||
try {
|
||||
this.pkg = await getPackage(this.patchDb, this.pkgId)
|
||||
|
||||
if (!this.pkg) {
|
||||
this.loadingError = 'This service does not exist'
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (this.dependentInfo) {
|
||||
const depConfig = await this.embassyApi.dryConfigureDependency({
|
||||
'dependency-id': this.pkgId,
|
||||
'dependent-id': this.dependentInfo.id,
|
||||
})
|
||||
|
||||
this.original = depConfig['old-config']
|
||||
this.value = depConfig['new-config'] || this.original
|
||||
this.spec = depConfig.spec
|
||||
this.patch = compare(this.original, this.value)
|
||||
} else {
|
||||
const { config, spec } = await this.embassyApi.getPackageConfig({
|
||||
id: this.pkgId,
|
||||
})
|
||||
|
||||
this.original = config
|
||||
this.value = config
|
||||
this.spec = spec
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.loadingError = getErrorMessage(e)
|
||||
} finally {
|
||||
this.loadingText = ''
|
||||
}
|
||||
}
|
||||
|
||||
private async save(config: any) {
|
||||
const loader = new Subscription()
|
||||
|
||||
try {
|
||||
await this.uploadFiles(config, loader)
|
||||
|
||||
if (hasCurrentDeps(this.pkg!)) {
|
||||
await this.configureDeps(config, loader)
|
||||
} else {
|
||||
await this.configure(config, loader)
|
||||
}
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
loader.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadFiles(config: Record<string, any>, loader: Subscription) {
|
||||
loader.unsubscribe()
|
||||
loader.closed = false
|
||||
|
||||
// TODO: Could be nested files
|
||||
const keys = Object.keys(config).filter(key => config[key] instanceof File)
|
||||
const message = `Uploading File${keys.length > 1 ? 's' : ''}...`
|
||||
|
||||
if (!keys.length) return
|
||||
|
||||
loader.add(this.loader.open(message).subscribe())
|
||||
|
||||
const hashes = await Promise.all(
|
||||
keys.map(key => this.embassyApi.uploadFile(config[key])),
|
||||
)
|
||||
keys.forEach((key, i) => (config[key] = hashes[i]))
|
||||
}
|
||||
|
||||
private async configureDeps(
|
||||
config: Record<string, any>,
|
||||
loader: Subscription,
|
||||
) {
|
||||
loader.unsubscribe()
|
||||
loader.closed = false
|
||||
loader.add(this.loader.open('Checking dependent services...').subscribe())
|
||||
|
||||
const breakages = await this.embassyApi.drySetPackageConfig({
|
||||
id: this.pkgId,
|
||||
config,
|
||||
})
|
||||
|
||||
loader.unsubscribe()
|
||||
loader.closed = false
|
||||
|
||||
if (isEmptyObject(breakages) || (await this.approveBreakages(breakages))) {
|
||||
await this.configure(config, loader)
|
||||
}
|
||||
}
|
||||
|
||||
private async configure(config: Record<string, any>, loader: Subscription) {
|
||||
loader.unsubscribe()
|
||||
loader.closed = false
|
||||
loader.add(this.loader.open('Saving...').subscribe())
|
||||
|
||||
await this.embassyApi.setPackageConfig({ id: this.pkgId, config })
|
||||
this.context.$implicit.complete()
|
||||
}
|
||||
|
||||
private async approveBreakages(breakages: Breakages): Promise<boolean> {
|
||||
const packages = await getAllPackages(this.patchDb)
|
||||
const message =
|
||||
'As a result of this change, the following services will no longer work properly and may crash:<ul>'
|
||||
const content = `${message}${Object.keys(breakages).map(
|
||||
id => `<li><b>${packages[id].manifest.title}</b></li>`,
|
||||
)}</ul>`
|
||||
const data: TuiPromptData = { content, yes: 'Continue', no: 'Cancel' }
|
||||
|
||||
return firstValueFrom(
|
||||
this.dialogs.open<boolean>(TUI_PROMPT, { data }).pipe(endWith(false)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
|
||||
import { ErrorService, SharedPipesModule } from '@start9labs/shared'
|
||||
import { TuiForModule } from '@taiga-ui/cdk'
|
||||
import { TuiButtonModule } from '@taiga-ui/experimental'
|
||||
import { POLYMORPHEUS_CONTEXT } from '@tinkoff/ng-polymorpheus'
|
||||
import { BehaviorSubject } from 'rxjs'
|
||||
import { ApiService } from 'src/app/services/api/embassy-api.service'
|
||||
import { SkeletonListComponentModule } from 'src/app/common/skeleton-list/skeleton-list.component.module'
|
||||
import { ServiceCredentialComponent } from '../components/credential.component'
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<skeleton-list *ngIf="loading$ | async; else loaded"></skeleton-list>
|
||||
<ng-template #loaded>
|
||||
<service-credential
|
||||
*ngFor="let cred of credentials | keyvalue : asIsOrder; empty: blank"
|
||||
[label]="cred.key"
|
||||
[value]="cred.value"
|
||||
></service-credential>
|
||||
</ng-template>
|
||||
<ng-template #blank>No credentials</ng-template>
|
||||
<button tuiButton icon="tuiIconRefreshCwLarge" (click)="refresh()">
|
||||
Refresh
|
||||
</button>
|
||||
`,
|
||||
styles: [
|
||||
`
|
||||
button {
|
||||
float: right;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
`,
|
||||
],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
TuiForModule,
|
||||
TuiButtonModule,
|
||||
SharedPipesModule,
|
||||
SkeletonListComponentModule,
|
||||
ServiceCredentialComponent,
|
||||
],
|
||||
})
|
||||
export class ServiceCredentialsModal {
|
||||
private readonly api = inject(ApiService)
|
||||
private readonly errorService = inject(ErrorService)
|
||||
|
||||
readonly id = inject<{ data: string }>(POLYMORPHEUS_CONTEXT).data
|
||||
readonly loading$ = new BehaviorSubject(true)
|
||||
|
||||
credentials: Record<string, string> = {}
|
||||
|
||||
async ngOnInit() {
|
||||
await this.getCredentials()
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
await this.getCredentials()
|
||||
}
|
||||
|
||||
private async getCredentials(): Promise<void> {
|
||||
this.loading$.next(true)
|
||||
|
||||
try {
|
||||
this.credentials = await this.api.getPackageCredentials({ id: this.id })
|
||||
} catch (e: any) {
|
||||
this.errorService.handleError(e)
|
||||
} finally {
|
||||
this.loading$.next(false)
|
||||
}
|
||||
}
|
||||
|
||||
asIsOrder(a: any, b: any) {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import { WithId } from '@start9labs/shared'
|
||||
import { Action, PackageDataEntry } from 'src/app/services/patch-db/data-model'
|
||||
|
||||
@Pipe({
|
||||
name: 'groupActions',
|
||||
standalone: true,
|
||||
})
|
||||
export class GroupActionsPipe implements PipeTransform {
|
||||
transform(
|
||||
actions: PackageDataEntry['actions'],
|
||||
): Array<Array<WithId<Action>>> | null {
|
||||
if (!actions) return null
|
||||
|
||||
const noGroup = 'noGroup'
|
||||
const grouped = Object.entries(actions).reduce<
|
||||
Record<string, WithId<Action>[]>
|
||||
>((groups, [id, action]) => {
|
||||
const actionWithId = { id, ...action }
|
||||
const groupKey = action.group || noGroup
|
||||
|
||||
if (!groups[groupKey]) {
|
||||
groups[groupKey] = [actionWithId]
|
||||
} else {
|
||||
groups[groupKey].push(actionWithId)
|
||||
}
|
||||
|
||||
return groups
|
||||
}, {})
|
||||
|
||||
return Object.values(grouped).map(group =>
|
||||
group.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { Pipe, PipeTransform } from '@angular/core'
|
||||
import {
|
||||
InterfaceInfo,
|
||||
PackageDataEntry,
|
||||
} from 'src/app/services/patch-db/data-model'
|
||||
|
||||
export interface ExtendedInterfaceInfo extends InterfaceInfo {
|
||||
id: string
|
||||
icon: string
|
||||
color: string
|
||||
typeDetail: string
|
||||
routerLink: string
|
||||
}
|
||||
|
||||
@Pipe({
|
||||
name: 'interfaceInfo',
|
||||
standalone: true,
|
||||
})
|
||||
export class InterfaceInfoPipe implements PipeTransform {
|
||||
transform({ installed }: PackageDataEntry): ExtendedInterfaceInfo[] {
|
||||
return Object.entries(installed!.interfaceInfo).map(([id, val]) => {
|
||||
let color: string
|
||||
let icon: string
|
||||
let typeDetail: string
|
||||
|
||||
switch (val.type) {
|
||||
case 'ui':
|
||||
color = 'var(--tui-primary)'
|
||||
icon = 'tuiIconMonitorLarge'
|
||||
typeDetail = 'User Interface (UI)'
|
||||
break
|
||||
case 'p2p':
|
||||
color = 'var(--tui-info-fill)'
|
||||
icon = 'tuiIconUsersLarge'
|
||||
typeDetail = 'Peer-To-Peer Interface (P2P)'
|
||||
break
|
||||
case 'api':
|
||||
color = 'var(--tui-support-09)'
|
||||
icon = 'tuiIconTerminalLarge'
|
||||
typeDetail = 'Application Program Interface (API)'
|
||||
break
|
||||
case 'other':
|
||||
color = 'var(--tui-text-02)'
|
||||
icon = 'tuiIconBoxLarge'
|
||||
typeDetail = 'Unknown Interface Type'
|
||||
break
|
||||
}
|
||||
|
||||
return {
|
||||
...val,
|
||||
id,
|
||||
color,
|
||||
icon,
|
||||
typeDetail,
|
||||
routerLink: `./interface/${id}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user