@@ -129,6 +122,7 @@
Update {{ data.name }}
mat-raised-button
color="primary"
(click)="done()"
+ [disabled]="form.invalid || !costEstimation"
>
Update
diff --git a/webgui/webapp/src/app/pages/admin-page/components/admin-user-click-dialog/admin-user-click-dialog.component.ts b/webgui/webapp/src/app/pages/admin-page/components/admin-user-click-dialog/admin-user-click-dialog.component.ts
index da0cdd2..747f6f3 100644
--- a/webgui/webapp/src/app/pages/admin-page/components/admin-user-click-dialog/admin-user-click-dialog.component.ts
+++ b/webgui/webapp/src/app/pages/admin-page/components/admin-user-click-dialog/admin-user-click-dialog.component.ts
@@ -14,13 +14,23 @@ import {
FormGroup,
FormsModule,
ReactiveFormsModule,
+ Validators,
} from '@angular/forms';
import { AdminService } from 'src/app/pages/admin-page/services/admin.service';
-import { catchError, of } from 'rxjs';
+import {
+ catchError,
+ debounceTime,
+ distinctUntilChanged,
+ forkJoin,
+ of,
+} from 'rxjs';
import * as _ from 'lodash';
import { ComponentSpinnerComponent } from 'src/app/components/component-spinner/component-spinner.component';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
+import { AwsService } from 'src/app/services/aws.service';
+import { bytesToGigabytes, gigabytesToBytes } from 'src/app/utils/file';
+import { UserQuotaService } from 'src/app/services/userquota.service';
@Component({
selector: 'app-admin-user-click-dialog',
@@ -48,44 +58,108 @@ export class AdminUserClickDialogComponent implements OnInit {
protected loading = false;
protected disableDelete = false;
+ // quota
+ protected costEstimation: number | null = 0;
+ protected usageSize = 0;
+ protected usageCount = 0;
+
constructor(
public dialogRef: MatDialogRef
,
private fb: FormBuilder,
private as: AdminService,
+ private uq: UserQuotaService,
+ private aws: AwsService,
private dg: MatDialog,
@Inject(MAT_DIALOG_DATA) public data: any,
) {
this.form = this.fb.group({
administrators: [false],
- managers: [false],
+ quotaSize: ['', [Validators.required, Validators.min(0)]],
+ quotaQueryCount: ['', [Validators.required, Validators.min(0)]],
});
}
ngOnInit(): void {
+ this.dialogRef.afterOpened().subscribe(() => {
+ this.getUserGroups();
+ });
+
+ this.onChangeCalculateCost();
+ }
+
+ onChangeCalculateCost() {
+ this.form.valueChanges
+ .pipe(debounceTime(400), distinctUntilChanged())
+ .subscribe((values) => {
+ if (values.quotaQueryCount && values.quotaSize) {
+ this.aws
+ .calculateQuotaEstimationPerMonth(
+ values.quotaQueryCount,
+ values.quotaSize,
+ )
+
+ .subscribe((res) => {
+ this.costEstimation = res;
+ });
+ }
+ });
+ }
+
+ getUserGroups() {
this.loading = true;
- this.as
+ // Define both observables
+ const userQuota$ = this.uq
+ .getUserQuota(this.data.sub)
+ .pipe(catchError(() => of(null)));
+
+ const userGroups$ = this.as
.listUsersGroups(this.data.email)
- .pipe(catchError(() => of(null)))
- .subscribe((response: any) => {
- const groups = _.get(response, 'groups', []);
- const user = _.get(response, 'user', null);
- const authorizer = _.get(response, 'authorizer', null);
- const groupNames = _.map(
- groups,
- (group) => _.split(group.GroupName, '-')[0],
- );
- const userGroups: { [key: string]: boolean } = {};
- _.each(groupNames, (gn: string) => {
- userGroups[gn] = true;
- });
- _.merge(this.initialGroups, userGroups);
- this.form.patchValue(userGroups);
- if (user === authorizer) {
- this.form.get('administrators')?.disable();
- this.disableDelete = true;
+ .pipe(catchError(() => of(null)));
+
+ // Use forkJoin to run them in parallel
+ forkJoin({ userQuota: userQuota$, userGroups: userGroups$ }).subscribe(
+ ({ userQuota, userGroups }) => {
+ // Process user quota response
+ if (userQuota) {
+ this.costEstimation = userQuota.CostEstimation;
+ this.usageSize = userQuota.Usage.usageSize;
+ this.usageCount = userQuota.Usage.usageCount;
+
+ this.form.patchValue({
+ quotaSize: bytesToGigabytes(userQuota.Usage.quotaSize),
+ quotaQueryCount: userQuota.Usage.quotaQueryCount,
+ });
+ }
+
+ // Process user groups response
+ if (userGroups) {
+ const groups = _.get(userGroups, 'groups', []);
+ const user = _.get(userGroups, 'user', null);
+ const authorizer = _.get(userGroups, 'authorizer', null);
+ const groupNames = _.map(
+ groups,
+ (group) => _.split(group.GroupName, '-')[0],
+ );
+ const userGroupsObj: { [key: string]: boolean } = {};
+ _.each(groupNames, (gn: string) => {
+ userGroupsObj[gn] = true;
+ });
+ _.merge(this.initialGroups, userGroupsObj);
+ this.form.patchValue(userGroupsObj);
+
+ if (user === authorizer) {
+ this.form.get('administrators')?.disable();
+ this.disableDelete = true;
+ }
}
+
this.loading = false;
- });
+ },
+ (error) => {
+ console.error('Error loading user data:', error);
+ this.loading = false;
+ },
+ );
}
async delete() {
@@ -117,19 +191,33 @@ export class AdminUserClickDialogComponent implements OnInit {
this.dialogRef.close();
}
- done(): void {
- if (_.isEqual(this.initialGroups, this.form.value)) {
- this.dialogRef.close();
- return;
- }
+ updateQuota() {
+ return this.uq
+ .upsertUserQuota(this.data.sub, this.costEstimation, {
+ quotaSize: gigabytesToBytes(this.form.value.quotaSize),
+ quotaQueryCount: this.form.value.quotaQueryCount,
+ usageSize: this.usageSize, // bytes
+ usageCount: this.usageCount,
+ })
+ .pipe(catchError(() => of(null)));
+ }
+ updateUser() {
+ const groups = _.pick(this.form.value, ['administrators']);
+ return this.as
+ .updateUsersGroups(this.data.email, groups)
+ .pipe(catchError(() => of(null)));
+ }
+
+ done(): void {
this.loading = true;
- this.as
- .updateUsersGroups(this.data.email, this.form.value)
- .pipe(catchError(() => of(null)))
- .subscribe(() => {
- this.loading = false;
- this.dialogRef.close();
- });
+
+ const updateQuota$ = this.updateQuota();
+ const updateUser$ = this.updateUser();
+
+ forkJoin([updateQuota$, updateUser$]).subscribe(() => {
+ this.loading = false;
+ this.dialogRef.close();
+ });
}
}
diff --git a/webgui/webapp/src/app/pages/clinic-page/svep-results/svep-results.component.html b/webgui/webapp/src/app/pages/clinic-page/svep-results/svep-results.component.html
index aca29b6..1917cec 100644
--- a/webgui/webapp/src/app/pages/clinic-page/svep-results/svep-results.component.html
+++ b/webgui/webapp/src/app/pages/clinic-page/svep-results/svep-results.component.html
@@ -16,6 +16,7 @@ View Results
class="w-[110px] mr-1"
mat-raised-button
color="primary"
+ style="color: white"
[routerLink]="[]"
[queryParams]="{ jobId: requestIdFormControl.value }"
>Load
-
+
Login
diff --git a/webgui/webapp/src/app/pages/portal-page/query-page/components/query-result-viewer-container/query-result-viewer-container.component.ts b/webgui/webapp/src/app/pages/portal-page/query-page/components/query-result-viewer-container/query-result-viewer-container.component.ts
index acf38f2..8c69125 100644
--- a/webgui/webapp/src/app/pages/portal-page/query-page/components/query-result-viewer-container/query-result-viewer-container.component.ts
+++ b/webgui/webapp/src/app/pages/portal-page/query-page/components/query-result-viewer-container/query-result-viewer-container.component.ts
@@ -11,9 +11,12 @@ import { MatCardModule } from '@angular/material/card';
import { DportalService } from 'src/app/services/dportal.service';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { SpinnerService } from 'src/app/services/spinner.service';
-import { catchError, from, of } from 'rxjs';
+import { catchError, filter, firstValueFrom, from, of, switchMap } from 'rxjs';
import { MatSnackBar, MatSnackBarModule } from '@angular/material/snack-bar';
import { Storage } from 'aws-amplify';
+import { getTotalStorageSize } from 'src/app/utils/file';
+import { UserQuotaService } from 'src/app/services/userquota.service';
+import { AuthService } from 'src/app/services/auth.service';
@Component({
selector: 'app-query-result-viewer-container',
@@ -51,6 +54,7 @@ export class QueryResultViewerContainerComponent implements OnChanges {
private dg: MatDialog,
private ss: SpinnerService,
private sb: MatSnackBar,
+ private uq: UserQuotaService,
) {}
ngOnChanges(): void {
@@ -69,6 +73,7 @@ export class QueryResultViewerContainerComponent implements OnChanges {
type: 'text/json;charset=utf-8;',
});
const url = URL.createObjectURL(blob);
+
const a = document.createElement('a');
a.href = url;
a.download = 'data.json';
@@ -77,7 +82,52 @@ export class QueryResultViewerContainerComponent implements OnChanges {
document.body.removeChild(a);
}
+ // Calculate total size from storage and current query result
+ async totalStorage(queryResults: any) {
+ // Get files in the storage
+ const res = await Storage.list(``, {
+ pageSize: 'ALL',
+ level: 'private',
+ });
+
+ // Get total size from storage
+ const bytesTotal = getTotalStorageSize(res.results);
+
+ // Get size from current query result
+ const blob = new Blob([JSON.stringify(queryResults, null, 2)], {
+ type: 'text/json;charset=utf-8;',
+ });
+
+ return bytesTotal + blob.size;
+ }
+
+ updateUserQuota(userQuota: any, currentTotalSize: number) {
+ this.uq
+ .upsertUserQuota(userQuota.userSub, userQuota.costEstimation, {
+ quotaSize: userQuota.quotaSize,
+ quotaQueryCount: userQuota.quotaQueryCount,
+ usageSize: currentTotalSize,
+ usageCount: userQuota.usageCount,
+ })
+ .pipe(catchError(() => of(null)));
+ }
+
async save(content: any) {
+ const userQuota = await firstValueFrom(this.uq.getCurrentUsage());
+ const currentTotalSize = await this.totalStorage(content);
+
+ // Check if the current total size is greater than the user's quota size
+ if (currentTotalSize >= userQuota.quotaSize) {
+ this.sb.open(
+ 'Cannot Save Query because Quota Limit reached. Please contact administrator to increase your quota.',
+ 'Okay',
+ {
+ duration: 60000,
+ },
+ );
+ return;
+ }
+
const { TextInputDialogComponent } = await import(
'../../../../../components/text-input-dialog/text-input-dialog.component'
);
@@ -91,6 +141,7 @@ export class QueryResultViewerContainerComponent implements OnChanges {
placeholder: 'My query results',
},
});
+
dialog.afterClosed().subscribe((name) => {
if (name) {
this.ss.start();
@@ -105,6 +156,8 @@ export class QueryResultViewerContainerComponent implements OnChanges {
if (!res) {
this.sb.open('Saving failed', 'Okay', { duration: 60000 });
}
+
+ this.updateUserQuota(userQuota, currentTotalSize);
this.ss.end();
});
}
diff --git a/webgui/webapp/src/app/pages/portal-page/query-page/components/query-tab/query-tab.component.ts b/webgui/webapp/src/app/pages/portal-page/query-page/components/query-tab/query-tab.component.ts
index 41a56ac..a4ed13b 100644
--- a/webgui/webapp/src/app/pages/portal-page/query-page/components/query-tab/query-tab.component.ts
+++ b/webgui/webapp/src/app/pages/portal-page/query-page/components/query-tab/query-tab.component.ts
@@ -24,7 +24,7 @@ import {
} from 'src/app/utils/parsers';
import { MatDialog } from '@angular/material/dialog';
import { FilterTypes, ScopeTypes } from 'src/app/utils/interfaces';
-import { catchError, of, Subscription } from 'rxjs';
+import { catchError, filter, firstValueFrom, of, Subscription } from 'rxjs';
import { MatSnackBar } from '@angular/material/snack-bar';
import _ from 'lodash';
import { AsyncPipe } from '@angular/common';
@@ -49,6 +49,8 @@ import {
twoRangesValidator,
} from 'src/app/utils/validators';
import { customQueries } from './custom-queries';
+import { AuthService } from 'src/app/services/auth.service';
+import { UserQuotaService } from 'src/app/services/userquota.service';
// import { result, query, endpoint } from './test_responses/individuals';
// import { result, query } from './test_responses/biosamples';
@@ -140,6 +142,11 @@ export class QueryTabComponent implements OnInit, AfterViewInit, OnDestroy {
page!: number;
private subscription: Subscription | null = null;
+ // user quota
+ protected userSub: string = '';
+ protected quotaQueryCount: number = 0;
+ protected usageCount: number = 0;
+
constructor(
private fb: FormBuilder,
private qs: QueryService,
@@ -147,6 +154,7 @@ export class QueryTabComponent implements OnInit, AfterViewInit, OnDestroy {
public dg: MatDialog,
private sb: MatSnackBar,
private ss: SpinnerService,
+ private uq: UserQuotaService,
) {
this.form = this.fb.group({
projects: [[], Validators.required],
@@ -301,8 +309,21 @@ export class QueryTabComponent implements OnInit, AfterViewInit, OnDestroy {
this.openPanels[index] = false;
}
- run() {
+ async run() {
this.ss.start();
+
+ const { quotaQueryCount, usageCount, userSub } = await firstValueFrom(
+ this.uq.getCurrentUsage(),
+ );
+
+ if (usageCount >= quotaQueryCount) {
+ this.sb.open('Run Query is reach quota limit.', 'Okay', {
+ duration: 60000,
+ });
+ this.ss.end();
+ return;
+ }
+
const form: any = this.form.value;
const query = {
projects: form.projects,
@@ -345,6 +366,10 @@ export class QueryTabComponent implements OnInit, AfterViewInit, OnDestroy {
this.results = data;
this.endpoint = endpoint;
this.scope = form.customReturn ? form.return : form.scope;
+
+ this.uq.incrementUsageCount(userSub).subscribe(() => {
+ console.log('usage count incremented');
+ });
} else {
this.sb.open(
'API request failed. Please check your parameters.',
diff --git a/webgui/webapp/src/app/services/userquota.service.spec.ts b/webgui/webapp/src/app/services/userquota.service.spec.ts
new file mode 100644
index 0000000..d399997
--- /dev/null
+++ b/webgui/webapp/src/app/services/userquota.service.spec.ts
@@ -0,0 +1,15 @@
+import { TestBed } from '@angular/core/testing';
+import { UserQuotaService } from './userquota.service';
+
+describe('UserQuotaService', () => {
+ let service: UserQuotaService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ service = TestBed.inject(UserQuotaService);
+ });
+
+ it('should be created', () => {
+ expect(service).toBeTruthy();
+ });
+});
diff --git a/webgui/webapp/src/app/services/userquota.service.ts b/webgui/webapp/src/app/services/userquota.service.ts
new file mode 100644
index 0000000..041be9f
--- /dev/null
+++ b/webgui/webapp/src/app/services/userquota.service.ts
@@ -0,0 +1,70 @@
+import { Injectable } from '@angular/core';
+import { API } from 'aws-amplify';
+import { catchError, filter, from, map, Observable, of, switchMap } from 'rxjs';
+import { environment } from 'src/environments/environment';
+import { AuthService } from './auth.service';
+
+@Injectable({
+ providedIn: 'root',
+})
+export class UserQuotaService {
+ constructor(private auth: AuthService) {}
+
+ getUserQuota(id: string) {
+ console.log('get user quota');
+ return from(
+ API.get(environment.api_endpoint_sbeacon.name, `dportal/quota/${id}`, {}),
+ );
+ }
+
+ upsertUserQuota(id: string, costEstimation: number | null, usage: any) {
+ console.log('upsert user quota');
+ return from(
+ API.post(environment.api_endpoint_sbeacon.name, 'dportal/quota', {
+ body: {
+ IdentityUser: id,
+ CostEstimation: costEstimation,
+ Usage: usage,
+ },
+ }),
+ );
+ }
+
+ incrementUsageCount(id: string) {
+ console.log('incrementUsageCount');
+ return from(
+ API.post(
+ environment.api_endpoint_sbeacon.name,
+ `dportal/quota/${id}/increment_usagecount`,
+ {},
+ ),
+ );
+ }
+
+ getCurrentUsage(): Observable<{
+ userSub: string;
+ quotaQueryCount: number;
+ quotaSize: number;
+ usageCount: number;
+ usageSize: number;
+ costEstimation: number;
+ }> {
+ return this.auth.user.pipe(
+ filter((u) => !!u),
+ switchMap((u: any) => {
+ const userSub = u.attributes.sub;
+ return this.getUserQuota(userSub).pipe(
+ catchError(() => of(null)),
+ map((res) => ({
+ userSub,
+ quotaQueryCount: res?.Usage.quotaQueryCount || 0,
+ usageCount: res?.Usage.usageCount || 0,
+ quotaSize: res?.Usage.quotaSize || 0,
+ usageSize: res?.Usage.usageSize || 0,
+ costEstimation: res?.CostEstimation || 0,
+ })),
+ );
+ }),
+ );
+ }
+}
diff --git a/webgui/webapp/src/app/utils/file.ts b/webgui/webapp/src/app/utils/file.ts
new file mode 100644
index 0000000..efb35c0
--- /dev/null
+++ b/webgui/webapp/src/app/utils/file.ts
@@ -0,0 +1,68 @@
+const bytesInOneGB = 1024 * 1024 * 1024; // 1024 * 1024 * 1024 Bytes
+
+/**
+ * Converts a given number of bytes into a human-readable string format.
+ *
+ * @param bytes - The number of bytes to be converted.
+ * @returns A string representing the size in a more readable format (e.g., '10.24 KB', '1.00 MB').
+ *
+ * @example
+ * ```typescript
+ * formatBytes(1024); // '1.00 KB'
+ * formatBytes(123456789); // '117.74 MB'
+ * ```
+ */
+export function formatBytes(bytes: number): string {
+ if (bytes === 0) return '0 Bytes';
+ const k = 1024;
+ const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(0)) + ' ' + sizes[i];
+}
+
+/**
+ * Converts a given number of gigabytes into bytes.
+ *
+ * @param gigabytes - The number of gigabytes to be converted.
+ * @returns The equivalent number of bytes.
+ *
+ * @example
+ * ```typescript
+ * gigabytesToBytes(1); // 1073741824
+ * gigabytesToBytes(0.5); // 536870912
+ * ```
+ */
+export function gigabytesToBytes(gigabytes: number): number {
+ return gigabytes * bytesInOneGB;
+}
+
+/**
+ * Converts a given number of bytes into gigabytes.
+ *
+ * @param bytes - The number of bytes to be converted.
+ * @returns The equivalent number of gigabytes.
+ *
+ * @example
+ * ```typescript
+ * bytesToGigabytes(1073741824); // 1
+ * bytesToGigabytes(536870912); // 0.5
+ * ```
+ */
+export function bytesToGigabytes(bytes: number): number {
+ return bytes / bytesInOneGB;
+}
+
+/**
+ * Calculates the total size of all items in a given data storage object.
+ *
+ * @param dataStorage - The data storage object containing the items to calculate the total size of.
+ * @returns The total size of all items in the data storage object.
+ *
+ * @example
+ * ```typescript
+ * getTotalSize(dataStorage); // 123456789
+ * ```
+ */
+export function getTotalStorageSize(dataStorage: any): number {
+ return dataStorage.reduce((total: number, item: any) => total + item.size, 0);
+}
diff --git a/webgui/webapp/src/assets/images/header.png b/webgui/webapp/src/assets/images/header.png
index 18e17f6..f9cae49 100755
Binary files a/webgui/webapp/src/assets/images/header.png and b/webgui/webapp/src/assets/images/header.png differ
diff --git a/webgui/webapp/src/styles.scss b/webgui/webapp/src/styles.scss
index b257458..3d294bd 100644
--- a/webgui/webapp/src/styles.scss
+++ b/webgui/webapp/src/styles.scss
@@ -12,17 +12,44 @@ Tailwind
@include mat.core();
-$bui-primary: mat.define-palette(mat.$cyan-palette, 900, 800, 700);
+$custom-contrast: (
+ 50: black,
+);
+
+$custom-color: (
+ 10: #e1fbfa,
+ 50: #16b3ac,
+ 60: #128f8a,
+ 100: #b3ece8,
+ 200: #80e1dc,
+ 300: #4dd6d0,
+ 400: #26cbc4,
+ 500: #16b3ac,
+ // primary color
+ 600: #14a59d,
+ 700: #12878d,
+ 800: #106a7e,
+ 900: #0d4e6e,
+ A100: #a1fdfc,
+ A200: #6ef4f4,
+ A400: #3beceb,
+ A700: #21e4e3,
+ contrast: $custom-contrast,
+
+ // Add contrast map here
+);
+
+$bui-primary: mat.define-palette($custom-color, 50);
$bui-accent: mat.define-palette(mat.$cyan-palette, A400, A200, A700);
$bui-theme: mat.define-light-theme(
(
color: (
primary: $bui-primary,
- accent: $bui-accent
+ accent: $bui-accent,
),
typography: mat.define-typography-config(),
- density: 0
+ density: 0,
)
);
@@ -40,7 +67,7 @@ body {
}
body {
- background: #2f2f2f url(assets/images/bg02.jpg) repeat;
+ background: theme("colors.primary-10");
font-family: "Source Sans Pro", sans-serif;
font-size: 12pt;
font-weight: 300;
@@ -48,7 +75,9 @@ body {
}
.bui-card-background {
- background-color: #1d4753;
- background-image: url(/assets/images/dot-grid.png);
+ background-color: theme("colors.primary-10");
}
+button {
+ color: white !important;
+}
diff --git a/webgui/webapp/tailwind.config.js b/webgui/webapp/tailwind.config.js
index 894b7ea..7948205 100644
--- a/webgui/webapp/tailwind.config.js
+++ b/webgui/webapp/tailwind.config.js
@@ -3,14 +3,17 @@ module.exports = {
corePlugins: {
preflight: false,
},
- content: [
- "./src/**/*.{html,ts}",
- ],
+ content: ["./src/**/*.{html,ts}"],
theme: {
- extend: {},
+ extend: {
+ colors: {
+ "primary-10": "#E1FBFA",
+ "primary-50": "#16B3AC",
+ "primary-60": "#128F8A",
+ "warning-10": "#FFA705",
+ // Add your custom color here
+ },
+ },
},
- plugins: [
-
- ],
-}
-
+ plugins: [],
+};