* feat: add widgets (#2034)

* feat: add Taiga UI library (#1992)

* feat: add widgets

* update patchdb

* right resizable sidebar with widgets

* feat: add resizing directive

* chore: remove unused code

* chore: remove unnecessary dep

* feat: `ResponsiveCol` add directive for responsive grid

* feat: add widgets edit mode and dialogs

* feat: add widgets model and modal

* chore: fix import

* chore: hide mobile widgets behind flag

* chore: add dummy widgets

* chore: start working on heath widget and implement other comments

* feat: health widget

* feat: add saving widgets and sidebar params to patch

* feat: preemptive UI update for widgets

* update health widget with more accurate states and styling (#2127)

* feat: `ResponsiveCol` add directive for responsive grid

* chore: some changes after merge

Co-authored-by: Matt Hill <matthewonthemoon@gmail.com>
Co-authored-by: Lucy C <12953208+elvece@users.noreply.github.com>

* fix(shared): `ElasticContainer` fix collapsing margin (#2150)

* fix(shared): `ElasticContainer` fix collapsing margin

* fix toolbar height so titles not chopped

---------

Co-authored-by: Matt Hill <matthewonthemoon@gmail.com>

* feat: make widgets sidebar width togglable (#2146)

* feat: make widgets sidebar width togglable

* feat: move widgets under header

* chore: fix wide layout

* fix(shared): `ResponsiveCol` fix missing grid steps (#2153)

* fix widget flag and refactor for non-persistence

* default widget flag to false

* fix(shared): fix responsive column size (#2159)

* fix(shared): fix responsive column size

* fix: add responsiveness to all pages

* fix responsiveness on more pages

* fix: comments

* revert some padding changes

---------

Co-authored-by: Lucy Cifferello <12953208+elvece@users.noreply.github.com>
Co-authored-by: Matt Hill <matthewonthemoon@gmail.com>

* chore: add analyzer (#2165)

* fix list styling to previous default (#2173)

* fix list styling to previous default

* dont need important flag

---------

Co-authored-by: Alex Inkin <alexander@inkin.ru>
Co-authored-by: Lucy C <12953208+elvece@users.noreply.github.com>
This commit is contained in:
Matt Hill
2023-03-02 15:21:03 -07:00
committed by Aiden McClelland
parent aeb6da111b
commit e867f31c31
113 changed files with 2452 additions and 295 deletions

View File

@@ -7,7 +7,7 @@
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<ion-content class="ion-padding-top with-widgets">
<ion-item-group *ngIf="pkg$ | async as pkg">
<!-- ** standard actions ** -->
<ion-item-divider>Standard Actions</ion-item-divider>

View File

@@ -7,7 +7,7 @@
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<ion-content class="ion-padding-top with-widgets">
<ion-item-group>
<!-- iff ui -->
<ng-container *ngIf="ui">
@@ -18,9 +18,9 @@
<!-- other interface -->
<ng-container *ngIf="other.length">
<ion-item-divider>Machine Interfaces</ion-item-divider>
<div *ngFor="let interface of other" style="margin-bottom: 30px;">
<div *ngFor="let interface of other" style="margin-bottom: 30px">
<app-interfaces-item [interface]="interface"></app-interfaces-item>
</div>
</ng-container>
</ion-item-group>
</ion-content>
</ion-content>

View File

@@ -5,6 +5,7 @@ import { IonicModule } from '@ionic/angular'
import { AppListPage } from './app-list.page'
import {
EmverPipesModule,
ResponsiveColModule,
TextSpinnerComponentModule,
} from '@start9labs/shared'
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
@@ -35,6 +36,7 @@ const routes: Routes = [
RouterModule.forChild(routes),
BadgeMenuComponentModule,
WidgetListComponentModule,
ResponsiveColModule,
],
declarations: [
AppListPage,

View File

@@ -7,7 +7,7 @@
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-content class="ion-padding with-widgets">
<!-- loaded -->
<ng-container *ngIf="pkgs$ | async as pkgs; else loading">
<ng-container *ngIf="!pkgs.length; else list">
@@ -20,7 +20,12 @@
<ng-template #list>
<ion-grid>
<ion-row>
<ion-col *ngFor="let pkg of pkgs" sizeSm="12" sizeLg="6">
<ion-col
*ngFor="let pkg of pkgs"
responsiveCol
sizeSm="12"
sizeMd="6"
>
<app-list-pkg
*ngIf="pkg | packageInfo | async as info"
[pkg]="info"

View File

@@ -10,7 +10,7 @@
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-content class="ion-padding with-widgets">
<skeleton-list *ngIf="loading"></skeleton-list>
<ion-item-group *ngIf="!loading">
<ion-item *ngFor="let metric of metrics | keyvalue : asIsOrder">

View File

@@ -13,7 +13,7 @@
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<ion-content class="ion-padding-top with-widgets">
<text-spinner
*ngIf="loading; else loaded"
text="Loading Properties"

View File

@@ -16,11 +16,11 @@ import {
PackageMainStatus,
} from 'src/app/services/patch-db/data-model'
import {
DestroyService,
ErrorToastService,
getPkgId,
copyToClipboard,
} from '@start9labs/shared'
import { TuiDestroyService } from '@taiga-ui/cdk'
import { getValueByPointer } from 'fast-json-patch'
import { map, takeUntil } from 'rxjs/operators'
@@ -28,7 +28,7 @@ import { map, takeUntil } from 'rxjs/operators'
selector: 'app-properties',
templateUrl: './app-properties.page.html',
styleUrls: ['./app-properties.page.scss'],
providers: [DestroyService],
providers: [TuiDestroyService],
})
export class AppPropertiesPage {
loading = true
@@ -56,7 +56,7 @@ export class AppPropertiesPage {
private readonly modalCtrl: ModalController,
private readonly navCtrl: NavController,
private readonly patch: PatchDB<DataModel>,
private readonly destroy$: DestroyService,
private readonly destroy$: TuiDestroyService,
) {}
ionViewDidEnter() {

View File

@@ -3,7 +3,7 @@ import { CommonModule } from '@angular/common'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { AppShowPage } from './app-show.page'
import { EmverPipesModule } from '@start9labs/shared'
import { EmverPipesModule, ResponsiveColModule } from '@start9labs/shared'
import { StatusComponentModule } from 'src/app/components/status/status.component.module'
import { AppConfigPageModule } from 'src/app/modals/app-config/app-config.module'
import { LaunchablePipeModule } from 'src/app/pipes/launchable/launchable.module'
@@ -55,6 +55,7 @@ const routes: Routes = [
EmverPipesModule,
LaunchablePipeModule,
UiPipeModule,
ResponsiveColModule,
],
})
export class AppShowPageModule {}

View File

@@ -3,7 +3,7 @@
<app-show-header [pkg]="pkg"></app-show-header>
<!-- content -->
<ion-content class="ion-padding">
<ion-content class="ion-padding with-widgets">
<!-- ** installing, updating, restoring ** -->
<ng-container *ngIf="showProgress(pkg); else installed">
<app-show-progress

View File

@@ -1,7 +1,7 @@
<ion-item-divider>Additional Info</ion-item-divider>
<ion-grid *ngIf="pkg.manifest as manifest">
<ion-row>
<ion-col sizeXs="12" sizeMd="6">
<ion-col responsiveCol sizeXs="12" sizeMd="6">
<ion-item-group>
<ion-item>
<ion-label>
@@ -51,7 +51,7 @@
</ion-item>
</ion-item-group>
</ion-col>
<ion-col sizeXs="12" sizeMd="6">
<ion-col responsiveCol sizeXs="12" sizeMd="6">
<ion-item-group>
<ion-item
[href]="manifest['upstream-repo']"

View File

@@ -16,14 +16,15 @@ import { ConfigSpec } from 'src/app/pkg-config/config-types'
import * as yaml from 'js-yaml'
import { v4 } from 'uuid'
import { DataModel, DevData } from 'src/app/services/patch-db/data-model'
import { DestroyService, ErrorToastService } from '@start9labs/shared'
import { ErrorToastService } from '@start9labs/shared'
import { TuiDestroyService } from '@taiga-ui/cdk'
import { takeUntil } from 'rxjs/operators'
@Component({
selector: 'developer-list',
templateUrl: 'developer-list.page.html',
styleUrls: ['developer-list.page.scss'],
providers: [DestroyService],
providers: [TuiDestroyService],
})
export class DeveloperListPage {
devData: DevData = {}
@@ -34,7 +35,7 @@ export class DeveloperListPage {
private readonly loadingCtrl: LoadingController,
private readonly errToast: ErrorToastService,
private readonly alertCtrl: AlertController,
private readonly destroy$: DestroyService,
private readonly destroy$: TuiDestroyService,
private readonly patch: PatchDB<DataModel>,
private readonly actionCtrl: ActionSheetController,
) {}

View File

@@ -3,7 +3,11 @@ import { CommonModule } from '@angular/common'
import { FormsModule } from '@angular/forms'
import { Routes, RouterModule } from '@angular/router'
import { IonicModule } from '@ionic/angular'
import { SharedPipesModule, EmverPipesModule } from '@start9labs/shared'
import {
SharedPipesModule,
EmverPipesModule,
ResponsiveColModule,
} from '@start9labs/shared'
import {
FilterPackagesPipeModule,
CategoriesModule,
@@ -41,6 +45,7 @@ const routes: Routes = [
SkeletonModule,
MarketplaceSettingsPageModule,
StoreIconComponentModule,
ResponsiveColModule,
],
declarations: [MarketplaceListPage],
exports: [MarketplaceListPage],

View File

@@ -10,7 +10,7 @@
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-content class="ion-padding with-widgets">
<ng-container *ngIf="details$ | async as details">
<ion-item [color]="details.color">
<ion-icon slot="start" name="information-circle-outline"></ion-icon>
@@ -21,7 +21,7 @@
<ion-grid>
<ion-row>
<ion-col size="12">
<ion-col>
<div class="heading">
<store-icon
class="icon"
@@ -38,7 +38,7 @@
</ion-col>
</ion-row>
<ion-row class="ion-align-items-center">
<ion-col size="12">
<ion-col>
<ng-container *ngIf="store$ | async as store; else loading">
<marketplace-categories
[categories]="store.categories"
@@ -55,6 +55,7 @@
<ion-row *ngIf="localPkgs$ | async as localPkgs">
<ion-col
*ngFor="let pkg of filtered"
responsiveCol
sizeXs="12"
sizeSm="12"
sizeMd="6"

View File

@@ -1,6 +1,6 @@
<marketplace-show-header></marketplace-show-header>
<ion-content class="ion-padding">
<ion-content class="ion-padding with-widgets">
<ng-container *ngIf="pkg$ | async as pkg else loading">
<ng-container *ngIf="pkg | empty; else show">
<div

View File

@@ -10,7 +10,7 @@
</ion-toolbar>
</ion-header>
<ion-content>
<ion-content class="with-widgets">
<!-- loading -->
<ion-item-group *ngIf="loading; else loaded">
<ion-item-divider>

View File

@@ -7,7 +7,7 @@
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<ion-content class="ion-padding-top with-widgets">
<ion-item-group>
<!-- about -->
<ion-item class="ion-padding-bottom">

View File

@@ -7,7 +7,7 @@
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-content class="ion-padding with-widgets">
<ion-grid *ngIf="pkgs$ | async as pkgs">
<ion-row *ngIf="backupProgress$ | async as backupProgress">
<ion-col>

View File

@@ -13,13 +13,13 @@ import { PatchDB } from 'patch-db-client'
import { skip, takeUntil } from 'rxjs/operators'
import { MappedBackupTarget } from 'src/app/types/mapped-backup-target'
import * as argon2 from '@start9labs/argon2'
import { TuiDestroyService } from '@taiga-ui/cdk'
import {
CifsBackupTarget,
DiskBackupTarget,
} from 'src/app/services/api/api.types'
import { BackupSelectPage } from 'src/app/modals/backup-select/backup-select.page'
import { EOSService } from 'src/app/services/eos.service'
import { DestroyService } from '@start9labs/shared'
import { getServerInfo } from 'src/app/util/get-server-info'
import { DataModel } from 'src/app/services/patch-db/data-model'
@@ -27,7 +27,7 @@ import { DataModel } from 'src/app/services/patch-db/data-model'
selector: 'server-backup',
templateUrl: './server-backup.page.html',
styleUrls: ['./server-backup.page.scss'],
providers: [DestroyService],
providers: [TuiDestroyService],
})
export class ServerBackupPage {
serviceIds: string[] = []
@@ -39,7 +39,7 @@ export class ServerBackupPage {
private readonly modalCtrl: ModalController,
private readonly embassyApi: ApiService,
private readonly navCtrl: NavController,
private readonly destroy$: DestroyService,
private readonly destroy$: TuiDestroyService,
private readonly eosService: EOSService,
private readonly patch: PatchDB<DataModel>,
) {}

View File

@@ -10,7 +10,7 @@
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-content class="ion-padding with-widgets">
<skeleton-list *ngIf="loading" [groups]="2"></skeleton-list>
<div id="metricSection">

View File

@@ -7,7 +7,7 @@
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-content class="ion-padding with-widgets">
<!-- loading -->
<ng-template #loading>
<text-spinner text="Connecting to Embassy"></text-spinner>

View File

@@ -7,7 +7,7 @@
</ion-toolbar>
</ion-header>
<ion-content>
<ion-content class="with-widgets">
<ion-item-group *ngIf="server$ | async as server">
<ion-item-divider>embassyOS Info</ion-item-divider>
<ion-item>

View File

@@ -7,7 +7,7 @@
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<ion-content class="ion-padding-top with-widgets">
<!-- loading -->
<ion-item-group *ngIf="loading; else notLoading">
<div *ngFor="let entry of ['This Session', 'Other Sessions']">

View File

@@ -7,7 +7,7 @@
</ion-toolbar>
</ion-header>
<ion-content class="ion-text-center">
<ion-content class="ion-text-center with-widgets">
<!-- file upload -->
<div
*ngIf="!toUpload.file; else fileUploaded"

View File

@@ -7,7 +7,7 @@
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<ion-content class="ion-padding-top with-widgets">
<ion-item-group>
<!-- always -->
<ion-item>

View File

@@ -13,7 +13,7 @@
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding-top">
<ion-content class="ion-padding-top with-widgets">
<ion-item-group>
<!-- always -->
<ion-item>

View File

@@ -7,7 +7,7 @@
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<ion-content class="ion-padding with-widgets">
<ion-item-group *ngIf="data$ | async as data">
<ng-container *ngFor="let host of data.hosts">
<ion-item-divider class="header">

View File

@@ -0,0 +1,10 @@
<div *ngIf="installed$ | async as installed" class="wrapper">
<button
*ngFor="let widget of widgets | tuiFilter: filter:installed; empty: empty"
class="tui-island tui-island_size_l"
(click)="context.completeWith(widget)"
>
<span class="tui-island__title">{{ widget.meta.name }}</span>
</button>
<ng-template #empty>No additional widgets found</ng-template>
</div>

View File

@@ -0,0 +1,9 @@
.wrapper {
display: flex;
flex-direction: column;
gap: 12px;
}
.tui-island {
text-align: left;
}

View File

@@ -0,0 +1,31 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import { Widget } from '../../../../services/patch-db/data-model'
import {
POLYMORPHEUS_CONTEXT,
PolymorpheusComponent,
} from '@tinkoff/ng-polymorpheus'
import { TuiDialogContext } from '@taiga-ui/core'
import { BUILT_IN_WIDGETS } from '../widgets'
@Component({
selector: 'add-widget',
templateUrl: './add.component.html',
styleUrls: ['./add.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class AddWidgetComponent {
readonly context = inject<TuiDialogContext<Widget>>(POLYMORPHEUS_CONTEXT)
readonly installed$ = inject(PatchDB).watch$('ui', 'widgets')
readonly widgets = BUILT_IN_WIDGETS
readonly filter = (widget: Widget, installed: readonly Widget[]) =>
!installed.find(({ id }) => id === widget.id)
}
export const ADD_WIDGET = new PolymorpheusComponent<
AddWidgetComponent,
TuiDialogContext<Widget>
>(AddWidgetComponent)

View File

@@ -0,0 +1,12 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { TuiFilterPipeModule, TuiForModule } from '@taiga-ui/cdk'
import { AddWidgetComponent } from './add.component'
@NgModule({
imports: [CommonModule, TuiFilterPipeModule, TuiForModule],
declarations: [AddWidgetComponent],
exports: [AddWidgetComponent],
})
export class AddWidgetModule {}

View File

@@ -0,0 +1 @@
<ion-button class="add">Add to quick launch</ion-button>

View File

@@ -0,0 +1,3 @@
.add {
font-size: 13px;
}

View File

@@ -0,0 +1,9 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
@Component({
selector: 'widget-favorites',
templateUrl: './favorites.component.html',
styleUrls: ['./favorites.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class FavoritesComponent {}

View File

@@ -0,0 +1,10 @@
import { NgModule } from '@angular/core'
import { FavoritesComponent } from './favorites.component'
import { IonicModule } from '@ionic/angular'
@NgModule({
imports: [IonicModule],
declarations: [FavoritesComponent],
exports: [FavoritesComponent],
})
export class FavoritesModule {}

View File

@@ -0,0 +1,11 @@
<h2 class="widget-title">Service health overview</h2>
<tui-ring-chart
*ngIf="data$ | async as data"
class="ring-chart"
[tuiHintContent]="hint"
[value]="data"
>
<ng-template #hint let-index>
{{ labels[index] }}: {{ data[index] }}
</ng-template>
</tui-ring-chart>

View File

@@ -0,0 +1,19 @@
:host {
/* index order must match labels array */
--tui-chart-0: var(--ion-color-danger-tint); // error
--tui-chart-1: var(--ion-color-success-tint); // healthy
--tui-chart-2: var(--ion-color-warning-tint); // needs attention
--tui-chart-3: var(--ion-color-step-600); // stopped
--tui-chart-4: var(--ion-color-primary-tint); // transitioning
}
.widget-title {
margin: 0;
font-size: 18px;
font-weight: bold;
}
.ring-chart {
transform: scale(0.85);
margin: 0.6rem auto;
}

View File

@@ -0,0 +1,64 @@
import { ChangeDetectionStrategy, Component, inject } from '@angular/core'
import { PatchDB } from 'patch-db-client'
import { map } from 'rxjs/operators'
import { PackageDataEntry } from 'src/app/services/patch-db/data-model'
import { PrimaryStatus } from 'src/app/services/pkg-status-rendering.service'
import { getPackageInfo, PkgInfo } from '../../../../util/get-package-info'
@Component({
selector: 'widget-health',
templateUrl: './health.component.html',
styleUrls: ['./health.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class HealthComponent {
readonly labels = [
'Error',
'Healthy',
'Needs Attention',
'Stopped',
'Transitioning',
] as const
readonly data$ = inject(PatchDB)
.watch$('package-data')
.pipe(
map(data => {
const pkgs = Object.values<PackageDataEntry>(data).map(getPackageInfo)
const result = this.labels.reduce<Record<string, number>>(
(acc, label) => ({
...acc,
[label]: this.getCount(label, pkgs),
}),
{},
)
result['Healthy'] =
pkgs.length -
result['Error'] -
result['Needs Attention'] -
result['Stopped'] -
result['Transitioning']
return this.labels.map(label => result[label])
}),
)
private getCount(label: string, pkgs: PkgInfo[]): number {
switch (label) {
case 'Error':
return pkgs.filter(
a => a.primaryStatus !== PrimaryStatus.Stopped && a.error,
).length
case 'Needs Attention':
return pkgs.filter(a => a.warning).length
case 'Stopped':
return pkgs.filter(a => a.primaryStatus === PrimaryStatus.Stopped)
.length
case 'Transitioning':
return pkgs.filter(a => a.transitioning).length
default:
return 0
}
}
}

View File

@@ -0,0 +1,12 @@
import { NgModule } from '@angular/core'
import { HealthComponent } from './health.component'
import { TuiRingChartModule } from '@taiga-ui/addon-charts'
import { TuiHintModule } from '@taiga-ui/core'
import { CommonModule } from '@angular/common'
@NgModule({
imports: [CommonModule, TuiRingChartModule, TuiHintModule],
declarations: [HealthComponent],
exports: [HealthComponent],
})
export class HealthModule {}

View File

@@ -0,0 +1,30 @@
<div class="stats">
<div class="stat">
<ion-icon class="stat-icon" name="server-outline"></ion-icon>
<div>
30%
<div class="description">Storage</div>
</div>
</div>
<div class="stat">
<ion-icon class="stat-icon" name="hardware-chip-outline"></ion-icon>
<div>
10%
<div class="description">CPU</div>
</div>
</div>
<div class="stat">
<ion-icon class="stat-icon" name="stats-chart-outline"></ion-icon>
<div>
10%
<div class="description">Memory</div>
</div>
</div>
<div class="stat">
<ion-icon class="stat-icon" name="thermometer-outline"></ion-icon>
<div>
50.6⁰C
<div class="description">Temp</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,34 @@
.stats {
display: flex;
align-items: center;
justify-content: space-around;
flex-wrap: wrap;
height: 100%;
text-align: center;
&_mobile .stat {
width: 50%;
}
}
.stat {
display: flex;
align-items: center;
justify-content: center;
:host-context(.wrapper_mobile) & {
width: 50%;
}
}
.stat-icon {
font-size: 32px;
margin: 12px;
}
.description {
color: #3a7be0;
text-transform: uppercase;
font-weight: bold;
font-size: 12px;
}

View File

@@ -0,0 +1,9 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
@Component({
selector: 'widget-metrics',
templateUrl: './metrics.component.html',
styleUrls: ['./metrics.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class MetricsComponent {}

View File

@@ -0,0 +1,10 @@
import { NgModule } from '@angular/core'
import { IonicModule } from '@ionic/angular'
import { MetricsComponent } from './metrics.component'
@NgModule({
imports: [IonicModule],
declarations: [MetricsComponent],
exports: [MetricsComponent],
})
export class MetricsModule {}

View File

@@ -0,0 +1,7 @@
<iframe
class="iframe"
src="https://www.youtube.com/embed/dQw4w9WgXcQ"
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowfullscreen
></iframe>

View File

@@ -0,0 +1,13 @@
:host {
border-radius: inherit;
}
.iframe {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
border: 0;
border-radius: inherit;
}

View File

@@ -0,0 +1,9 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
@Component({
selector: 'widget-network',
templateUrl: './network.component.html',
styleUrls: ['./network.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NetworkComponent {}

View File

@@ -0,0 +1,9 @@
import { NgModule } from '@angular/core'
import { NetworkComponent } from './network.component'
@NgModule({
imports: [],
declarations: [NetworkComponent],
exports: [NetworkComponent],
})
export class NetworkModule {}

View File

@@ -0,0 +1 @@
System time and uptime

View File

@@ -0,0 +1,9 @@
import { ChangeDetectionStrategy, Component } from '@angular/core'
@Component({
selector: 'widget-uptime',
templateUrl: './uptime.component.html',
styleUrls: ['./uptime.component.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class UptimeComponent {}

View File

@@ -0,0 +1,9 @@
import { NgModule } from '@angular/core'
import { UptimeComponent } from './uptime.component'
@NgModule({
imports: [],
declarations: [UptimeComponent],
exports: [UptimeComponent],
})
export class UptimeModule {}

View File

@@ -0,0 +1,54 @@
import { Widget } from '../../../services/patch-db/data-model'
export const BUILT_IN_WIDGETS: readonly Widget[] = [
{
id: 'favorites',
meta: {
name: 'Favorites',
width: 2,
height: 2,
mobileWidth: 2,
mobileHeight: 2,
},
},
{
id: 'health',
meta: {
name: 'Service health overview',
width: 2,
height: 2,
mobileWidth: 2,
mobileHeight: 2,
},
},
{
id: 'metrics',
meta: {
name: 'Server metrics',
width: 4,
height: 1,
mobileWidth: 2,
mobileHeight: 2,
},
},
{
id: 'network',
meta: {
name: 'Network',
width: 4,
height: 2,
mobileWidth: 2,
mobileHeight: 3,
},
},
{
id: 'uptime',
meta: {
name: 'System time and uptime',
width: 2,
height: 2,
mobileWidth: 2,
mobileHeight: 2,
},
},
]

View File

@@ -0,0 +1,42 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { RouterModule, Routes } from '@angular/router'
import { TuiLetModule } from '@taiga-ui/cdk'
import { TuiLoaderModule } from '@taiga-ui/core'
import { TuiTilesModule } from '@taiga-ui/kit'
import { WidgetsPage } from './widgets.page'
import { AddWidgetModule } from './built-in/add/add.module'
import { FavoritesModule } from './built-in/favorites/favorites.module'
import { HealthModule } from './built-in/health/health.module'
import { MetricsModule } from './built-in/metrics/metrics.module'
import { NetworkModule } from './built-in/network/network.module'
import { UptimeModule } from './built-in/uptime/uptime.module'
const routes: Routes = [
{
path: '',
component: WidgetsPage,
},
]
@NgModule({
imports: [
CommonModule,
IonicModule,
TuiTilesModule,
TuiLetModule,
AddWidgetModule,
FavoritesModule,
HealthModule,
MetricsModule,
NetworkModule,
UptimeModule,
RouterModule.forChild(routes),
TuiLoaderModule,
],
declarations: [WidgetsPage],
exports: [WidgetsPage],
})
export class WidgetsPageModule {}

View File

@@ -0,0 +1,55 @@
<h1 class="heading">
<tui-loader
*ngIf="pending else buttons"
[inheritColor]="true"
size="s"
class="loader"
></tui-loader>
<ng-template #buttons>
<ion-button fill="clear" color="primary" class="button" (click)="toggle()">
<ion-icon
[name]="edit ? 'checkmark-outline' : 'pencil-outline'"
></ion-icon>
{{ edit ? 'Save' : 'Edit'}}
</ion-button>
<ion-button fill="clear" color="primary" class="button" (click)="add()">
<ion-icon name="duplicate-outline"></ion-icon>
Add
</ion-button>
</ng-template>
</h1>
<!-- TODO: Fix resize lag in Taiga UI -->
<tui-tiles
class="wrapper"
[class.wrapper_wide]="wide"
[debounce]="500"
[(order)]="order"
>
<tui-tile
*ngFor="let item of items; let index = index; trackBy: trackBy"
class="item"
[class.item_edit]="edit"
[width]="wide ? item.meta.width : item.meta.mobileWidth"
[height]="wide ? item.meta.height : item.meta.mobileHeight"
[style.order]="order.get(index)"
>
<div class="content">
<ng-container *ngComponentOutlet="components[item.id]"></ng-container>
</div>
<div tuiTileHandle class="handle"></div>
<ion-icon
*ngIf="item.id === 'favorites'"
name="settings-outline"
class="settings"
></ion-icon>
<ion-icon
*ngIf="!pending else loader"
name="trash-outline"
class="remove"
(click)="remove(index)"
></ion-icon>
<ng-template #loader>
<tui-loader [inheritColor]="true" class="pending"></tui-loader>
</ng-template>
</tui-tile>
</tui-tiles>

View File

@@ -0,0 +1,112 @@
:host {
overflow: auto;
}
.title {
margin-right: auto;
}
.loader {
width: 24px;
color: var(--ion-color-tertiary);
}
.heading {
display: flex;
align-items: center;
height: 44px;
font-size: 20px;
margin: 14px 0 -20px;
padding: 0 40px;
:host.dialog & {
margin: 0 0 -24px;
padding: 0;
font-size: 24px;
}
}
.button {
width: 50%;
ion-icon {
margin-right: 8px;
}
}
.wrapper {
gap: 24px;
grid-auto-rows: 100px;
grid-auto-columns: 1fr;
margin: 40px;
&_wide {
grid-template-columns: repeat(4, 1fr);
}
:host.dialog & {
margin: 40px 0;
}
}
.item {
box-shadow: inset 0 0 0 3px rgba(255, 255, 255, 0.1);
border-radius: 24px;
}
.content {
height: 100%;
text-align: center;
background: #333;
border-radius: 24px;
padding: 24px;
box-sizing: border-box;
overflow: hidden;
box-shadow: 0 3px 5px -2px rgba(0, 0, 0, 0.5);
transition: opacity 0.3s;
.item_edit & {
opacity: var(--tui-disabled-opacity);
pointer-events: none;
}
}
.handle {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
cursor: move;
.item:not(.item_edit) & {
pointer-events: none;
}
}
.remove,
.settings,
.pending {
position: absolute;
right: 24px;
top: 50%;
width: 24px;
height: 24px;
transform: translateY(-50%);
padding: 10px;
box-sizing: content-box;
border-radius: 100%;
background: rgba(0, 0, 0, 0.5);
pointer-events: none;
opacity: 0;
transition: opacity 0.3s;
.item_edit & {
pointer-events: auto;
opacity: 1;
}
}
.settings {
left: 24px;
}

View File

@@ -0,0 +1,136 @@
import {
ChangeDetectionStrategy,
ChangeDetectorRef,
Component,
Inject,
Input,
Optional,
Type,
} from '@angular/core'
import { TuiDialogContext, TuiDialogService } from '@taiga-ui/core'
import {
PolymorpheusComponent,
POLYMORPHEUS_CONTEXT,
} from '@tinkoff/ng-polymorpheus'
import { PatchDB } from 'patch-db-client'
import { DataModel, Widget } from '../../services/patch-db/data-model'
import { ApiService } from '../../services/api/embassy-api.service'
import { ADD_WIDGET } from './built-in/add/add.component'
import { FavoritesComponent } from './built-in/favorites/favorites.component'
import { HealthComponent } from './built-in/health/health.component'
import { NetworkComponent } from './built-in/network/network.component'
import { MetricsComponent } from './built-in/metrics/metrics.component'
import { UptimeComponent } from './built-in/uptime/uptime.component'
import { take } from 'rxjs/operators'
@Component({
selector: 'widgets',
templateUrl: 'widgets.page.html',
styleUrls: ['widgets.page.scss'],
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
'[class.dialog]': 'context',
},
})
export class WidgetsPage {
@Input()
wide = false
edit = false
order = new Map<number, number>()
items: readonly Widget[] = []
pending = true
readonly components: Record<string, Type<any>> = {
health: HealthComponent,
favorites: FavoritesComponent,
metrics: MetricsComponent,
network: NetworkComponent,
uptime: UptimeComponent,
}
constructor(
@Optional()
@Inject(POLYMORPHEUS_CONTEXT)
readonly context: TuiDialogContext | null,
private readonly dialog: TuiDialogService,
private readonly patch: PatchDB<DataModel>,
private readonly cdr: ChangeDetectorRef,
private readonly api: ApiService,
) {
this.patch
.watch$('ui', 'widgets')
.pipe(take(1))
.subscribe(items => {
this.updateItems(items)
this.pending = false
})
}
trackBy(_: number, { id }: Widget) {
return id
}
toggle() {
if (this.edit) {
this.updateItems(this.getReordered())
}
this.edit = !this.edit
}
add() {
this.dialog.open(ADD_WIDGET, { label: 'Add widget' }).subscribe(widget => {
this.addWidget(widget)
})
}
remove(index: number) {
this.removeWidget(index)
}
private removeWidget(index: number) {
this.updateItems(
this.getReordered().filter((_, i) => i !== this.order.get(index)),
)
}
private addWidget(widget: Widget) {
this.updateItems(this.getReordered().concat(widget))
}
private getReordered(): Widget[] {
const items: Widget[] = []
Array.from(this.order.entries()).forEach(([index, order]) => {
items[order] = this.items[index]
})
return items
}
private updateItems(items: readonly Widget[]) {
const previous = this.items
if (!this.pending) {
this.pending = true
this.api
.setDbValue(['widgets'], items)
.catch(() => {
this.updateItems(previous)
})
.finally(() => {
this.pending = false
this.cdr.markForCheck()
})
}
this.items = items
this.order = new Map(items.map((_, index) => [index, index]))
}
}
export const WIDGETS_COMPONENT = new PolymorpheusComponent(WidgetsPage)