rename frontend to web

This commit is contained in:
Matt Hill
2023-11-13 15:59:16 -07:00
parent 09303ab2fb
commit 862ca375ee
869 changed files with 0 additions and 66 deletions

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

View File

@@ -0,0 +1,125 @@
:host {
display: block;
height: 100%;
}
tui-root {
height: 100%;
}
.left-menu {
--side-max-width: 280px;
}
.menu {
:host-context(body[data-theme='Light']) & {
--ion-color-base: #F4F4F5 !important;
}
}
.container {
transition: filter 0.3s;
&_offline {
filter: saturate(0.75) contrast(0.85);
}
@media screen and (max-width: 991.499px) {
--widgets-width: 0px;
}
}
.right-menu {
--side-max-width: 600px;
position: fixed;
z-index: 1000;
right: 0;
left: auto;
top: 74px;
// For some reason *ngIf is broken upon first login
&_hidden {
display: none;
}
}
.divider {
height: 100%;
width: 10px;
pointer-events: none;
position: absolute;
left: 0;
top: 0;
bottom: 0;
background: #e2e2e2;
z-index: 10;
opacity: 0.2;
transition: opacity 0.3s;
&:before,
&:after {
content: '';
position: absolute;
top: 50%;
margin-top: -78px;
left: 10px;
width: 60px;
height: 50px;
border-bottom-left-radius: 14px;
box-shadow: -14px 0 0 -1px #e2e2e2;
}
&:after {
margin-top: 28px;
border-radius: 0;
border-top-left-radius: 14px;
}
&:hover {
opacity: 0.4;
}
}
.widgets-button {
position: absolute;
top: 50%;
font-size: 0;
left: 100%;
width: 16px;
height: 60px;
margin-top: -30px;
border-top-right-radius: 10px;
border-bottom-right-radius: 10px;
background: inherit;
pointer-events: auto;
&:before,
&:after {
content: '';
position: absolute;
top: 50%;
left: 3px;
width: 2px;
height: 8px;
background: black;
transform: rotate(-45deg);
border-radius: 2px;
}
&:before {
margin-top: -5px;
transform: rotate(45deg);
}
&_collapse:before {
transform: rotate(-45deg);
}
&_collapse:after {
transform: rotate(45deg);
}
}

View File

@@ -0,0 +1,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()
}
}

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

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

View File

@@ -0,0 +1,16 @@
<ion-toolbar
*ngIf="connection$ | async as connection"
class="connection-toolbar"
[color]="connection.color"
>
<div class="inline" slot="start">
<ion-icon [name]="connection.icon" class="icon"></ion-icon>
<p style="margin: 8px 0; font-weight: 600">{{ connection.message }}</p>
<ion-spinner
*ngIf="connection.dots"
name="dots"
color="light"
class="ion-margin-start"
></ion-spinner>
</div>
</ion-toolbar>

View File

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

View File

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

View File

