Feature/introduce tabs to admin control panel (#494)
* Add tabs * Update changelog
This commit is contained in:
parent
dc9b2ce194
commit
a24a094407
@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## Unreleased
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Added tabs to the admin control panel
|
||||||
|
|
||||||
## 1.81.0 - 27.11.2021
|
## 1.81.0 - 27.11.2021
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
@ -0,0 +1,150 @@
|
|||||||
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
||||||
|
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||||
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
||||||
|
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
|
||||||
|
import { User } from '@ghostfolio/common/interfaces';
|
||||||
|
import {
|
||||||
|
differenceInSeconds,
|
||||||
|
formatDistanceToNowStrict,
|
||||||
|
isValid,
|
||||||
|
parseISO
|
||||||
|
} from 'date-fns';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'gf-admin-overview',
|
||||||
|
styleUrls: ['./admin-overview.scss'],
|
||||||
|
templateUrl: './admin-overview.html'
|
||||||
|
})
|
||||||
|
export class AdminOverviewComponent implements OnDestroy, OnInit {
|
||||||
|
public dataGatheringInProgress: boolean;
|
||||||
|
public dataGatheringProgress: number;
|
||||||
|
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
||||||
|
public exchangeRates: { label1: string; label2: string; value: number }[];
|
||||||
|
public lastDataGathering: string;
|
||||||
|
public transactionCount: number;
|
||||||
|
public userCount: number;
|
||||||
|
public user: User;
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
private adminService: AdminService,
|
||||||
|
private cacheService: CacheService,
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private dataService: DataService,
|
||||||
|
private userService: UserService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the controller
|
||||||
|
*/
|
||||||
|
public ngOnInit() {
|
||||||
|
this.fetchAdminData();
|
||||||
|
|
||||||
|
this.userService.stateChanged
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe((state) => {
|
||||||
|
if (state?.user) {
|
||||||
|
this.user = state.user;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onFlushCache() {
|
||||||
|
this.cacheService
|
||||||
|
.flush()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public onGatherMax() {
|
||||||
|
const confirmation = confirm(
|
||||||
|
'This action may take some time. Do you want to proceed?'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmation === true) {
|
||||||
|
this.adminService
|
||||||
|
.gatherMax()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.reload();
|
||||||
|
}, 300);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public onGatherProfileData() {
|
||||||
|
this.adminService
|
||||||
|
.gatherProfileData()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
public formatDistanceToNow(aDateString: string) {
|
||||||
|
if (aDateString) {
|
||||||
|
const distanceString = formatDistanceToNowStrict(parseISO(aDateString), {
|
||||||
|
addSuffix: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return Math.abs(differenceInSeconds(parseISO(aDateString), new Date())) <
|
||||||
|
60
|
||||||
|
? 'just now'
|
||||||
|
: distanceString;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchAdminData() {
|
||||||
|
this.dataService
|
||||||
|
.fetchAdminData()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(
|
||||||
|
({
|
||||||
|
dataGatheringProgress,
|
||||||
|
exchangeRates,
|
||||||
|
lastDataGathering,
|
||||||
|
transactionCount,
|
||||||
|
userCount
|
||||||
|
}) => {
|
||||||
|
this.dataGatheringProgress = dataGatheringProgress;
|
||||||
|
this.exchangeRates = exchangeRates;
|
||||||
|
|
||||||
|
if (isValid(parseISO(lastDataGathering?.toString()))) {
|
||||||
|
this.lastDataGathering = formatDistanceToNowStrict(
|
||||||
|
new Date(lastDataGathering),
|
||||||
|
{
|
||||||
|
addSuffix: true
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else if (lastDataGathering === 'IN_PROGRESS') {
|
||||||
|
this.dataGatheringInProgress = true;
|
||||||
|
} else {
|
||||||
|
this.lastDataGathering = 'Starting soon...';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.transactionCount = transactionCount;
|
||||||
|
this.userCount = userCount;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,107 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="mb-5 row">
|
||||||
|
<div class="col">
|
||||||
|
<mat-card class="mb-3">
|
||||||
|
<mat-card-content>
|
||||||
|
<div
|
||||||
|
*ngIf="exchangeRates?.length > 0"
|
||||||
|
class="align-items-start d-flex my-3"
|
||||||
|
>
|
||||||
|
<div class="w-50" i18n>Exchange Rates</div>
|
||||||
|
<div class="w-50">
|
||||||
|
<table>
|
||||||
|
<tr *ngFor="let exchangeRate of exchangeRates">
|
||||||
|
<td class="d-flex">
|
||||||
|
<gf-value
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
[value]="1"
|
||||||
|
></gf-value>
|
||||||
|
</td>
|
||||||
|
<td class="pl-1">{{ exchangeRate.label1 }}</td>
|
||||||
|
<td class="px-1">=</td>
|
||||||
|
<td class="d-flex justify-content-end">
|
||||||
|
<gf-value
|
||||||
|
[locale]="user?.settings?.locale"
|
||||||
|
[precision]="4"
|
||||||
|
[value]="exchangeRate.value"
|
||||||
|
></gf-value>
|
||||||
|
</td>
|
||||||
|
<td class="pl-1">{{ exchangeRate.label2 }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex my-3">
|
||||||
|
<div class="w-50" i18n>Data Gathering</div>
|
||||||
|
<div class="w-50">
|
||||||
|
<div>
|
||||||
|
<ng-container *ngIf="lastDataGathering"
|
||||||
|
>{{ lastDataGathering }}</ng-container
|
||||||
|
>
|
||||||
|
<ng-container *ngIf="dataGatheringInProgress" i18n
|
||||||
|
>In Progress ({{ dataGatheringProgress | percent : '1.2-2'
|
||||||
|
}})</ng-container
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 overflow-hidden">
|
||||||
|
<div class="mb-2">
|
||||||
|
<button
|
||||||
|
class="mw-100"
|
||||||
|
color="accent"
|
||||||
|
mat-flat-button
|
||||||
|
(click)="onFlushCache()"
|
||||||
|
>
|
||||||
|
<ion-icon
|
||||||
|
class="mr-1"
|
||||||
|
name="close-circle-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<span i18n>Reset Data Gathering</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mb-2">
|
||||||
|
<button
|
||||||
|
class="mw-100"
|
||||||
|
color="warn"
|
||||||
|
mat-flat-button
|
||||||
|
[disabled]="dataGatheringInProgress"
|
||||||
|
(click)="onGatherMax()"
|
||||||
|
>
|
||||||
|
<ion-icon class="mr-1" name="warning-outline"></ion-icon>
|
||||||
|
<span i18n>Gather All Data</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button
|
||||||
|
class="mb-2 mr-2 mw-100"
|
||||||
|
color="accent"
|
||||||
|
mat-flat-button
|
||||||
|
(click)="onGatherProfileData()"
|
||||||
|
>
|
||||||
|
<ion-icon
|
||||||
|
class="mr-1"
|
||||||
|
name="cloud-download-outline"
|
||||||
|
></ion-icon>
|
||||||
|
<span i18n>Gather Profile Data</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex my-3">
|
||||||
|
<div class="w-50" i18n>User Count</div>
|
||||||
|
<div class="w-50">{{ userCount }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex my-3">
|
||||||
|
<div class="w-50" i18n>Transaction Count</div>
|
||||||
|
<div class="w-50">
|
||||||
|
<ng-container *ngIf="transactionCount">
|
||||||
|
{{ transactionCount }} ({{ transactionCount / userCount | number
|
||||||
|
: '1.2-2' }} <span i18n>per User</span>)
|
||||||
|
</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</mat-card-content>
|
||||||
|
</mat-card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,17 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatCardModule } from '@angular/material/card';
|
||||||
|
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||||
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
|
|
||||||
|
import { AdminOverviewComponent } from './admin-overview.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [AdminOverviewComponent],
|
||||||
|
exports: [],
|
||||||
|
imports: [CommonModule, GfValueModule, MatButtonModule, MatCardModule],
|
||||||
|
providers: [CacheService],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class AdminOverviewModule {}
|
@ -0,0 +1,17 @@
|
|||||||
|
@import '~apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.mat-flat-button {
|
||||||
|
::ng-deep {
|
||||||
|
.mat-button-wrapper {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,82 @@
|
|||||||
|
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
|
import { DataService } from '@ghostfolio/client/services/data.service';
|
||||||
|
import { AdminData } from '@ghostfolio/common/interfaces';
|
||||||
|
import {
|
||||||
|
differenceInSeconds,
|
||||||
|
formatDistanceToNowStrict,
|
||||||
|
parseISO
|
||||||
|
} from 'date-fns';
|
||||||
|
import { Subject } from 'rxjs';
|
||||||
|
import { takeUntil } from 'rxjs/operators';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'gf-admin-users',
|
||||||
|
styleUrls: ['./admin-users.scss'],
|
||||||
|
templateUrl: './admin-users.html'
|
||||||
|
})
|
||||||
|
export class AdminUsersComponent implements OnDestroy, OnInit {
|
||||||
|
public users: AdminData['users'];
|
||||||
|
|
||||||
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @constructor
|
||||||
|
*/
|
||||||
|
public constructor(
|
||||||
|
private changeDetectorRef: ChangeDetectorRef,
|
||||||
|
private dataService: DataService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the controller
|
||||||
|
*/
|
||||||
|
public ngOnInit() {
|
||||||
|
this.fetchAdminData();
|
||||||
|
}
|
||||||
|
|
||||||
|
public formatDistanceToNow(aDateString: string) {
|
||||||
|
if (aDateString) {
|
||||||
|
const distanceString = formatDistanceToNowStrict(parseISO(aDateString), {
|
||||||
|
addSuffix: true
|
||||||
|
});
|
||||||
|
|
||||||
|
return Math.abs(differenceInSeconds(parseISO(aDateString), new Date())) <
|
||||||
|
60
|
||||||
|
? 'just now'
|
||||||
|
: distanceString;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public onDeleteUser(aId: string) {
|
||||||
|
const confirmation = confirm('Do you really want to delete this user?');
|
||||||
|
|
||||||
|
if (confirmation) {
|
||||||
|
this.dataService
|
||||||
|
.deleteUser(aId)
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.fetchAdminData();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public ngOnDestroy() {
|
||||||
|
this.unsubscribeSubject.next();
|
||||||
|
this.unsubscribeSubject.complete();
|
||||||
|
}
|
||||||
|
|
||||||
|
private fetchAdminData() {
|
||||||
|
this.dataService
|
||||||
|
.fetchAdminData()
|
||||||
|
.pipe(takeUntil(this.unsubscribeSubject))
|
||||||
|
.subscribe(({ users }) => {
|
||||||
|
this.users = users;
|
||||||
|
|
||||||
|
this.changeDetectorRef.markForCheck();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
86
apps/client/src/app/components/admin-users/admin-users.html
Normal file
86
apps/client/src/app/components/admin-users/admin-users.html
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
<div class="container">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<div class="users">
|
||||||
|
<table class="gf-table">
|
||||||
|
<thead>
|
||||||
|
<tr class="mat-header-row">
|
||||||
|
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2" i18n>User</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
||||||
|
Registration
|
||||||
|
</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
||||||
|
Accounts
|
||||||
|
</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
||||||
|
Transactions
|
||||||
|
</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
||||||
|
Engagement per Day
|
||||||
|
</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2" i18n>Last Activitiy</th>
|
||||||
|
<th class="mat-header-cell px-1 py-2"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr *ngFor="let userItem of users; let i = index" class="mat-row">
|
||||||
|
<td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</td>
|
||||||
|
<td class="mat-cell px-1 py-2">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<span class="d-none d-sm-inline-block"
|
||||||
|
>{{ userItem.alias || userItem.id }}</span
|
||||||
|
>
|
||||||
|
<span class="d-inline-block d-sm-none"
|
||||||
|
>{{ userItem.alias || (userItem.id | slice:0:5) +
|
||||||
|
'...' }}</span
|
||||||
|
>
|
||||||
|
<ion-icon
|
||||||
|
*ngIf="userItem?.subscription?.type === 'Premium'"
|
||||||
|
class="ml-1 text-muted"
|
||||||
|
name="diamond-outline"
|
||||||
|
></ion-icon>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td class="mat-cell px-1 py-2 text-right">
|
||||||
|
{{ formatDistanceToNow(userItem.createdAt) }}
|
||||||
|
</td>
|
||||||
|
<td class="mat-cell px-1 py-2 text-right">
|
||||||
|
{{ userItem.accountCount }}
|
||||||
|
</td>
|
||||||
|
<td class="mat-cell px-1 py-2 text-right">
|
||||||
|
{{ userItem.transactionCount }}
|
||||||
|
</td>
|
||||||
|
<td class="mat-cell px-1 py-2 text-right">
|
||||||
|
{{ userItem.engagement | number: '1.0-0' }}
|
||||||
|
</td>
|
||||||
|
<td class="mat-cell px-1 py-2">
|
||||||
|
{{ formatDistanceToNow(userItem.lastActivity) }}
|
||||||
|
</td>
|
||||||
|
<td class="mat-cell px-1 py-2">
|
||||||
|
<button
|
||||||
|
class="mx-1 no-min-width px-2"
|
||||||
|
mat-button
|
||||||
|
[matMenuTriggerFor]="accountMenu"
|
||||||
|
(click)="$event.stopPropagation()"
|
||||||
|
>
|
||||||
|
<ion-icon name="ellipsis-vertical"></ion-icon>
|
||||||
|
</button>
|
||||||
|
<mat-menu #accountMenu="matMenu" xPosition="before">
|
||||||
|
<button
|
||||||
|
i18n
|
||||||
|
mat-menu-item
|
||||||
|
[disabled]="userItem.id === user?.id"
|
||||||
|
(click)="onDeleteUser(userItem.id)"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</mat-menu>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -0,0 +1,14 @@
|
|||||||
|
import { CommonModule } from '@angular/common';
|
||||||
|
import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
||||||
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
|
|
||||||
|
import { AdminUsersComponent } from './admin-users.component';
|
||||||
|
|
||||||
|
@NgModule({
|
||||||
|
declarations: [AdminUsersComponent],
|
||||||
|
exports: [],
|
||||||
|
imports: [CommonModule, MatButtonModule, MatMenuModule],
|
||||||
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
})
|
||||||
|
export class AdminUsersModule {}
|
18
apps/client/src/app/components/admin-users/admin-users.scss
Normal file
18
apps/client/src/app/components/admin-users/admin-users.scss
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
@import '~apps/client/src/styles/ghostfolio-style';
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.users {
|
||||||
|
overflow-x: auto;
|
||||||
|
|
||||||
|
table {
|
||||||
|
min-width: 100%;
|
||||||
|
|
||||||
|
.mat-row,
|
||||||
|
.mat-header-row {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,11 +1,22 @@
|
|||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
import { RouterModule, Routes } from '@angular/router';
|
import { RouterModule, Routes } from '@angular/router';
|
||||||
|
import { AdminOverviewComponent } from '@ghostfolio/client/components/admin-overview/admin-overview.component';
|
||||||
|
import { AdminUsersComponent } from '@ghostfolio/client/components/admin-users/admin-users.component';
|
||||||
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
import { AuthGuard } from '@ghostfolio/client/core/auth.guard';
|
||||||
|
|
||||||
import { AdminPageComponent } from './admin-page.component';
|
import { AdminPageComponent } from './admin-page.component';
|
||||||
|
|
||||||
const routes: Routes = [
|
const routes: Routes = [
|
||||||
{ path: '', component: AdminPageComponent, canActivate: [AuthGuard] }
|
{
|
||||||
|
path: '',
|
||||||
|
component: AdminPageComponent,
|
||||||
|
canActivate: [AuthGuard],
|
||||||
|
children: [
|
||||||
|
{ path: '', redirectTo: 'overview', pathMatch: 'full' },
|
||||||
|
{ path: 'overview', component: AdminOverviewComponent },
|
||||||
|
{ path: 'users', component: AdminUsersComponent }
|
||||||
|
]
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
|
@ -1,169 +1,26 @@
|
|||||||
import { ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core';
|
import { Component, OnDestroy, OnInit } from '@angular/core';
|
||||||
import { AdminService } from '@ghostfolio/client/services/admin.service';
|
|
||||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
|
||||||
import { DataService } from '@ghostfolio/client/services/data.service';
|
|
||||||
import { UserService } from '@ghostfolio/client/services/user/user.service';
|
|
||||||
import { DEFAULT_DATE_FORMAT } from '@ghostfolio/common/config';
|
|
||||||
import { AdminData, User } from '@ghostfolio/common/interfaces';
|
|
||||||
import {
|
|
||||||
differenceInSeconds,
|
|
||||||
formatDistanceToNowStrict,
|
|
||||||
isValid,
|
|
||||||
parseISO
|
|
||||||
} from 'date-fns';
|
|
||||||
import { Subject } from 'rxjs';
|
import { Subject } from 'rxjs';
|
||||||
import { takeUntil } from 'rxjs/operators';
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
host: { class: 'mb-5' },
|
|
||||||
selector: 'gf-admin-page',
|
selector: 'gf-admin-page',
|
||||||
styleUrls: ['./admin-page.scss'],
|
styleUrls: ['./admin-page.scss'],
|
||||||
templateUrl: './admin-page.html'
|
templateUrl: './admin-page.html'
|
||||||
})
|
})
|
||||||
export class AdminPageComponent implements OnDestroy, OnInit {
|
export class AdminPageComponent implements OnDestroy, OnInit {
|
||||||
public dataGatheringInProgress: boolean;
|
|
||||||
public dataGatheringProgress: number;
|
|
||||||
public defaultDateFormat = DEFAULT_DATE_FORMAT;
|
|
||||||
public exchangeRates: { label1: string; label2: string; value: number }[];
|
|
||||||
public lastDataGathering: string;
|
|
||||||
public transactionCount: number;
|
|
||||||
public userCount: number;
|
|
||||||
public user: User;
|
|
||||||
public users: AdminData['users'];
|
|
||||||
|
|
||||||
private unsubscribeSubject = new Subject<void>();
|
private unsubscribeSubject = new Subject<void>();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @constructor
|
* @constructor
|
||||||
*/
|
*/
|
||||||
public constructor(
|
public constructor() {}
|
||||||
private adminService: AdminService,
|
|
||||||
private cacheService: CacheService,
|
|
||||||
private changeDetectorRef: ChangeDetectorRef,
|
|
||||||
private dataService: DataService,
|
|
||||||
private userService: UserService
|
|
||||||
) {}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initializes the controller
|
* Initializes the controller
|
||||||
*/
|
*/
|
||||||
public ngOnInit() {
|
public ngOnInit() {}
|
||||||
this.fetchAdminData();
|
|
||||||
|
|
||||||
this.userService.stateChanged
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe((state) => {
|
|
||||||
if (state?.user) {
|
|
||||||
this.user = state.user;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public onFlushCache() {
|
|
||||||
this.cacheService
|
|
||||||
.flush()
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
public onGatherMax() {
|
|
||||||
const confirmation = confirm(
|
|
||||||
'This action may take some time. Do you want to proceed?'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (confirmation === true) {
|
|
||||||
this.adminService
|
|
||||||
.gatherMax()
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
window.location.reload();
|
|
||||||
}, 300);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public onGatherProfileData() {
|
|
||||||
this.adminService
|
|
||||||
.gatherProfileData()
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
public formatDistanceToNow(aDateString: string) {
|
|
||||||
if (aDateString) {
|
|
||||||
const distanceString = formatDistanceToNowStrict(parseISO(aDateString), {
|
|
||||||
addSuffix: true
|
|
||||||
});
|
|
||||||
|
|
||||||
return Math.abs(differenceInSeconds(parseISO(aDateString), new Date())) <
|
|
||||||
60
|
|
||||||
? 'just now'
|
|
||||||
: distanceString;
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
public onDeleteUser(aId: string) {
|
|
||||||
const confirmation = confirm('Do you really want to delete this user?');
|
|
||||||
|
|
||||||
if (confirmation) {
|
|
||||||
this.dataService
|
|
||||||
.deleteUser(aId)
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe({
|
|
||||||
next: () => {
|
|
||||||
this.fetchAdminData();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public ngOnDestroy() {
|
public ngOnDestroy() {
|
||||||
this.unsubscribeSubject.next();
|
this.unsubscribeSubject.next();
|
||||||
this.unsubscribeSubject.complete();
|
this.unsubscribeSubject.complete();
|
||||||
}
|
}
|
||||||
|
|
||||||
private fetchAdminData() {
|
|
||||||
this.dataService
|
|
||||||
.fetchAdminData()
|
|
||||||
.pipe(takeUntil(this.unsubscribeSubject))
|
|
||||||
.subscribe(
|
|
||||||
({
|
|
||||||
dataGatheringProgress,
|
|
||||||
exchangeRates,
|
|
||||||
lastDataGathering,
|
|
||||||
transactionCount,
|
|
||||||
userCount,
|
|
||||||
users
|
|
||||||
}) => {
|
|
||||||
this.dataGatheringProgress = dataGatheringProgress;
|
|
||||||
this.exchangeRates = exchangeRates;
|
|
||||||
this.users = users;
|
|
||||||
|
|
||||||
if (isValid(parseISO(lastDataGathering?.toString()))) {
|
|
||||||
this.lastDataGathering = formatDistanceToNowStrict(
|
|
||||||
new Date(lastDataGathering),
|
|
||||||
{
|
|
||||||
addSuffix: true
|
|
||||||
}
|
|
||||||
);
|
|
||||||
} else if (lastDataGathering === 'IN_PROGRESS') {
|
|
||||||
this.dataGatheringInProgress = true;
|
|
||||||
} else {
|
|
||||||
this.lastDataGathering = 'Starting soon...';
|
|
||||||
}
|
|
||||||
|
|
||||||
this.transactionCount = transactionCount;
|
|
||||||
this.userCount = userCount;
|
|
||||||
|
|
||||||
this.changeDetectorRef.markForCheck();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -1,195 +1,17 @@
|
|||||||
<div class="container">
|
<router-outlet></router-outlet>
|
||||||
<div class="mb-5 row">
|
|
||||||
<div class="col">
|
<nav mat-align-tabs="center" mat-tab-nav-bar>
|
||||||
<h3 class="d-flex justify-content-center mb-3" i18n>
|
<a
|
||||||
Admin Control Panel
|
*ngFor="let link of [
|
||||||
</h3>
|
{ iconName: 'reader-outline', path: 'overview' },
|
||||||
<mat-card class="mb-3">
|
{ iconName: 'people-outline', path: 'users' }
|
||||||
<mat-card-content>
|
]"
|
||||||
<div
|
#rla="routerLinkActive"
|
||||||
*ngIf="exchangeRates?.length > 0"
|
mat-tab-link
|
||||||
class="align-items-start d-flex my-3"
|
routerLinkActive
|
||||||
>
|
[active]="rla.isActive"
|
||||||
<div class="w-50" i18n>Exchange Rates</div>
|
[routerLink]="link.path"
|
||||||
<div class="w-50">
|
>
|
||||||
<table>
|
<ion-icon size="large" [name]="link.iconName"></ion-icon>
|
||||||
<tr *ngFor="let exchangeRate of exchangeRates">
|
</a>
|
||||||
<td class="d-flex">
|
</nav>
|
||||||
<gf-value
|
|
||||||
[locale]="user?.settings?.locale"
|
|
||||||
[value]="1"
|
|
||||||
></gf-value>
|
|
||||||
</td>
|
|
||||||
<td class="pl-1">{{ exchangeRate.label1 }}</td>
|
|
||||||
<td class="px-1">=</td>
|
|
||||||
<td class="d-flex justify-content-end">
|
|
||||||
<gf-value
|
|
||||||
[locale]="user?.settings?.locale"
|
|
||||||
[precision]="4"
|
|
||||||
[value]="exchangeRate.value"
|
|
||||||
></gf-value>
|
|
||||||
</td>
|
|
||||||
<td class="pl-1">{{ exchangeRate.label2 }}</td>
|
|
||||||
</tr>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex my-3">
|
|
||||||
<div class="w-50" i18n>Data Gathering</div>
|
|
||||||
<div class="w-50">
|
|
||||||
<div>
|
|
||||||
<ng-container *ngIf="lastDataGathering"
|
|
||||||
>{{ lastDataGathering }}</ng-container
|
|
||||||
>
|
|
||||||
<ng-container *ngIf="dataGatheringInProgress" i18n
|
|
||||||
>In Progress ({{ dataGatheringProgress | percent : '1.2-2'
|
|
||||||
}})</ng-container
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
<div class="mt-2 overflow-hidden">
|
|
||||||
<div class="mb-2">
|
|
||||||
<button
|
|
||||||
class="mw-100"
|
|
||||||
color="accent"
|
|
||||||
mat-flat-button
|
|
||||||
(click)="onFlushCache()"
|
|
||||||
>
|
|
||||||
<ion-icon
|
|
||||||
class="mr-1"
|
|
||||||
name="close-circle-outline"
|
|
||||||
></ion-icon>
|
|
||||||
<span i18n>Reset Data Gathering</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div class="mb-2">
|
|
||||||
<button
|
|
||||||
class="mw-100"
|
|
||||||
color="warn"
|
|
||||||
mat-flat-button
|
|
||||||
[disabled]="dataGatheringInProgress"
|
|
||||||
(click)="onGatherMax()"
|
|
||||||
>
|
|
||||||
<ion-icon class="mr-1" name="warning-outline"></ion-icon>
|
|
||||||
<span i18n>Gather All Data</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
class="mb-2 mr-2 mw-100"
|
|
||||||
color="accent"
|
|
||||||
mat-flat-button
|
|
||||||
(click)="onGatherProfileData()"
|
|
||||||
>
|
|
||||||
<ion-icon
|
|
||||||
class="mr-1"
|
|
||||||
name="cloud-download-outline"
|
|
||||||
></ion-icon>
|
|
||||||
<span i18n>Gather Profile Data</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex my-3">
|
|
||||||
<div class="w-50" i18n>User Count</div>
|
|
||||||
<div class="w-50">{{ userCount }}</div>
|
|
||||||
</div>
|
|
||||||
<div class="d-flex my-3">
|
|
||||||
<div class="w-50" i18n>Transaction Count</div>
|
|
||||||
<div class="w-50">
|
|
||||||
<ng-container *ngIf="transactionCount">
|
|
||||||
{{ transactionCount }} ({{ transactionCount / userCount | number
|
|
||||||
: '1.2-2' }} <span i18n>per User</span>)
|
|
||||||
</ng-container>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</mat-card-content>
|
|
||||||
</mat-card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
<h3 class="mb-3 text-center" i18n>Users</h3>
|
|
||||||
<div class="users">
|
|
||||||
<table class="gf-table">
|
|
||||||
<thead>
|
|
||||||
<tr class="mat-header-row">
|
|
||||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>#</th>
|
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>User</th>
|
|
||||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
|
||||||
Registration
|
|
||||||
</th>
|
|
||||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
|
||||||
Accounts
|
|
||||||
</th>
|
|
||||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
|
||||||
Transactions
|
|
||||||
</th>
|
|
||||||
<th class="mat-header-cell px-1 py-2 text-right" i18n>
|
|
||||||
Engagement per Day
|
|
||||||
</th>
|
|
||||||
<th class="mat-header-cell px-1 py-2" i18n>Last Activitiy</th>
|
|
||||||
<th class="mat-header-cell px-1 py-2"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr *ngFor="let userItem of users; let i = index" class="mat-row">
|
|
||||||
<td class="mat-cell px-1 py-2 text-right">{{ i + 1 }}</td>
|
|
||||||
<td class="mat-cell px-1 py-2">
|
|
||||||
<div class="d-flex align-items-center">
|
|
||||||
<span class="d-none d-sm-inline-block"
|
|
||||||
>{{ userItem.alias || userItem.id }}</span
|
|
||||||
>
|
|
||||||
<span class="d-inline-block d-sm-none"
|
|
||||||
>{{ userItem.alias || (userItem.id | slice:0:5) +
|
|
||||||
'...' }}</span
|
|
||||||
>
|
|
||||||
<ion-icon
|
|
||||||
*ngIf="userItem?.subscription?.type === 'Premium'"
|
|
||||||
class="ml-1 text-muted"
|
|
||||||
name="diamond-outline"
|
|
||||||
></ion-icon>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td class="mat-cell px-1 py-2 text-right">
|
|
||||||
{{ formatDistanceToNow(userItem.createdAt) }}
|
|
||||||
</td>
|
|
||||||
<td class="mat-cell px-1 py-2 text-right">
|
|
||||||
{{ userItem.accountCount }}
|
|
||||||
</td>
|
|
||||||
<td class="mat-cell px-1 py-2 text-right">
|
|
||||||
{{ userItem.transactionCount }}
|
|
||||||
</td>
|
|
||||||
<td class="mat-cell px-1 py-2 text-right">
|
|
||||||
{{ userItem.engagement | number: '1.0-0' }}
|
|
||||||
</td>
|
|
||||||
<td class="mat-cell px-1 py-2">
|
|
||||||
{{ formatDistanceToNow(userItem.lastActivity) }}
|
|
||||||
</td>
|
|
||||||
<td class="mat-cell px-1 py-2">
|
|
||||||
<button
|
|
||||||
class="mx-1 no-min-width px-2"
|
|
||||||
mat-button
|
|
||||||
[matMenuTriggerFor]="accountMenu"
|
|
||||||
(click)="$event.stopPropagation()"
|
|
||||||
>
|
|
||||||
<ion-icon name="ellipsis-vertical"></ion-icon>
|
|
||||||
</button>
|
|
||||||
<mat-menu #accountMenu="matMenu" xPosition="before">
|
|
||||||
<button
|
|
||||||
i18n
|
|
||||||
mat-menu-item
|
|
||||||
[disabled]="userItem.id === user?.id"
|
|
||||||
(click)="onDeleteUser(userItem.id)"
|
|
||||||
>
|
|
||||||
Delete
|
|
||||||
</button>
|
|
||||||
</mat-menu>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
@ -3,6 +3,9 @@ import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core';
|
|||||||
import { MatButtonModule } from '@angular/material/button';
|
import { MatButtonModule } from '@angular/material/button';
|
||||||
import { MatCardModule } from '@angular/material/card';
|
import { MatCardModule } from '@angular/material/card';
|
||||||
import { MatMenuModule } from '@angular/material/menu';
|
import { MatMenuModule } from '@angular/material/menu';
|
||||||
|
import { MatTabsModule } from '@angular/material/tabs';
|
||||||
|
import { AdminOverviewModule } from '@ghostfolio/client/components/admin-overview/admin-overview.module';
|
||||||
|
import { AdminUsersModule } from '@ghostfolio/client/components/admin-users/admin-users.module';
|
||||||
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
import { CacheService } from '@ghostfolio/client/services/cache.service';
|
||||||
import { GfValueModule } from '@ghostfolio/ui/value';
|
import { GfValueModule } from '@ghostfolio/ui/value';
|
||||||
|
|
||||||
@ -13,12 +16,15 @@ import { AdminPageComponent } from './admin-page.component';
|
|||||||
declarations: [AdminPageComponent],
|
declarations: [AdminPageComponent],
|
||||||
exports: [],
|
exports: [],
|
||||||
imports: [
|
imports: [
|
||||||
|
AdminOverviewModule,
|
||||||
AdminPageRoutingModule,
|
AdminPageRoutingModule,
|
||||||
|
AdminUsersModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
GfValueModule,
|
GfValueModule,
|
||||||
MatButtonModule,
|
MatButtonModule,
|
||||||
MatCardModule,
|
MatCardModule,
|
||||||
MatMenuModule
|
MatMenuModule,
|
||||||
|
MatTabsModule
|
||||||
],
|
],
|
||||||
providers: [CacheService],
|
providers: [CacheService],
|
||||||
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
schemas: [CUSTOM_ELEMENTS_SCHEMA]
|
||||||
|
@ -2,29 +2,31 @@
|
|||||||
|
|
||||||
:host {
|
:host {
|
||||||
color: rgb(var(--dark-primary-text));
|
color: rgb(var(--dark-primary-text));
|
||||||
display: block;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: calc(100vh - 5rem);
|
||||||
|
overflow-y: auto;
|
||||||
|
|
||||||
.users {
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
overflow-x: auto;
|
padding-bottom: constant(safe-area-inset-bottom);
|
||||||
|
|
||||||
table {
|
::ng-deep {
|
||||||
min-width: 100%;
|
gf-admin-overview,
|
||||||
|
gf-admin-users {
|
||||||
.mat-row,
|
flex: 1 1 auto;
|
||||||
.mat-header-row {
|
overflow-y: auto;
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.mat-flat-button {
|
.mat-tab-header {
|
||||||
::ng-deep {
|
border-bottom: 0;
|
||||||
.mat-button-wrapper {
|
|
||||||
display: block;
|
.mat-ink-bar {
|
||||||
overflow: hidden;
|
visibility: hidden !important;
|
||||||
text-overflow: ellipsis;
|
}
|
||||||
white-space: nowrap;
|
|
||||||
width: 100%;
|
.mat-tab-label-active {
|
||||||
|
color: rgba(var(--palette-primary-500), 1);
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
x
Reference in New Issue
Block a user