@@ -0,0 +1,71 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import { combineLatest, map, Observable, startWith } from 'rxjs'
import { ConnectionService } from 'src/app/services/connection.service'
import { DataModel } from 'src/app/services/patch-db/data-model'
@Component({
selector: 'connection-bar',
templateUrl: './connection-bar.component.html',
styleUrls: ['./connection-bar.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class ConnectionBarComponent {
private readonly websocket$ = this.connectionService.websocketConnected$
readonly connection$: Observable<{
message: string
color: string
icon: string
dots: boolean
}> = combineLatest([
this.connectionService.networkConnected$,
this.websocket$.pipe(startWith(false)),
this.patch
.watch$('server-info', 'status-info')
.pipe(startWith({ restarting: false, 'shutting-down': false })),
]).pipe(
map(([network, websocket, status]) => {
if (!network)
return {
message: 'No Internet',
color: 'danger',
icon: 'cloud-offline-outline',
dots: false,
}
if (!websocket)
return {
message: 'Connecting',
color: 'warning',
icon: 'cloud-offline-outline',
dots: true,
}
if (status['shutting-down'])
return {
message: 'Shutting Down',
color: 'dark',
icon: 'power',
dots: true,
}
if (status.restarting)
return {
message: 'Restarting',
color: 'dark',
icon: 'power',
dots: true,
}
return {
message: 'Connected',
color: 'success',
icon: 'cloud-done',
dots: false,
}
}),
)
constructor(
private readonly connectionService: ConnectionService,
private readonly patch: PatchDB<DataModel>,
) {}
}

View File

@@ -0,0 +1,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>

View File

@@ -0,0 +1,9 @@
.list {
box-shadow: inset 0 1px var(--ion-color-dark);
box-sizing: border-box;
}
.progress {
width: auto;
margin: 0 16px 16px 16px;
}

View File

@@ -0,0 +1,32 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs'
import { heightCollapse } from 'src/app/util/animations'
import { DataModel } from 'src/app/services/patch-db/data-model'
@Component({
selector: 'footer[appFooter]',
templateUrl: 'footer.component.html',
styleUrls: ['footer.component.scss'],
animations: [heightCollapse],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FooterComponent {
readonly progress$ = this.patch
.watch$('server-info', 'status-info', 'update-progress')
.pipe(map(a => a && { ...a }))
readonly animation = {
value: '',
params: {
duration: 1000,
delay: 50,
},
}
constructor(private readonly patch: PatchDB<DataModel>) {}
getProgress(size: number, downloaded: number): number {
return Math.round((100 * downloaded) / (size || 1))
}
}

View File

@@ -0,0 +1,12 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { IonicModule } from '@ionic/angular'
import { FooterComponent } from './footer.component'
@NgModule({
imports: [CommonModule, IonicModule],
declarations: [FooterComponent],
exports: [FooterComponent],
})
export class FooterModule {}

View File

@@ -0,0 +1,64 @@
<a class="logo" routerLink="/home">
<img alt="StartOS" src="assets/img/icon.png" />
</a>
<ion-item-group class="menu">
<ion-menu-toggle *ngFor="let page of pages" auto-hide="false">
<ion-item
button
class="link"
routerLinkActive="link_selected"
color="transparent"
routerDirection="root"
lines="none"
detail="false"
[routerLink]="page.url"
>
<ion-icon
slot="start"
class="icon label"
routerLinkActive="label_selected"
[name]="page.icon"
></ion-icon>
<ion-label class="label montserrat" routerLinkActive="label_selected">
{{ page.title }}
</ion-label>
<ion-icon
*ngIf="page.url === '/system' && (warning$ | async)"
color="warning"
size="small"
name="warning"
></ion-icon>
<ion-icon
*ngIf="page.url === '/system' && (showEOSUpdate$ | async)"
color="success"
size="small"
name="rocket"
></ion-icon>
<ion-badge
*ngIf="page.url === '/updates' && (updateCount$ | async) as updateCount"
color="success"
>
{{ updateCount }}
</ion-badge>
<ion-badge
*ngIf="
page.url === '/notifications' &&
(notificationCount$ | async) as notificaitonCount
"
color="danger"
>
{{ notificaitonCount }}
</ion-badge>
</ion-item>
</ion-menu-toggle>
</ion-item-group>
<img
appSnek
class="snek"
alt="Play Snake"
src="assets/img/icons/snek.png"
[appSnekHighScore]="(snekScore$ | async) || 0"
/>
<ion-footer *ngIf="sidebarOpen$ | async" class="bottom">
<connection-bar></connection-bar>
</ion-footer>

View File

@@ -0,0 +1,49 @@
:host {
display: block;
}
.logo {
display: block;
width: 36%;
margin: 0 auto;
padding: 16px 16px 0 16px;
}
.menu {
padding: 30px 0;
}
.link {
--border-radius: 0;
:host-context(body[data-theme='Light']) &_selected {
--ion-color-base: #333;
--ion-color-contrast: #fff;
}
}
.icon {
margin-left: 10px;
}
.label {
color: var(--ion-color-dark-shade);
&_selected {
color: var(--ion-color-dark);
font-weight: bold;
}
}
.snek {
position: absolute;
bottom: 56px;
right: 20px;
width: 20px;
cursor: pointer;
}
.bottom {
position: absolute;
bottom: 0;
}

View File

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

View File

@@ -0,0 +1,20 @@
import { CommonModule } from '@angular/common'
import { NgModule } from '@angular/core'
import { RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { MenuComponent } from './menu.component'
import { SnekModule } from '../snek/snek.module'
import { ConnectionBarComponentModule } from '../connection-bar/connection-bar.component.module'
@NgModule({
imports: [
CommonModule,
IonicModule,
RouterModule,
SnekModule,
ConnectionBarComponentModule,
],
declarations: [MenuComponent],
exports: [MenuComponent],
})
export class MenuModule {}

View File

@@ -0,0 +1,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>

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

View File

@@ -0,0 +1,13 @@
import { CommonModule } from '@angular/common'
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'
import { IonicModule } from '@ionic/angular'
import { QrCodeModule } from 'ng-qrcode'
import { PreloaderComponent } from './preloader.component'
@NgModule({
imports: [CommonModule, IonicModule, QrCodeModule],
declarations: [PreloaderComponent],
exports: [PreloaderComponent],
schemas: [CUSTOM_ELEMENTS_SCHEMA],
})
export class PreloaderModule {}

View File

@@ -0,0 +1,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>

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

View File

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

View File

@@ -0,0 +1,19 @@
import { Component, inject } from '@angular/core'
import { NavController } from '@ionic/angular'
import {
provideSetupLogsService,
provideSetupService,
} from '@start9labs/shared'
import { ApiService } from 'src/app/services/api/embassy-api.service'
@Component({
templateUrl: 'loading.page.html',
providers: [
provideSetupService(ApiService),
provideSetupLogsService(ApiService),
],
})
export class LoadingPage {
readonly navCtrl = inject(NavController)
}

View File

@@ -0,0 +1,106 @@
<div class="center-container">
<ng-container *ngIf="!caTrusted; else trusted">
<ion-card id="untrusted" class="text-center">
<ion-icon name="lock-closed-outline" class="wiz-icon"></ion-icon>
<h1>Trust Your Root CA</h1>
<p>
Download and trust your server's Root Certificate Authority to establish
a secure (HTTPS) connection. You will need to repeat this on every
device you use to connect to your server.
</p>
<ol>
<li>
<b>Bookmark this page</b>
- Save this page so you can access it later. You can also find the
address in the
<code>StartOS-info.html</code>
file downloaded at the end of initial setup.
</li>
<li>
<b>Download your server's Root CA</b>
- Your server uses its Root CA to generate SSL/TLS certificates for
itself and installed services. These certificates are then used to
encrypt network traffic with your client devices.
<br />
<ion-button
strong
size="small"
shape="round"
color="tertiary"
(click)="download()"
>
Download
<ion-icon slot="end" name="download-outline"></ion-icon>
</ion-button>
</li>
<li>
<b>Trust your server's Root CA</b>
- Follow instructions for your OS. By trusting your server's Root CA,
your device can verify the authenticity of encrypted communications
with your server.
<br />
<ion-button
strong
size="small"
shape="round"
color="primary"
href="https://docs.start9.com/0.3.5.x/user-manual/trust-ca#establishing-trust"
target="_blank"
noreferrer
>
View Instructions
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-button>
</li>
<li>
<b>Test</b>
- Refresh the page. If refreshing the page does not work, you may need
to quit and re-open your browser, then revisit this page.
<br />
<ion-button
strong
size="small"
shape="round"
class="refresh"
(click)="refresh()"
>
Refresh
<ion-icon slot="end" name="refresh"></ion-icon>
</ion-button>
</li>
</ol>
<ion-button fill="clear" (click)="launchHttps()" [disabled]="caTrusted">
Skip
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-button>
<span class="skip_detail">(not recommended)</span>
</ion-card>
</ng-container>
<ng-template #trusted>
<ion-card id="trusted" class="text-center">
<ion-icon
name="shield-checkmark-outline"
class="wiz-icon"
color="success"
></ion-icon>
<h1>Root CA Trusted!</h1>
<p>
You have successfully trusted your server's Root CA and may now log in
securely.
</p>
<ion-button strong (click)="launchHttps()" color="tertiary" shape="round">
Go to login
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-button>
</ion-card>
</ng-template>
</div>
<a
id="install-cert"
href="/eos/local.crt"
[download]="
config.isLocal() ? document.location.hostname + '.crt' : 'startos.crt'
"
></a>

View File

@@ -0,0 +1,83 @@
#trusted {
max-width: 40%;
}
#untrusted {
max-width: 50%;
}
.center-container {
padding: 1rem;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
}
ion-card {
color: var(--ion-color-dark);
background: #414141;
box-shadow: 0 4px 4px rgba(17, 17, 17, 0.144);
border-radius: 35px;
padding: 1.5rem;
width: 100%;
h1 {
font-weight: bold;
font-size: 1.5rem;
padding-bottom: 1.5rem;
}
p {
font-size: 21px;
line-height: 25px;
margin-bottom: 30px;
margin-top: 0;
}
}
.text-center {
text-align: center;
}
ol {
font-size: 17px;
line-height: 25px;
text-align: left;
li {
padding-bottom: 24px;
}
ion-button {
margin-top: 10px;
}
}
.refresh {
--background: var(--ion-color-success-shade);
}
.wiz-icon {
font-size: 64px;
}
.skip_detail {
display: block;
font-size: 0.8rem;
margin-top: -13px;
padding-bottom: 0.5rem;
}
@media (max-width: 700px) {
#trusted, #untrusted {
max-width: 100%;
}
}
@media (min-width: 701px) and (max-width: 1200px) {
#trusted, #untrusted {
max-width: 75%;
}
}

View File

@@ -0,0 +1,49 @@
import { Component, Inject } from '@angular/core'
import { ApiService } from 'src/app/services/api/embassy-api.service'
import { ConfigService } from 'src/app/services/config.service'
import { RELATIVE_URL } from '@start9labs/shared'
import { DOCUMENT } from '@angular/common'
import { WINDOW } from '@ng-web-apis/common'
@Component({
selector: 'ca-wizard',
templateUrl: './ca-wizard.component.html',
styleUrls: ['./ca-wizard.component.scss'],
})
export class CAWizardComponent {
caTrusted = false
constructor(
private readonly api: ApiService,
public readonly config: ConfigService,
@Inject(RELATIVE_URL) private readonly relativeUrl: string,
@Inject(DOCUMENT) public readonly document: Document,
@Inject(WINDOW) private readonly windowRef: Window,
) {}
async ngOnInit() {
await this.testHttps().catch(e =>
console.warn('Failed Https connection attempt'),
)
}
download() {
this.document.getElementById('install-cert')?.click()
}
refresh() {
this.document.location.reload()
}
launchHttps() {
const host = this.config.getHost()
this.windowRef.open(`https://${host}`, '_self')
}
private async testHttps() {
const url = `https://${this.document.location.host}${this.relativeUrl}`
await this.api.echo({ message: 'ping' }, url).then(() => {
this.caTrusted = true
})
}
}

View File

@@ -0,0 +1,30 @@
import { NgModule } from '@angular/core'
import { RouterModule, Routes } from '@angular/router'
import { CommonModule } from '@angular/common'
import { FormsModule } from '@angular/forms'
import { IonicModule } from '@ionic/angular'
import { LoginPage } from './login.page'
import { CAWizardComponent } from './ca-wizard/ca-wizard.component'
import { SharedPipesModule } from '@start9labs/shared'
import { TuiHintModule, TuiTooltipModule } from '@taiga-ui/core'
const routes: Routes = [
{
path: '',
component: LoginPage,
},
]
@NgModule({
imports: [
CommonModule,
FormsModule,
IonicModule,
SharedPipesModule,
RouterModule.forChild(routes),
TuiTooltipModule,
TuiHintModule,
],
declarations: [LoginPage, CAWizardComponent],
})
export class LoginPageModule {}

View File

@@ -0,0 +1,93 @@
<ion-content class="content">
<!-- Local HTTP -->
<ng-container *ngIf="config.isLanHttp(); else notLanHttp">
<ca-wizard></ca-wizard>
</ng-container>
<!-- not Local HTTP -->
<ng-template #notLanHttp>
<div *ngIf="config.isTorHttp()" class="banner">
<ion-item color="warning">
<ion-icon slot="start" name="warning-outline"></ion-icon>
<ion-label>
<h2 style="font-weight: bold">Http detected</h2>
<p style="font-weight: 600">
Tor is faster over https. Your Root CA must be trusted.
<a
href="https://docs.start9.com/0.3.5.x/user-manual/trust-ca"
target="_blank"
noreferrer
style="color: black"
>
View instructions
</a>
</p>
</ion-label>
<ion-button slot="end" color="light" (click)="launchHttps()">
Open Https
<ion-icon slot="end" name="open-outline"></ion-icon>
</ion-button>
</ion-item>
</div>
<ion-grid class="grid">
<ion-row class="row">
<ion-col>
<ion-card>
<img
alt="StartOS Icon"
class="header-icon"
src="assets/img/icon.png"
/>
<ion-card-header>
<ion-card-title class="title">Login to StartOS</ion-card-title>
</ion-card-header>
<ion-card-content class="ion-margin">
<form (submit)="submit()">
<ion-item color="dark" fill="solid">
<ion-icon
slot="start"
size="small"
color="base"
name="key-outline"
style="margin-right: 16px"
></ion-icon>
<ion-input
name="password"
placeholder="Password"
[type]="unmasked ? 'text' : 'password'"
[(ngModel)]="password"
(ionChange)="error = ''"
></ion-input>
<ion-button
slot="end"
fill="clear"
color="dark"
(click)="unmasked = !unmasked"
>
<ion-icon
slot="icon-only"
size="small"
[name]="unmasked ? 'eye-off-outline' : 'eye-outline'"
></ion-icon>
</ion-button>
</ion-item>
<p class="error ion-text-center">
<ion-text color="danger">{{ error }}</ion-text>
</p>
<ion-button
class="login-button"
type="submit"
expand="block"
color="tertiary"
>
Login
</ion-button>
</form>
</ion-card-content>
</ion-card>
</ion-col>
</ion-row>
</ion-grid>
</ng-template>
</ion-content>

View File

@@ -0,0 +1,76 @@
.content {
--background: #333333;
}
.grid {
height: 100%;
max-width: 540px;
}
.row {
height: 100%;
align-items: center;
text-align: center;
}
.banner {
position: absolute;
padding: 20px;
width: 100%;
display: inline-block;
ion-item {
max-width: 800px;
margin: auto;
}
}
ion-card {
background: #414141;
box-shadow: 0 4px 4px rgba(17, 17, 17, 0.144);
border-radius: 35px;
min-height: 16rem;
contain: unset;
overflow: unset;
position: relative;
}
ion-item {
--background: transparent;
--border-radius: 0px;
}
.title {
padding-top: 55px;
color: #e0e0e0;
font-size: 1.3rem;
}
.header {
&-icon {
width: 100px;
position: absolute;
left: 50%;
margin-left: -50px;
top: -17%;
z-index: 100;
}
}
.login-button {
height: 45px;
width: 120px;
--border-radius: 50px;
margin: 0 auto;
margin-top: 27px;
margin-bottom: 10px;
}
.item-interactive {
--highlight-background: #5260ff !important;
}
.error {
display: block;
padding-top: 4px;
}

View File

@@ -0,0 +1,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()
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
<header appHeader>My server</header>
<nav appNavigation></nav>
<main>
<router-outlet></router-outlet>
</main>
<app-drawer></app-drawer>

View File

@@ -0,0 +1,10 @@
:host {
// TODO: Theme
background: url(/assets/img/background_dark.jpeg);
background-size: cover;
}
main {
flex: 1;
overflow: hidden;
}

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(' &rarr; ')
}
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'
}
}
}

View File

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

View File

@@ -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[] = []
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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