From ea66f7fe6a1c0c1172740a6904e3dd9615ecf313 Mon Sep 17 00:00:00 2001 From: kflemin <2205659+kflemin@users.noreply.github.com> Date: Thu, 17 Apr 2025 19:13:53 -0600 Subject: [PATCH 1/5] analyses page --- src/@seed/api/analysis/analysis.service.ts | 151 +++++++++++++ src/@seed/api/analysis/analysis.types.ts | 71 ++++++ src/@seed/api/analysis/index.ts | 2 + src/@seed/api/cycle/index.ts | 1 + .../modules/analyses/analyses.component.html | 109 +++++++++- .../modules/analyses/analyses.component.scss | 76 +++++++ .../modules/analyses/analyses.component.ts | 202 +++++++++++++++++- src/app/modules/analyses/analyses.routes.ts | 44 +++- .../analysis-run/analysis-run.component.html | 3 + .../analysis-run/analysis-run.component.ts | 22 ++ .../modules/analyses/analysis-run/index.ts | 1 + .../delete-analysis-dialog.component.html | 29 +++ .../delete-analysis-dialog.component.ts | 48 +++++ .../analyses/delete-analysis-dialog/index.ts | 1 + src/app/modules/analyses/index.ts | 1 + 15 files changed, 753 insertions(+), 8 deletions(-) create mode 100644 src/@seed/api/analysis/analysis.service.ts create mode 100644 src/@seed/api/analysis/analysis.types.ts create mode 100644 src/@seed/api/analysis/index.ts create mode 100644 src/app/modules/analyses/analyses.component.scss create mode 100644 src/app/modules/analyses/analysis-run/analysis-run.component.html create mode 100644 src/app/modules/analyses/analysis-run/analysis-run.component.ts create mode 100644 src/app/modules/analyses/analysis-run/index.ts create mode 100644 src/app/modules/analyses/delete-analysis-dialog/delete-analysis-dialog.component.html create mode 100644 src/app/modules/analyses/delete-analysis-dialog/delete-analysis-dialog.component.ts create mode 100644 src/app/modules/analyses/delete-analysis-dialog/index.ts diff --git a/src/@seed/api/analysis/analysis.service.ts b/src/@seed/api/analysis/analysis.service.ts new file mode 100644 index 00000000..a39698c3 --- /dev/null +++ b/src/@seed/api/analysis/analysis.service.ts @@ -0,0 +1,151 @@ +import type { HttpErrorResponse } from '@angular/common/http' +import { HttpClient } from '@angular/common/http' +import { inject, Injectable } from '@angular/core' +import { OrganizationService } from '@seed/api/organization' +import { ErrorService } from '@seed/services' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' +// import type { Observable } from 'rxjs' +import { BehaviorSubject, catchError, forkJoin, map, Observable, Subject, takeUntil, tap } from 'rxjs' +import type { Analysis, AnalysisResponse, AnalysesMessage, AnalysesViews, ListAnalysesResponse, ListMessagesResponse, OriginalView, View } from './analysis.types' + +@Injectable({ providedIn: 'root' }) +export class AnalysisService { + private _httpClient = inject(HttpClient) + private _organizationService = inject(OrganizationService) + private _snackBar = inject(SnackBarService) + private _errorService = inject(ErrorService) + private _analyses = new BehaviorSubject([]) // BehaviorSubject to hold the analyses + private _views = new BehaviorSubject([]) + private _originalViews = new BehaviorSubject([]) + private _messages = new BehaviorSubject([]) + private readonly _unsubscribeAll$ = new Subject() + orgId: number + analyses$ = this._analyses.asObservable() // Expose the observable for components to subscribe + views$ = this._views.asObservable() + originalViews$ = this._originalViews.asObservable() + messages$ = this._messages.asObservable() + + constructor() { + this._organizationService.currentOrganization$ + .pipe( + takeUntil(this._unsubscribeAll$), + tap(({ org_id }) => { + this.orgId = org_id + }), + ) + .subscribe() + } + + // Method to update the analyses list + updateAnalyses(analyses: Analysis[]): void { + this._analyses.next(analyses) + } + + getAnalyses(): Observable { + return this._httpClient + .get(`/api/v3/analyses/?organization_id=${this.orgId}`) + .pipe( + map((response) => response), + tap((response) => { + this._analyses.next(response.analyses) + this._views.next(response.views) + this._originalViews.next(response.original_views) + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching analyses') + }), + ) + } + + // get AnalysesMessages + getAnalysesMessages(): Observable { + return this._httpClient + .get(`/api/v3/analyses/0/messages/?organization_id=${this.orgId}`) + .pipe( + map((response) => response.messages), + tap((response) => { + this._messages.next(response) + }), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching analyses messages') + }), + ) + } + + // get single analysis + getAnalysis(_analysisId): Observable { + return this._httpClient + .get(`/api/v3/analyses/${_analysisId}?organization_id=${this.orgId}`) + .pipe( + map((response) => response.analysis), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching analyses') + }), + ) + } + + // delete analysis + delete(id: number) { + const url = `/api/v3/analyses/${id}/?organization_id=${this.orgId}` + return this._httpClient.delete(url).pipe( + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error deleting analysis') + }), + ) + } + + // poll for completion (pass in list of analyses that are still running) + // This function should be called on an interval until all analyses are completed + // For the analyses provided, poll for Completion one at a time + // Completion statuses include: 'Failed', 'Stopped', 'Completed' + pollForCompletion(analyses: Analysis[]): Observable { + const completionStatuses = ['Failed', 'Stopped', 'Completed'] + return new Observable((observer) => { + let remainingAnalyses = [...analyses] // Clone the list of analyses to track remaining ones + const pollInterval = setInterval(() => { + const analysisRequests = remainingAnalyses.map((analysis) => this.getAnalysis(analysis.id)) + forkJoin(analysisRequests).subscribe({ + next: (updatedAnalyses) => { + // Get the current list of analyses from the BehaviorSubject + const currentAnalyses = this._analyses.getValue() + // Merge the updated analyses into the current list + const mergedAnalyses = currentAnalyses.map((analysis) => { + const updatedAnalysis = updatedAnalyses.find((updated) => updated.id === analysis.id) + if (updatedAnalysis) { + const isCompletionStatus = completionStatuses.includes(updatedAnalysis.status) + const statusChanged = analysis.status !== updatedAnalysis.status + // Only update the analysis if the status has changed to a completion status + if (isCompletionStatus && statusChanged) { + return updatedAnalysis + } + } + // If no update is needed, return the original analysis + return analysis + }) + // Emit the merged list to the BehaviorSubject + this._analyses.next(mergedAnalyses) + // Remove analyses that have reached a completion status + remainingAnalyses = remainingAnalyses.filter( + (analysis) => !updatedAnalyses.some( + (updated) => updated.id === analysis.id && completionStatuses.includes(updated.status) + ), + ) + // If all analyses have reached a completion status, complete the observable + if (remainingAnalyses.length === 0) { + clearInterval(pollInterval) + observer.next({ + analyses: mergedAnalyses, + views: this._views.getValue(), // Assuming views are already stored in the BehaviorSubject + }) + observer.complete() + } + }, + error: (error: HttpErrorResponse) => { + clearInterval(pollInterval) + observer.error(this._errorService.handleError(error, 'Error polling for completion')) + }, + }) + }, 5000) // Poll every 5 seconds + }) + } +} diff --git a/src/@seed/api/analysis/analysis.types.ts b/src/@seed/api/analysis/analysis.types.ts new file mode 100644 index 00000000..65fb71ec --- /dev/null +++ b/src/@seed/api/analysis/analysis.types.ts @@ -0,0 +1,71 @@ +// Highlight Subset type +export type Highlight = { + name: string; + value: string; +} + +// Analysis type +export type Analysis = { + id: number; + service: string; + status: string; + name: string; + created_at: string; + start_time: string; + end_time: string; + configuration: Record; // configuration is different for each analysis type + parsed_results: Record; // parsed_results is different for each analysis type + user: number; + organization: number; + access_level_instance: number; + number_of_analysis_property_views: number; + views: number[]; + cycles: number[]; + highlights: Highlight[]; + _finished_with_tasks: boolean; // used to determine if an analysis has no currently running tasks +} + +// Analysis by View type +export type View = { + id: number; + analysis: number; + cycle: number; + display_name: string; + output_files: Record[]; + property: number; + property_state: number; +} + +// OriginalView is an array of key values where the key is a string and the value is a number +export type OriginalView = Record + +export type ListAnalysesResponse = { + status: 'success'; + analyses: Analysis[]; + views: View[]; + original_views: OriginalView[]; +} + +export type AnalysisResponse = { + status: 'success'; + analysis: Analysis; +} + +export type AnalysesViews = { + analyses: Analysis[]; + views: View[]; +} + +export type AnalysesMessage = { + id: number; + analysis: number; + analysis_property_view: number; + debug_message: string; + type: string; + user_message: string; +} + +export type ListMessagesResponse = { + status: 'success'; + messages: AnalysesMessage[]; +} diff --git a/src/@seed/api/analysis/index.ts b/src/@seed/api/analysis/index.ts new file mode 100644 index 00000000..7f827b6e --- /dev/null +++ b/src/@seed/api/analysis/index.ts @@ -0,0 +1,2 @@ +export * from './analysis.service' +export * from './analysis.types' diff --git a/src/@seed/api/cycle/index.ts b/src/@seed/api/cycle/index.ts index ad7ed2a0..b3b343b8 100644 --- a/src/@seed/api/cycle/index.ts +++ b/src/@seed/api/cycle/index.ts @@ -1 +1,2 @@ export * from './cycle.types' +export * from './cycle.service' diff --git a/src/app/modules/analyses/analyses.component.html b/src/app/modules/analyses/analyses.component.html index 107bdd0f..12e67984 100644 --- a/src/app/modules/analyses/analyses.component.html +++ b/src/app/modules/analyses/analyses.component.html @@ -1,3 +1,110 @@ -
Analyses Content
+
+
+
+
+ + +
+ + Analysis Name + Actions + Number of Properties + Type + Configuration + Created + Run Status + Run Date + Run Duration + Run Cycle + + + @for (analysis of analyses; track trackByIdAndStatus(analysis)) { + + {{ analysis.name }} + + + + + @if (currentUser.org_role !== 'viewer') { + + } + + + {{ analysis.number_of_analysis_property_views }} + {{ analysis.service }} + +
+
    + + @for (key of getKeys(analysis.configuration); track key) { +
  • {{ key }}: {{ analysis.configuration[key] }}
  • + } +
+
+
+ {{ analysis.created_at | date : 'MM-dd-yy HH:mm' }} + + + {{ analysis.status }} + + {{ analysis.start_time | date : 'MM-dd-yy HH:mm' }} + {{ runDuration(analysis) }} + {{ cycle(analysis.cycles[0]) }} + } +
+ +
+
+ +
+ @for (view of views; track view.id) { + + + + + Run ID {{ view.id }} + + + + + + + {{ view.display_name || 'Property ' + originalViews[view.id] }} + + +
+ @if (filteredMessages(view.id); as items) { +
    + @for (message of items; track message.id) { +
  • + {{ message['user_message'] }} - {{ message['debug_message'] }} +
  • + } +
+ } +
+
+
+
+
+ } +
+
+
+
+
+
+
+ + +
diff --git a/src/app/modules/analyses/analyses.component.scss b/src/app/modules/analyses/analyses.component.scss new file mode 100644 index 00000000..d3b393b0 --- /dev/null +++ b/src/app/modules/analyses/analyses.component.scss @@ -0,0 +1,76 @@ +$warningBackground: #fcf8e3; +$errorBackground: #f2dede; +$successBackground: #dff0d8; +$infoBackground: #d9edf7; +$cellBackground: #f2f2f2; + +.even-card { + background-color: $cellBackground !important; +} + +.dynamic-grid { + padding-bottom: 10px; +} + +// mat-grid-tile { +// display: flex; +// flex-direction: column; +// justify-content: flex-start; +// align-items: stretch; /* Ensure content stretches to fit the width */ +// height: auto; /* Allow the tile to grow based on content */ +// overflow: hidden; /* Prevent content from spilling out */ +// padding: 8px; /* Add padding for better spacing */ +// box-sizing: border-box; /* Include padding in the height/width calculations */ +// } + +// .tile-content { +// display: flex; +// flex-direction: column; +// justify-content: flex-start; +// align-items: flex-start; +// width: 100%; /* Ensure the content takes up the full width */ +// height: auto; /* Allow the content to grow dynamically */ +// overflow: visible; /* Ensure content is not clipped */ +// } + +.header-row { + font-weight: bold; + padding: 0 5px; + box-sizing: border-box; + background-color: $cellBackground !important; +} + +.alt-row { + background-color: $cellBackground !important; +} + +.pad-row { + padding-left:10px !important; +} + +/* ANALYSIS STATUS COLORS */ + +.analysis-status { + &.creating, + &.warning { + background: $warningBackground !important; + } + + &.stopped, + &.failed, + &.error { + background: $errorBackground !important; + } + + &.ready, + &.completed, + &.success { + background: $successBackground !important; + } + + &.queued, + &.running, + &.info { + background: $infoBackground !important; + } +} diff --git a/src/app/modules/analyses/analyses.component.ts b/src/app/modules/analyses/analyses.component.ts index 65d19de4..056c32c0 100644 --- a/src/app/modules/analyses/analyses.component.ts +++ b/src/app/modules/analyses/analyses.component.ts @@ -1,14 +1,204 @@ -import type { OnInit } from '@angular/core' -import { Component } from '@angular/core' -import { PageComponent } from '@seed/components' +import { CommonModule } from '@angular/common' +import type { OnDestroy, OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { MatCardModule } from '@angular/material/card' +import { MatDialog, MatDialogModule } from '@angular/material/dialog' +import { MatGridListModule } from '@angular/material/grid-list' +import { MatIconModule } from '@angular/material/icon' +import { MatListModule } from '@angular/material/list' +import { MatTabsModule } from '@angular/material/tabs' +import { RouterLink } from '@angular/router' +import { ActivatedRoute, Router } from '@angular/router' +import { from, map, Observable, Subject, skip, switchMap, takeUntil, tap } from 'rxjs' +import type { AnalysesMessage, Analysis, OriginalView, View } from '@seed/api/analysis' +import { AnalysisService } from '@seed/api/analysis' +import type { Cycle } from '@seed/api/cycle' +import { OrganizationService } from '@seed/api/organization' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' +import type { CurrentUser } from '@seed/api/user' +import { UserService } from '@seed/api/user' +import { TranslocoService } from '@jsverse/transloco' +import { ObjectRendererComponent, PageComponent, TableContainerComponent } from '@seed/components' +import { SharedImports } from '@seed/directives' +import { DeleteAnalysisDialogComponent } from './delete-analysis-dialog' @Component({ selector: 'seed-analyses', templateUrl: './analyses.component.html', - imports: [PageComponent], + styleUrls: ['./analyses.component.scss'], + imports: [ + // AgGridAngular, + CommonModule, + MatCardModule, + MatDialogModule, + MatGridListModule, + MatIconModule, + MatListModule, + MatTabsModule, + ObjectRendererComponent, + PageComponent, + RouterLink, + TableContainerComponent, + SharedImports, + ], }) -export class AnalysesComponent implements OnInit { +export class AnalysesComponent implements OnInit, OnDestroy { + analyses: Analysis[] + views: View[] + originalViews: OriginalView[] + cycles: Cycle[] + messages: AnalysesMessage[] + currentUser: CurrentUser + private _dialog = inject(MatDialog) + private _route = inject(ActivatedRoute) + private _router = inject(Router) + private _analysisService = inject(AnalysisService) + private _organizationService = inject(OrganizationService) + private _snackBar = inject(SnackBarService) + private _userService = inject(UserService) + private _transloco = inject(TranslocoService) + private readonly _unsubscribeAll$ = new Subject() + ngOnInit(): void { - console.log('Analyses') + this._userService.currentUser$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((currentUser) => { + this.currentUser = currentUser + }) + + this._init() + + // Subscribe to the analyses$ observable to keep the analyses list in sync + this._analysisService.analyses$ + .pipe(takeUntil(this._unsubscribeAll$)) // Automatically unsubscribe when the component is destroyed + .subscribe((analyses) => { + this.analyses = analyses + }) + + // Rerun resolver and initializer on org change + this._organizationService.currentOrganization$.pipe(skip(1)).subscribe(() => { + from(this._router.navigate([this._router.url])).subscribe(() => { + this._init() + }) + }) + + // subscribe to the list of pending analyses. (call poll function with list of IDs) + // function in tap that handles the function. figure out the list here (maintain the list) + // I will update the observable when the status changes. + // when it changes, remove from the list and change the status. + // take updated analysis and replace in this._analyses + // figure out which ones are pending and poll those via service + } + + ngOnDestroy(): void { + // Clean up subscriptions and other resources + this._unsubscribeAll$.next() + this._unsubscribeAll$.complete() + } + + cycle(_id: number): string { + const cycle: Cycle = this.cycles.find((cycle) => cycle.id === _id) + if (cycle) { + return cycle.name + } + return '' + } + + // add flag to the analysis indicating it has no currently running tasks + // Used to determine if we should indicate on UI if an analysis's status is being polled + mark_analysis_not_active(analysis_id: number): void { + const analysis_index = this.analyses.findIndex((analysis) => analysis.id === analysis_id) + this.analyses[analysis_index]._finished_with_tasks = true + }; + + // Return messages filtered by analysis property view + filteredMessages(_id: number): AnalysesMessage[] { + return this.messages.filter((item) => item.analysis_property_view === _id) + } + + // calculate run duration from start_time and end_time in minutes and seconds only. don't display hours if hours is 0 + runDuration(analysis): string { + const start = new Date(analysis.start_time) + const end = new Date(analysis.end_time) + const duration = Math.abs(end.getTime() - start.getTime()) + const minutes = Math.floor((duration % (1000 * 60 * 60)) / (1000 * 60)) + const seconds = Math.floor((duration % (1000 * 60)) / 1000) + return `${minutes}m ${seconds}s` + } + + trackById(index: number, item: { id: number }): number { + return item.id + } + + trackByIdAndStatus(item: Analysis): string { + return `${item.id}-${item.status}` + } + + getKeys(obj: any): string[] { + return Object.keys(obj); + } + + deleteAnalysis(analysis: Analysis): void { + const dialogRef = this._dialog.open(DeleteAnalysisDialogComponent, { + width: '40rem', + data: { analysis }, + }) + + dialogRef + .afterClosed() + .pipe( + takeUntil(this._unsubscribeAll$), + tap((result) => { + if (result != 'canceled') { // Only proceed if the dialog confirms deletion + // Remove the analysis and related data + this.analyses = this.analyses.filter((item) => item.id !== analysis.id) + this.views = this.views.filter((item) => item.analysis !== analysis.id) + this.messages = this.messages.filter((item) => item.analysis_property_view !== analysis.id) + + // Fetch the translated string and show a snackbar + const successMessage = this._transloco.translate('Analysis Deleted Successfully') + this._snackBar.success(successMessage) + } + }), + ) + .subscribe() + } + + private _init() { + this.analyses = this._route.snapshot.data.analyses.analyses as Analysis[] + this.views = this._route.snapshot.data.analyses.views as View[] + this.originalViews = this._route.snapshot.data.analyses.original_views as OriginalView[] + this.cycles = this._route.snapshot.data.cycles as Cycle[] + this.messages = this._route.snapshot.data.messages as AnalysesMessage[] + console.log('analyses', this.analyses) + console.log('views', this.views) + console.log('originalViews', this.originalViews) + console.log('cycles:', this.cycles) + console.log('messages:', this.messages) + + // go through the analyses and make a list of those analyses where analysis.status is any of the following statuses: "Pending Creation", "Creating", "Ready", "Queued", "Running" + // then subscribe to "pollAnalyses" in the analysis service and when updated data comes in, + // replace the entry in the main analyses object. + this._analysisService.pollForCompletion( + this.analyses.filter((analysis) => + ['Pending Creation', 'Creating', 'Ready', 'Queued', 'Running'].includes(analysis.status), + ), + ) + .pipe( + tap((response) => { + // Update the analyses list with the updated analyses + this.analyses = this.analyses.map((analysis) => { + const updatedAnalysis = response.analyses.find((updated) => updated.id === analysis.id) + return updatedAnalysis ? updatedAnalysis : analysis + }) + }), + takeUntil(this._unsubscribeAll$), // Automatically unsubscribe when the component is destroyed + ) + .subscribe({ + next: () => { + console.log('Polling completed successfully.') + }, + error: (err) => { + console.error('Error during polling:', err) + }, + }) } } diff --git a/src/app/modules/analyses/analyses.routes.ts b/src/app/modules/analyses/analyses.routes.ts index 2d22e1eb..718d749f 100644 --- a/src/app/modules/analyses/analyses.routes.ts +++ b/src/app/modules/analyses/analyses.routes.ts @@ -1,15 +1,57 @@ +import { inject } from '@angular/core' import type { Routes } from '@angular/router' -import { AnalysesComponent, AnalysisComponent } from '.' +import { switchMap, take } from 'rxjs' +import { AnalysisService } from '@seed/api/analysis' +import { CycleService } from '@seed/api/cycle' +import { UserService } from '@seed/api/user' +import { AnalysesComponent, AnalysisComponent, AnalysisRunComponent } from '.' export default [ { path: '', title: 'Analyses', component: AnalysesComponent, + resolve: { + analyses: () => { + const analysisService = inject(AnalysisService) + const userService = inject(UserService) + return userService.currentOrganizationId$.pipe( + take(1), + switchMap(() => { + return analysisService.getAnalyses() + }), + ) + }, + messages: () => { + const analysisService = inject(AnalysisService) + const userService = inject(UserService) + return userService.currentOrganizationId$.pipe( + take(1), + switchMap(() => { + return analysisService.getAnalysesMessages() + }), + ) + }, + cycles: () => { + const cycleService = inject(CycleService) + const userService = inject(UserService) + return userService.currentOrganizationId$.pipe( + take(1), + switchMap((orgId) => { + return cycleService.get(orgId) + }), + ) + }, + }, }, { path: ':id', title: 'Analysis', component: AnalysisComponent, }, + { + path: ':id/runs/:runId', + title: 'Analysis Run', + component: AnalysisRunComponent, + }, ] satisfies Routes diff --git a/src/app/modules/analyses/analysis-run/analysis-run.component.html b/src/app/modules/analyses/analysis-run/analysis-run.component.html new file mode 100644 index 00000000..b2753ab6 --- /dev/null +++ b/src/app/modules/analyses/analysis-run/analysis-run.component.html @@ -0,0 +1,3 @@ + +
Analysis Run Content
+
diff --git a/src/app/modules/analyses/analysis-run/analysis-run.component.ts b/src/app/modules/analyses/analysis-run/analysis-run.component.ts new file mode 100644 index 00000000..6063e72f --- /dev/null +++ b/src/app/modules/analyses/analysis-run/analysis-run.component.ts @@ -0,0 +1,22 @@ +import type { OnInit } from '@angular/core' +import { Component, inject } from '@angular/core' +import { MatIconModule } from '@angular/material/icon' +import { ActivatedRoute } from '@angular/router' +import { PageComponent } from '@seed/components' + +@Component({ + selector: 'seed-analyses-analysis-run', + templateUrl: './analysis-run.component.html', + imports: [MatIconModule, PageComponent], +}) +export class AnalysisRunComponent implements OnInit { + private _route = inject(ActivatedRoute) + + analysisId = Number(this._route.snapshot.paramMap.get('id')) + runId = Number(this._route.snapshot.paramMap.get('runId')) + + ngOnInit(): void { + console.log(`Analysis ${this.analysisId}`) + console.log(`Run ${this.runId}`) + } +} diff --git a/src/app/modules/analyses/analysis-run/index.ts b/src/app/modules/analyses/analysis-run/index.ts new file mode 100644 index 00000000..72d517c7 --- /dev/null +++ b/src/app/modules/analyses/analysis-run/index.ts @@ -0,0 +1 @@ +export * from './analysis-run.component' diff --git a/src/app/modules/analyses/delete-analysis-dialog/delete-analysis-dialog.component.html b/src/app/modules/analyses/delete-analysis-dialog/delete-analysis-dialog.component.html new file mode 100644 index 00000000..8e775dc6 --- /dev/null +++ b/src/app/modules/analyses/delete-analysis-dialog/delete-analysis-dialog.component.html @@ -0,0 +1,29 @@ +
+
+
+ +
+ +
+
+ {{ t('Delete Analysis') }} {{ analysis.name }} +
+
+ {{ t('DELETE_ANALYSIS_TEXT') }} +
+
+
+ +
+ + +
+
diff --git a/src/app/modules/analyses/delete-analysis-dialog/delete-analysis-dialog.component.ts b/src/app/modules/analyses/delete-analysis-dialog/delete-analysis-dialog.component.ts new file mode 100644 index 00000000..7f006ade --- /dev/null +++ b/src/app/modules/analyses/delete-analysis-dialog/delete-analysis-dialog.component.ts @@ -0,0 +1,48 @@ +import { CommonModule } from '@angular/common' +import { Component, inject } from '@angular/core' +import { MatButtonModule } from '@angular/material/button' +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog' +import { MatIconModule } from '@angular/material/icon' +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner' +import type { Analysis } from '@seed/api/analysis' +import { AnalysisService } from '@seed/api/analysis' +import { SharedImports } from '@seed/directives' +import { finalize } from 'rxjs' + +@Component({ + selector: 'seed-delete-analysis-dialog', + templateUrl: './delete-analysis-dialog.component.html', + imports: [CommonModule, MatButtonModule, MatDialogModule, MatIconModule, MatProgressSpinnerModule, SharedImports], +}) +export class DeleteAnalysisDialogComponent { + private _data = inject(MAT_DIALOG_DATA) as { analysis: Analysis } + private _dialogRef = inject(MatDialogRef) + private _analysisService = inject(AnalysisService) + + readonly analysis = this._data.analysis + submitted = false + + delete() { + console.log('Delete analysis', this.analysis.id) + if (!this.submitted) { + this.submitted = true + this._analysisService + .delete(this._data.analysis.id) + .pipe( + finalize(() => { + this._dialogRef.close() + }), + ) + .subscribe() + } else { + this._dialogRef.close() + } + } + close() { + this._dialogRef.close() + } + + dismiss() { + this._dialogRef.close() + } +} diff --git a/src/app/modules/analyses/delete-analysis-dialog/index.ts b/src/app/modules/analyses/delete-analysis-dialog/index.ts new file mode 100644 index 00000000..3f19da5f --- /dev/null +++ b/src/app/modules/analyses/delete-analysis-dialog/index.ts @@ -0,0 +1 @@ +export * from './delete-analysis-dialog.component' diff --git a/src/app/modules/analyses/index.ts b/src/app/modules/analyses/index.ts index b7576ee2..bbc75cb7 100644 --- a/src/app/modules/analyses/index.ts +++ b/src/app/modules/analyses/index.ts @@ -1,2 +1,3 @@ export * from './analyses.component' export * from './analysis' +export * from './analysis-run' From 8bbf4195d09126078c203f119faa26d190cf63f7 Mon Sep 17 00:00:00 2001 From: kflemin <2205659+kflemin@users.noreply.github.com> Date: Fri, 9 May 2025 14:44:08 -0600 Subject: [PATCH 2/5] analyses pages wip --- src/@seed/api/analysis/analysis.service.ts | 32 +++- src/@seed/api/analysis/analysis.types.ts | 13 ++ .../modules/analyses/analyses.component.html | 36 ++--- .../modules/analyses/analyses.component.scss | 62 +++++-- .../modules/analyses/analyses.component.ts | 12 +- src/app/modules/analyses/analyses.routes.ts | 119 +++++++++++++- .../analysis-run/analysis-run.component.html | 152 +++++++++++++++++- .../analysis-run/analysis-run.component.ts | 95 ++++++++++- .../analyses/analysis/analysis.component.html | 116 ++++++++++++- .../analyses/analysis/analysis.component.ts | 84 +++++++++- src/styles/styles.scss | 12 +- 11 files changed, 671 insertions(+), 62 deletions(-) diff --git a/src/@seed/api/analysis/analysis.service.ts b/src/@seed/api/analysis/analysis.service.ts index a39698c3..fee20475 100644 --- a/src/@seed/api/analysis/analysis.service.ts +++ b/src/@seed/api/analysis/analysis.service.ts @@ -6,7 +6,7 @@ import { ErrorService } from '@seed/services' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' // import type { Observable } from 'rxjs' import { BehaviorSubject, catchError, forkJoin, map, Observable, Subject, takeUntil, tap } from 'rxjs' -import type { Analysis, AnalysisResponse, AnalysesMessage, AnalysesViews, ListAnalysesResponse, ListMessagesResponse, OriginalView, View } from './analysis.types' +import type { Analysis, AnalysisResponse, AnalysesMessage, AnalysesViews, AnalysisView, AnalysisViews, ListAnalysesResponse, ListMessagesResponse, OriginalView, View } from './analysis.types' @Injectable({ providedIn: 'root' }) export class AnalysisService { @@ -57,10 +57,10 @@ export class AnalysisService { ) } - // get AnalysesMessages - getAnalysesMessages(): Observable { + // get AnalysesMessages (for all analyses or for a single one) + getMessages(_analysisId = '0'): Observable { return this._httpClient - .get(`/api/v3/analyses/0/messages/?organization_id=${this.orgId}`) + .get(`/api/v3/analyses/${_analysisId}/messages/?organization_id=${this.orgId}`) .pipe( map((response) => response.messages), tap((response) => { @@ -84,6 +84,30 @@ export class AnalysisService { ) } + // get single analysis view (from a single run) + getRun(_analysisId, _runId): Observable { + return this._httpClient + .get(`/api/v3/analyses/${_analysisId}/views/${_runId}?organization_id=${this.orgId}`) + .pipe( + map((response) => response), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching analysis run') + }), + ) + } + + // get analysis views + getAnalysisViews(_analysisId): Observable { + return this._httpClient + .get(`/api/v3/analyses/${_analysisId}/views?organization_id=${this.orgId}`) + .pipe( + map((response) => response), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching analyses') + }), + ) + } + // delete analysis delete(id: number) { const url = `/api/v3/analyses/${id}/?organization_id=${this.orgId}` diff --git a/src/@seed/api/analysis/analysis.types.ts b/src/@seed/api/analysis/analysis.types.ts index 65fb71ec..748d33d7 100644 --- a/src/@seed/api/analysis/analysis.types.ts +++ b/src/@seed/api/analysis/analysis.types.ts @@ -32,6 +32,7 @@ export type View = { cycle: number; display_name: string; output_files: Record[]; + parsed_results: Record; property: number; property_state: number; } @@ -56,6 +57,18 @@ export type AnalysesViews = { views: View[]; } +export type AnalysisViews = { + status: 'success'; + views: View[]; + original_views: OriginalView[]; +} + +export type AnalysisView = { + status: 'success'; + view: View; + original_view: OriginalView; +} + export type AnalysesMessage = { id: number; analysis: number; diff --git a/src/app/modules/analyses/analyses.component.html b/src/app/modules/analyses/analyses.component.html index 12e67984..39044e49 100644 --- a/src/app/modules/analyses/analyses.component.html +++ b/src/app/modules/analyses/analyses.component.html @@ -1,24 +1,24 @@ - +
- +
- Analysis Name - Actions - Number of Properties - Type - Configuration - Created - Run Status - Run Date - Run Duration - Run Cycle + {{ t('Analysis Name') }} + {{ t('Actions') }} + {{ t('Number of Properties') }} + {{ t('Type') }} + {{ t('Configuration') }} + {{ t('Created') }} + {{ t('Run Status') }} + {{ t('Run Date') }} + {{ t('Run Duration') }} + {{ t('Run Cycle') }} - + @for (analysis of analyses; track trackByIdAndStatus(analysis)) { {{ analysis.name }} @@ -57,14 +57,9 @@ {{ cycle(analysis.cycles[0]) }} } -
- +
@for (view of views; track view.id) { @@ -104,7 +99,4 @@
- - - diff --git a/src/app/modules/analyses/analyses.component.scss b/src/app/modules/analyses/analyses.component.scss index d3b393b0..72ff92b4 100644 --- a/src/app/modules/analyses/analyses.component.scss +++ b/src/app/modules/analyses/analyses.component.scss @@ -1,16 +1,17 @@ -$warningBackground: #fcf8e3; -$errorBackground: #f2dede; -$successBackground: #dff0d8; -$infoBackground: #d9edf7; +// $warningBackground: #fcf8e3; +// $errorBackground: #f2dede; +// $successBackground: #dff0d8; +// $infoBackground: #d9edf7; $cellBackground: #f2f2f2; + .even-card { background-color: $cellBackground !important; } -.dynamic-grid { - padding-bottom: 10px; -} +// .dynamic-grid { +// padding-bottom: 10px; +// } // mat-grid-tile { // display: flex; @@ -51,26 +52,65 @@ $cellBackground: #f2f2f2; /* ANALYSIS STATUS COLORS */ .analysis-status { + font-weight: 600; &.creating, &.warning { - background: $warningBackground !important; + /* color: $warningBackground !important; */ + color: var(--warning-text-color); } &.stopped, &.failed, &.error { - background: $errorBackground !important; + /* color: $errorBackground !important; */ + color: var(--error-text-color); } &.ready, &.completed, &.success { - background: $successBackground !important; + /* color: $success !important; */ + color: var(--success-text-color); } &.queued, &.running, &.info { - background: $infoBackground !important; + /* color: $infoBackground !important; */ + color: var(--info-text-color); } } + +.mat-cell, .mat-mdc-cell { + padding-top:10px; + padding-bottom:10px; +} + +.mat-mdc-header-row { + background-color: $cellBackground; +} + +mat-icon.small-icon { + width: 16px !important; + height: 16px; + font-size: 16px; +} + +.small-icon { + width: 16px !important; + height: 16px; + font-size: 16px; +} + +.mat-column-property { + min-width: 300px; +} + +.mat-column-outputs { + min-width: 250px; +} + +.mat-column-messages { + max-width: 700px; /* Adjust as needed */ + white-space: wrap; +} diff --git a/src/app/modules/analyses/analyses.component.ts b/src/app/modules/analyses/analyses.component.ts index 056c32c0..eff1db0c 100644 --- a/src/app/modules/analyses/analyses.component.ts +++ b/src/app/modules/analyses/analyses.component.ts @@ -18,7 +18,7 @@ import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import type { CurrentUser } from '@seed/api/user' import { UserService } from '@seed/api/user' import { TranslocoService } from '@jsverse/transloco' -import { ObjectRendererComponent, PageComponent, TableContainerComponent } from '@seed/components' +import { PageComponent } from '@seed/components' import { SharedImports } from '@seed/directives' import { DeleteAnalysisDialogComponent } from './delete-analysis-dialog' @@ -27,7 +27,6 @@ import { DeleteAnalysisDialogComponent } from './delete-analysis-dialog' templateUrl: './analyses.component.html', styleUrls: ['./analyses.component.scss'], imports: [ - // AgGridAngular, CommonModule, MatCardModule, MatDialogModule, @@ -35,10 +34,8 @@ import { DeleteAnalysisDialogComponent } from './delete-analysis-dialog' MatIconModule, MatListModule, MatTabsModule, - ObjectRendererComponent, PageComponent, RouterLink, - TableContainerComponent, SharedImports, ], }) @@ -80,7 +77,7 @@ export class AnalysesComponent implements OnInit, OnDestroy { }) }) - // subscribe to the list of pending analyses. (call poll function with list of IDs) + // TODO - subscribe to the list of pending analyses. (call poll function with list of IDs) // function in tap that handles the function. figure out the list here (maintain the list) // I will update the observable when the status changes. // when it changes, remove from the list and change the status. @@ -168,11 +165,6 @@ export class AnalysesComponent implements OnInit, OnDestroy { this.originalViews = this._route.snapshot.data.analyses.original_views as OriginalView[] this.cycles = this._route.snapshot.data.cycles as Cycle[] this.messages = this._route.snapshot.data.messages as AnalysesMessage[] - console.log('analyses', this.analyses) - console.log('views', this.views) - console.log('originalViews', this.originalViews) - console.log('cycles:', this.cycles) - console.log('messages:', this.messages) // go through the analyses and make a list of those analyses where analysis.status is any of the following statuses: "Pending Creation", "Creating", "Ready", "Queued", "Running" // then subscribe to "pollAnalyses" in the analysis service and when updated data comes in, diff --git a/src/app/modules/analyses/analyses.routes.ts b/src/app/modules/analyses/analyses.routes.ts index 718d749f..87ad2274 100644 --- a/src/app/modules/analyses/analyses.routes.ts +++ b/src/app/modules/analyses/analyses.routes.ts @@ -1,5 +1,6 @@ import { inject } from '@angular/core' import type { Routes } from '@angular/router' +import { ActivatedRoute, ActivatedRouteSnapshot } from '@angular/router' import { switchMap, take } from 'rxjs' import { AnalysisService } from '@seed/api/analysis' import { CycleService } from '@seed/api/cycle' @@ -28,7 +29,7 @@ export default [ return userService.currentOrganizationId$.pipe( take(1), switchMap(() => { - return analysisService.getAnalysesMessages() + return analysisService.getMessages() }), ) }, @@ -48,10 +49,126 @@ export default [ path: ':id', title: 'Analysis', component: AnalysisComponent, + resolve: { + analysis: (route: ActivatedRouteSnapshot) => { + const analysisService = inject(AnalysisService) + const userService = inject(UserService) + + // Retrieve the ID from the snapshot + const id = route.paramMap.get('id') + if (!id) { + throw new Error('Analysis ID is missing from the route parameters.') + } + + return userService.currentOrganizationId$.pipe( + take(1), + switchMap(() => analysisService.getAnalysis(id)), + ) + }, + viewsPayload: (route: ActivatedRouteSnapshot) => { + // returns status, views, and original_views + const analysisService = inject(AnalysisService) + const userService = inject(UserService) + + const id = route.paramMap.get('id') + if (!id) { + throw new Error('Analysis ID is missing from the route parameters.') + } + + return userService.currentOrganizationId$.pipe( + take(1), + switchMap(() => analysisService.getAnalysisViews(id)), + ) + }, + messages: (route: ActivatedRouteSnapshot) => { + const analysisService = inject(AnalysisService) + const userService = inject(UserService) + + const id = route.paramMap.get('id') + if (!id) { + throw new Error('Analysis ID is missing from the route parameters.') + } + + return userService.currentOrganizationId$.pipe( + take(1), + switchMap(() => analysisService.getMessages(id)), + ) + }, + cycles: () => { + const cycleService = inject(CycleService) + const userService = inject(UserService) + return userService.currentOrganizationId$.pipe( + take(1), + switchMap((orgId) => { + return cycleService.get(orgId) + }), + ) + }, + }, }, { path: ':id/runs/:runId', title: 'Analysis Run', component: AnalysisRunComponent, + resolve: { + analysis: (route: ActivatedRouteSnapshot) => { + const analysisService = inject(AnalysisService) + const userService = inject(UserService) + + // Retrieve the ID from the snapshot + const id = route.paramMap.get('id') + if (!id) { + throw new Error('Analysis ID is missing from the route parameters.') + } + + return userService.currentOrganizationId$.pipe( + take(1), + switchMap(() => analysisService.getAnalysis(id)), + ) + }, + viewPayload: (route: ActivatedRouteSnapshot) => { + // returns status, views, and original_views + const analysisService = inject(AnalysisService) + const userService = inject(UserService) + + const id = route.paramMap.get('id') + const runId = route.paramMap.get('runId') + if (!id) { + throw new Error('Analysis ID is missing from the route parameters.') + } + if (!runId) { + throw new Error('Analysis View ID is missing from the route parameters') + } + + return userService.currentOrganizationId$.pipe( + take(1), + switchMap(() => analysisService.getRun(id, runId)), + ) + }, + messages: (route: ActivatedRouteSnapshot) => { + const analysisService = inject(AnalysisService) + const userService = inject(UserService) + + const id = route.paramMap.get('id') + if (!id) { + throw new Error('Analysis ID is missing from the route parameters.') + } + + return userService.currentOrganizationId$.pipe( + take(1), + switchMap(() => analysisService.getMessages(id)), + ) + }, + cycles: () => { + const cycleService = inject(CycleService) + const userService = inject(UserService) + return userService.currentOrganizationId$.pipe( + take(1), + switchMap((orgId) => { + return cycleService.get(orgId) + }), + ) + }, + }, }, ] satisfies Routes diff --git a/src/app/modules/analyses/analysis-run/analysis-run.component.html b/src/app/modules/analyses/analysis-run/analysis-run.component.html index b2753ab6..2a112660 100644 --- a/src/app/modules/analyses/analysis-run/analysis-run.component.html +++ b/src/app/modules/analyses/analysis-run/analysis-run.component.html @@ -1,3 +1,151 @@ - -
Analysis Run Content
+ +
+
+
+
+
{{ analysis.name }} - Run {{ view.id }}
+
+

{{ t('ANALYSIS_DESCRIPTION_' + analysis.service.split(' ').join('')) }}

+
+
+ + {{ t('Number of Runs') }} + {{ t('Type') }} + {{ t('Configuration') }} + {{ t('Created') }} + {{ t('Run Status') }} + {{ t('Run Date') }} + {{ t('Run Duration') }} + {{ t('Run Cycle') }} + +
+ + {{ analysis.number_of_analysis_property_views }} + {{ analysis.service }} + +
+
    + + @for (key of getKeys(analysis.configuration); track key) { +
  • {{ key }}: {{ analysis.configuration[key] }}
  • + } +
+
+
+ {{ analysis.created_at | date : 'MM-dd-yy HH:mm' }} + + + {{ analysis.status }} + + {{ analysis.start_time | date : 'MM-dd-yy HH:mm' }} + {{ runDuration(analysis) }} + {{ cycle(analysis.cycles[0]) }} +
+
+
+ + + + + + + + + + + + + + + + + + + +
{{ t('Run ID') }} + {{ view.id }} + {{ t('Property') }} + {{ view.display_name || 'Property ' + originalViews[view.id] }} + {{ t('Latest Messages') }} + @if (filteredMessages(view.id); as items) { +
    + @for (message of items; track message.id) { +
  • + {{ message['user_message'] }} + @if (message['debug_message']) { + - {{ message['debug_message'] }} + } +
  • + } +
+ } +
{{ t('Download Output Files') }} + +
+
+ + + +

{{ t('Results') }}

+
+
+ @if (view.parsed_results && analysis.service !== 'BETTER') { + + @for (key of getKeys(view.parsed_results); track key) { + @if (key.includes('URL')) { + {{ key.replace(' URL', '') }}: {{ t('View Report') }} + } @else { + {{ key }}: {{ view.parsed_results[key] }} + } + @if(!$last) { +
+ } + } +
+ } +
+
+ @if (view.output_files.length > 0) { + @for (file of view.output_files; track file.id ) { + @if (file.content_type === 'html') { + + } + @if (file.content_type === 'PNG') { +
+ report {{file.id}} +
+ @if (!$last) { +
+ } + } + } + } +
+
+
+
+
+
diff --git a/src/app/modules/analyses/analysis-run/analysis-run.component.ts b/src/app/modules/analyses/analysis-run/analysis-run.component.ts index 6063e72f..b4d97a3c 100644 --- a/src/app/modules/analyses/analysis-run/analysis-run.component.ts +++ b/src/app/modules/analyses/analysis-run/analysis-run.component.ts @@ -1,22 +1,109 @@ -import type { OnInit } from '@angular/core' +import { CommonModule } from '@angular/common' import { Component, inject } from '@angular/core' +import { DomSanitizer, SafeResourceUrl } from '@angular/platform-browser'; +import type { OnInit } from '@angular/core' +import { MatButtonModule } from '@angular/material/button' +import { MatCardModule } from '@angular/material/card' +import { MatGridListModule } from '@angular/material/grid-list' import { MatIconModule } from '@angular/material/icon' -import { ActivatedRoute } from '@angular/router' +import { MatTableModule } from '@angular/material/table' import { PageComponent } from '@seed/components' +import { RouterLink } from '@angular/router' +import { ActivatedRoute, Router } from '@angular/router' +import { from, map, Observable, Subject, skip, switchMap, takeUntil, tap } from 'rxjs' +import type { AnalysesMessage, Analysis, OriginalView, View } from '@seed/api/analysis' +import { AnalysisService } from '@seed/api/analysis' +import type { Cycle } from '@seed/api/cycle' +import { OrganizationService } from '@seed/api/organization' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' +import type { CurrentUser } from '@seed/api/user' +import { UserService } from '@seed/api/user' +import { TranslocoService } from '@jsverse/transloco' +import { SharedImports } from '@seed/directives' @Component({ selector: 'seed-analyses-analysis-run', templateUrl: './analysis-run.component.html', - imports: [MatIconModule, PageComponent], + styleUrls: ['../analyses.component.scss'], + imports: [CommonModule, MatButtonModule, MatCardModule, MatGridListModule, MatIconModule, MatTableModule, PageComponent, RouterLink, SharedImports], }) export class AnalysisRunComponent implements OnInit { private _route = inject(ActivatedRoute) - analysisId = Number(this._route.snapshot.paramMap.get('id')) runId = Number(this._route.snapshot.paramMap.get('runId')) + analysis: Analysis + view: View + views: View[] + originalViews: OriginalView[] + cycles: Cycle[] + messages: AnalysesMessage[] + currentUser: CurrentUser + columnsToDisplay = ['id', 'property', 'messages', 'outputs'] + private _router = inject(Router) + private _analysisService = inject(AnalysisService) + private _organizationService = inject(OrganizationService) + private _snackBar = inject(SnackBarService) + private _userService = inject(UserService) + private _transloco = inject(TranslocoService) + private readonly _unsubscribeAll$ = new Subject() + constructor(private _sanitizer: DomSanitizer) {} ngOnInit(): void { console.log(`Analysis ${this.analysisId}`) console.log(`Run ${this.runId}`) + this._userService.currentUser$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((currentUser) => { + this.currentUser = currentUser + }) + + this._init() + } + + sanitizeUrl(url: string): SafeResourceUrl { + // this is a local file path in the /media dir within SEED backend + // TODO: we will need to retrieve it with a full path to backend? + console.log('URL: ', url) + return this._sanitizer.bypassSecurityTrustResourceUrl(`http://127.0.0.1:8000${url}`) + } + + cycle(_id: number): string { + const cycle: Cycle = this.cycles.find((cycle) => cycle.id === _id) + if (cycle) { + return cycle.name + } + return '' + } + + getKeys(obj: any): string[] { + return Object.keys(obj); + } + + // Return messages filtered by analysis property view + filteredMessages(_id: number): AnalysesMessage[] { + return this.messages.filter((item) => item.analysis_property_view === _id) + } + + // calculate run duration from start_time and end_time in minutes and seconds only. don't display hours if hours is 0 + runDuration(analysis): string { + const start = new Date(analysis.start_time) + const end = new Date(analysis.end_time) + const duration = Math.abs(end.getTime() - start.getTime()) + const minutes = Math.floor((duration % (1000 * 60 * 60)) / (1000 * 60)) + const seconds = Math.floor((duration % (1000 * 60)) / 1000) + return `${minutes}m ${seconds}s` + } + + private _init() { + this.analysis = this._route.snapshot.data.analysis as Analysis + this.view = this._route.snapshot.data.viewPayload.view as View + this.views = [this.view] + this.originalViews = [this._route.snapshot.data.viewPayload.original_view] as OriginalView[] + this.cycles = this._route.snapshot.data.cycles as Cycle[] + this.messages = this._route.snapshot.data.messages as AnalysesMessage[] + console.log('analysis', this.analysis) + console.log('view', this.view) + console.log('originalViews', this.originalViews) + console.log('cycles:', this.cycles) + console.log('messages:', this.messages) + console.log(this.view.parsed_results) } } diff --git a/src/app/modules/analyses/analysis/analysis.component.html b/src/app/modules/analyses/analysis/analysis.component.html index 2b4821a4..ff72be9f 100644 --- a/src/app/modules/analyses/analysis/analysis.component.html +++ b/src/app/modules/analyses/analysis/analysis.component.html @@ -1,3 +1,115 @@ - -
Analysis Content
+ + +
+
+
+
+
{{ analysis.name }}
+
+

{{ t('ANALYSIS_DESCRIPTION_' + analysis.service.split(' ').join('')) }}

+
+
+ + {{ t('Number of Runs') }} + {{ t('Type') }} + {{ t('Configuration') }} + {{ t('Created') }} + {{ t('Run Status') }} + {{ t('Run Date') }} + {{ t('Run Duration') }} + {{ t('Run Cycle') }} + +
+ + {{ analysis.number_of_analysis_property_views }} + {{ analysis.service }} + +
+
    + + @for (key of getKeys(analysis.configuration); track key) { +
  • {{ key }}: {{ analysis.configuration[key] }}
  • + } +
+
+
+ {{ analysis.created_at | date : 'MM-dd-yy HH:mm' }} + + + {{ analysis.status }} + + {{ analysis.start_time | date : 'MM-dd-yy HH:mm' }} + {{ runDuration(analysis) }} + {{ cycle(analysis.cycles[0]) }} +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
{{ t('Run ID') }} + {{ view.id }} + {{ t('Property') }} + {{ view.display_name || 'Property ' + originalViews[view.id] }} + {{ t('Latest Messages') }} + @if (filteredMessages(view.id); as items) { +
    + @for (message of items; track message.id) { +
  • + {{ message['user_message'] }} + @if (message['debug_message']) { + - {{ message['debug_message'] }} + } +
  • + } +
+ } +
{{ t('Download Output Files') }} + + + View Results +
+
+
+
+
+
+
diff --git a/src/app/modules/analyses/analysis/analysis.component.ts b/src/app/modules/analyses/analysis/analysis.component.ts index 4e048ada..4d1fedf9 100644 --- a/src/app/modules/analyses/analysis/analysis.component.ts +++ b/src/app/modules/analyses/analysis/analysis.component.ts @@ -1,20 +1,94 @@ -import type { OnInit } from '@angular/core' +import { CommonModule } from '@angular/common' import { Component, inject } from '@angular/core' +import type { OnInit } from '@angular/core' +import { MatButtonModule } from '@angular/material/button' +import { MatCardModule } from '@angular/material/card' +import { MatGridListModule } from '@angular/material/grid-list' import { MatIconModule } from '@angular/material/icon' -import { ActivatedRoute } from '@angular/router' +import { MatTableModule } from '@angular/material/table' import { PageComponent } from '@seed/components' +import { RouterLink } from '@angular/router' +import { ActivatedRoute, Router } from '@angular/router' +import { from, map, Observable, Subject, skip, switchMap, takeUntil, tap } from 'rxjs' +import type { AnalysesMessage, Analysis, OriginalView, View } from '@seed/api/analysis' +import { AnalysisService } from '@seed/api/analysis' +import type { Cycle } from '@seed/api/cycle' +import { OrganizationService } from '@seed/api/organization' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' +import type { CurrentUser } from '@seed/api/user' +import { UserService } from '@seed/api/user' +import { TranslocoService } from '@jsverse/transloco' +import { SharedImports } from '@seed/directives' @Component({ selector: 'seed-analyses-analysis', templateUrl: './analysis.component.html', - imports: [MatIconModule, PageComponent], + styleUrls: ['../analyses.component.scss'], + imports: [CommonModule, MatButtonModule, MatCardModule, MatGridListModule, MatIconModule, MatTableModule, PageComponent, RouterLink, SharedImports], }) export class AnalysisComponent implements OnInit { private _route = inject(ActivatedRoute) - analysisId = Number(this._route.snapshot.paramMap.get('id')) + analysis: Analysis + views: View[] + originalViews: OriginalView[] + cycles: Cycle[] + messages: AnalysesMessage[] + currentUser: CurrentUser + columnsToDisplay = ['id', 'property', 'messages', 'outputs', 'actions'] + private _router = inject(Router) + private _analysisService = inject(AnalysisService) + private _organizationService = inject(OrganizationService) + private _snackBar = inject(SnackBarService) + private _userService = inject(UserService) + private _transloco = inject(TranslocoService) + private readonly _unsubscribeAll$ = new Subject() ngOnInit(): void { - console.log(`Analysis ${this.analysisId}`) + this._userService.currentUser$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((currentUser) => { + this.currentUser = currentUser + }) + + this._init() + } + + cycle(_id: number): string { + const cycle: Cycle = this.cycles.find((cycle) => cycle.id === _id) + if (cycle) { + return cycle.name + } + return '' + } + + getKeys(obj: any): string[] { + return Object.keys(obj); + } + + // Return messages filtered by analysis property view + filteredMessages(_id: number): AnalysesMessage[] { + return this.messages.filter((item) => item.analysis_property_view === _id) + } + + // calculate run duration from start_time and end_time in minutes and seconds only. don't display hours if hours is 0 + runDuration(analysis): string { + const start = new Date(analysis.start_time) + const end = new Date(analysis.end_time) + const duration = Math.abs(end.getTime() - start.getTime()) + const minutes = Math.floor((duration % (1000 * 60 * 60)) / (1000 * 60)) + const seconds = Math.floor((duration % (1000 * 60)) / 1000) + return `${minutes}m ${seconds}s` + } + + private _init() { + this.analysis = this._route.snapshot.data.analysis as Analysis + this.views = this._route.snapshot.data.viewsPayload.views as View[] + this.originalViews = this._route.snapshot.data.viewsPayload.original_views as OriginalView[] + this.cycles = this._route.snapshot.data.cycles as Cycle[] + this.messages = this._route.snapshot.data.messages as AnalysesMessage[] + console.log('analysis', this.analysis) + console.log('views', this.views) + console.log('originalViews', this.originalViews) + console.log('cycles:', this.cycles) + console.log('messages:', this.messages) } } diff --git a/src/styles/styles.scss b/src/styles/styles.scss index 72fae089..e1baa182 100644 --- a/src/styles/styles.scss +++ b/src/styles/styles.scss @@ -1,6 +1,16 @@ // Styles from this file will override 'vendors.scss' and SEED's base styles. @tailwind utilities; +// colors for text alerts +:root { + --primary-text-color: rgb(29 78 216); + --success-text-color: rgb(21 128 61); + --info-text-color: theme('colors.cyan.600'); + --warning-text-color: rgb(180 83 9); + --error-text-color: rgb(185 28 28); +} + + .mat-mdc-snack-bar-container { &.success-snackbar { --mat-snack-bar-button-color: #fff; @@ -141,7 +151,7 @@ } } -.filter-sort-chip +.filter-sort-chip .mat-mdc-chip-action-label { @apply inline !text-gray-700 dark:!text-gray-400 !text-xs; } From 4de41b6b5126253c6d887df190c97f2872a36649 Mon Sep 17 00:00:00 2001 From: kflemin <2205659+kflemin@users.noreply.github.com> Date: Fri, 9 May 2025 17:35:17 -0600 Subject: [PATCH 3/5] fix darkmode and other cleaning --- .../modules/analyses/analyses.component.html | 33 +++++++++---------- .../modules/analyses/analyses.component.scss | 18 +++++++--- .../analysis-run/analysis-run.component.html | 6 ++-- .../analysis-run/analysis-run.component.ts | 13 ++------ .../analyses/analysis/analysis.component.html | 8 ++--- .../analyses/analysis/analysis.component.ts | 5 --- src/styles/styles.scss | 6 ++++ 7 files changed, 44 insertions(+), 45 deletions(-) diff --git a/src/app/modules/analyses/analyses.component.html b/src/app/modules/analyses/analyses.component.html index 39044e49..27867376 100644 --- a/src/app/modules/analyses/analyses.component.html +++ b/src/app/modules/analyses/analyses.component.html @@ -18,7 +18,7 @@ {{ t('Run Duration') }} {{ t('Run Cycle') }} - + @for (analysis of analyses; track trackByIdAndStatus(analysis)) { {{ analysis.name }} @@ -66,29 +66,26 @@ - Run ID {{ view.id }} + Run ID {{ view.id }} - + {{ view.display_name || 'Property ' + originalViews[view.id] }} - - - {{ view.display_name || 'Property ' + originalViews[view.id] }} - - -
- @if (filteredMessages(view.id); as items) { -
    - @for (message of items; track message.id) { -
  • - {{ message['user_message'] }} - {{ message['debug_message'] }} -
  • +
    + @if (filteredMessages(view.id); as items) { +
      + @for (message of items; track message.id) { +
    • + {{ message['user_message'] }} + @if (message['debug_message']) { + - {{ message['debug_message'] }} } -
    + } -
    - - +
+ } +
} diff --git a/src/app/modules/analyses/analyses.component.scss b/src/app/modules/analyses/analyses.component.scss index 72ff92b4..e3e11bf6 100644 --- a/src/app/modules/analyses/analyses.component.scss +++ b/src/app/modules/analyses/analyses.component.scss @@ -6,7 +6,7 @@ $cellBackground: #f2f2f2; .even-card { - background-color: $cellBackground !important; + background-color: var(--header-row-bg); } // .dynamic-grid { @@ -38,11 +38,11 @@ $cellBackground: #f2f2f2; font-weight: bold; padding: 0 5px; box-sizing: border-box; - background-color: $cellBackground !important; + background-color: var(--header-row-bg); } .alt-row { - background-color: $cellBackground !important; + background-color: var(--header-row-bg); } .pad-row { @@ -87,9 +87,19 @@ $cellBackground: #f2f2f2; } .mat-mdc-header-row { - background-color: $cellBackground; + background-color: var(--header-row-bg); } +// #analysis-table .mat-mdc-row:hover, #analysis-table .mat-row:hover, #analysis-table tr:hover, #analysis-table td:hover { +// background-color: transparent !important; +// } +// tr.mat-mdc-row:hover, #analysis-table tr.mat-mdc-row:hover { +// background-color: transparent !important; +// } +// .mat-mdc-row:hover .mat-mdc-cell { +// background: unset!important; +// } + mat-icon.small-icon { width: 16px !important; height: 16px; diff --git a/src/app/modules/analyses/analysis-run/analysis-run.component.html b/src/app/modules/analyses/analysis-run/analysis-run.component.html index 2a112660..9947ffc8 100644 --- a/src/app/modules/analyses/analysis-run/analysis-run.component.html +++ b/src/app/modules/analyses/analysis-run/analysis-run.component.html @@ -1,4 +1,4 @@ - +
@@ -9,7 +9,7 @@
- {{ t('Number of Runs') }} + {{ t('Runs') }} {{ t('Type') }} {{ t('Configuration') }} {{ t('Created') }} @@ -53,7 +53,7 @@ {{ t('Property') }} - {{ view.display_name || 'Property ' + originalViews[view.id] }} + {{ view.display_name || 'Property ' + originalView }} diff --git a/src/app/modules/analyses/analysis-run/analysis-run.component.ts b/src/app/modules/analyses/analysis-run/analysis-run.component.ts index b4d97a3c..84310bac 100644 --- a/src/app/modules/analyses/analysis-run/analysis-run.component.ts +++ b/src/app/modules/analyses/analysis-run/analysis-run.component.ts @@ -34,7 +34,7 @@ export class AnalysisRunComponent implements OnInit { analysis: Analysis view: View views: View[] - originalViews: OriginalView[] + originalView: OriginalView cycles: Cycle[] messages: AnalysesMessage[] currentUser: CurrentUser @@ -49,8 +49,6 @@ export class AnalysisRunComponent implements OnInit { constructor(private _sanitizer: DomSanitizer) {} ngOnInit(): void { - console.log(`Analysis ${this.analysisId}`) - console.log(`Run ${this.runId}`) this._userService.currentUser$.pipe(takeUntil(this._unsubscribeAll$)).subscribe((currentUser) => { this.currentUser = currentUser }) @@ -61,7 +59,6 @@ export class AnalysisRunComponent implements OnInit { sanitizeUrl(url: string): SafeResourceUrl { // this is a local file path in the /media dir within SEED backend // TODO: we will need to retrieve it with a full path to backend? - console.log('URL: ', url) return this._sanitizer.bypassSecurityTrustResourceUrl(`http://127.0.0.1:8000${url}`) } @@ -96,14 +93,8 @@ export class AnalysisRunComponent implements OnInit { this.analysis = this._route.snapshot.data.analysis as Analysis this.view = this._route.snapshot.data.viewPayload.view as View this.views = [this.view] - this.originalViews = [this._route.snapshot.data.viewPayload.original_view] as OriginalView[] + this.originalView = this._route.snapshot.data.viewPayload.original_view as OriginalView this.cycles = this._route.snapshot.data.cycles as Cycle[] this.messages = this._route.snapshot.data.messages as AnalysesMessage[] - console.log('analysis', this.analysis) - console.log('view', this.view) - console.log('originalViews', this.originalViews) - console.log('cycles:', this.cycles) - console.log('messages:', this.messages) - console.log(this.view.parsed_results) } } diff --git a/src/app/modules/analyses/analysis/analysis.component.html b/src/app/modules/analyses/analysis/analysis.component.html index ff72be9f..51d7f81b 100644 --- a/src/app/modules/analyses/analysis/analysis.component.html +++ b/src/app/modules/analyses/analysis/analysis.component.html @@ -1,5 +1,5 @@ - +
@@ -10,7 +10,7 @@
- {{ t('Number of Runs') }} + {{ t('Runs') }} {{ t('Type') }} {{ t('Configuration') }} {{ t('Created') }} @@ -44,9 +44,9 @@
- +
- + diff --git a/src/app/modules/analyses/analysis/analysis.component.ts b/src/app/modules/analyses/analysis/analysis.component.ts index 4d1fedf9..deabb83a 100644 --- a/src/app/modules/analyses/analysis/analysis.component.ts +++ b/src/app/modules/analyses/analysis/analysis.component.ts @@ -85,10 +85,5 @@ export class AnalysisComponent implements OnInit { this.originalViews = this._route.snapshot.data.viewsPayload.original_views as OriginalView[] this.cycles = this._route.snapshot.data.cycles as Cycle[] this.messages = this._route.snapshot.data.messages as AnalysesMessage[] - console.log('analysis', this.analysis) - console.log('views', this.views) - console.log('originalViews', this.originalViews) - console.log('cycles:', this.cycles) - console.log('messages:', this.messages) } } diff --git a/src/styles/styles.scss b/src/styles/styles.scss index 5126c8a3..ed74603c 100644 --- a/src/styles/styles.scss +++ b/src/styles/styles.scss @@ -8,8 +8,14 @@ --info-text-color: theme('colors.cyan.600'); --warning-text-color: rgb(180 83 9); --error-text-color: rgb(185 28 28); + + --header-row-bg: #f2f2f2; /* Light mode background */ + } +.dark { + --header-row-bg: #0f172b; /* Dark mode background */ +} .mat-mdc-snack-bar-container { &.success-snackbar { From bce145ea033964a05acde046b0752f698a10494b Mon Sep 17 00:00:00 2001 From: kflemin <2205659+kflemin@users.noreply.github.com> Date: Sun, 18 May 2025 10:32:31 -0600 Subject: [PATCH 4/5] display analyses table on inventory detail page --- src/@seed/api/analysis/analysis.service.ts | 14 +- src/@seed/api/analysis/analysis.types.ts | 5 + .../modules/analyses/analyses.component.ts | 7 +- .../analysis-run/analysis-run.component.ts | 2 - .../analyses/analysis/analysis.component.ts | 2 - .../inventory/detail/detail.component.html | 8 + .../inventory/detail/detail.component.ts | 18 ++ .../detail/grid/analyses-grid.component.html | 20 +++ .../detail/grid/analyses-grid.component.ts | 168 ++++++++++++++++++ .../inventory/detail/header.component.ts | 2 +- src/app/modules/inventory/detail/index.ts | 1 + 11 files changed, 238 insertions(+), 9 deletions(-) create mode 100644 src/app/modules/inventory/detail/grid/analyses-grid.component.html create mode 100644 src/app/modules/inventory/detail/grid/analyses-grid.component.ts diff --git a/src/@seed/api/analysis/analysis.service.ts b/src/@seed/api/analysis/analysis.service.ts index fee20475..e29ab91e 100644 --- a/src/@seed/api/analysis/analysis.service.ts +++ b/src/@seed/api/analysis/analysis.service.ts @@ -6,7 +6,7 @@ import { ErrorService } from '@seed/services' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' // import type { Observable } from 'rxjs' import { BehaviorSubject, catchError, forkJoin, map, Observable, Subject, takeUntil, tap } from 'rxjs' -import type { Analysis, AnalysisResponse, AnalysesMessage, AnalysesViews, AnalysisView, AnalysisViews, ListAnalysesResponse, ListMessagesResponse, OriginalView, View } from './analysis.types' +import type { Analysis, AnalysisResponse, AnalysesMessage, AnalysesViews, AnalysisView, AnalysisViews, ListAnalysesResponse, ListMessagesResponse, OriginalView, PropertyAnalysesResponse, View } from './analysis.types' @Injectable({ providedIn: 'root' }) export class AnalysisService { @@ -84,6 +84,18 @@ export class AnalysisService { ) } + // get analyses for a property (by property ID) + getPropertyAnalyses(_propertyId): Observable { + return this._httpClient + .get(`/api/v3/properties/${_propertyId}/analyses/?organization_id=${this.orgId}`) + .pipe( + map((response) => response.analyses), + catchError((error: HttpErrorResponse) => { + return this._errorService.handleError(error, 'Error fetching analyses for this property') + }), + ) + } + // get single analysis view (from a single run) getRun(_analysisId, _runId): Observable { return this._httpClient diff --git a/src/@seed/api/analysis/analysis.types.ts b/src/@seed/api/analysis/analysis.types.ts index 748d33d7..d372eec9 100644 --- a/src/@seed/api/analysis/analysis.types.ts +++ b/src/@seed/api/analysis/analysis.types.ts @@ -52,6 +52,11 @@ export type AnalysisResponse = { analysis: Analysis; } +export type PropertyAnalysesResponse = { + status: 'success'; + analyses: Analysis[]; +} + export type AnalysesViews = { analyses: Analysis[]; views: View[]; diff --git a/src/app/modules/analyses/analyses.component.ts b/src/app/modules/analyses/analyses.component.ts index eff1db0c..127aaad6 100644 --- a/src/app/modules/analyses/analyses.component.ts +++ b/src/app/modules/analyses/analyses.component.ts @@ -9,17 +9,18 @@ import { MatListModule } from '@angular/material/list' import { MatTabsModule } from '@angular/material/tabs' import { RouterLink } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router' +import { TranslocoService } from '@jsverse/transloco' import { from, map, Observable, Subject, skip, switchMap, takeUntil, tap } from 'rxjs' import type { AnalysesMessage, Analysis, OriginalView, View } from '@seed/api/analysis' import { AnalysisService } from '@seed/api/analysis' import type { Cycle } from '@seed/api/cycle' import { OrganizationService } from '@seed/api/organization' -import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import type { CurrentUser } from '@seed/api/user' import { UserService } from '@seed/api/user' -import { TranslocoService } from '@jsverse/transloco' import { PageComponent } from '@seed/components' import { SharedImports } from '@seed/directives' +import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' + import { DeleteAnalysisDialogComponent } from './delete-analysis-dialog' @Component({ @@ -52,8 +53,8 @@ export class AnalysesComponent implements OnInit, OnDestroy { private _analysisService = inject(AnalysisService) private _organizationService = inject(OrganizationService) private _snackBar = inject(SnackBarService) - private _userService = inject(UserService) private _transloco = inject(TranslocoService) + private _userService = inject(UserService) private readonly _unsubscribeAll$ = new Subject() ngOnInit(): void { diff --git a/src/app/modules/analyses/analysis-run/analysis-run.component.ts b/src/app/modules/analyses/analysis-run/analysis-run.component.ts index 84310bac..ac9896a8 100644 --- a/src/app/modules/analyses/analysis-run/analysis-run.component.ts +++ b/src/app/modules/analyses/analysis-run/analysis-run.component.ts @@ -18,7 +18,6 @@ import { OrganizationService } from '@seed/api/organization' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import type { CurrentUser } from '@seed/api/user' import { UserService } from '@seed/api/user' -import { TranslocoService } from '@jsverse/transloco' import { SharedImports } from '@seed/directives' @Component({ @@ -44,7 +43,6 @@ export class AnalysisRunComponent implements OnInit { private _organizationService = inject(OrganizationService) private _snackBar = inject(SnackBarService) private _userService = inject(UserService) - private _transloco = inject(TranslocoService) private readonly _unsubscribeAll$ = new Subject() constructor(private _sanitizer: DomSanitizer) {} diff --git a/src/app/modules/analyses/analysis/analysis.component.ts b/src/app/modules/analyses/analysis/analysis.component.ts index deabb83a..e91697d7 100644 --- a/src/app/modules/analyses/analysis/analysis.component.ts +++ b/src/app/modules/analyses/analysis/analysis.component.ts @@ -17,7 +17,6 @@ import { OrganizationService } from '@seed/api/organization' import { SnackBarService } from 'app/core/snack-bar/snack-bar.service' import type { CurrentUser } from '@seed/api/user' import { UserService } from '@seed/api/user' -import { TranslocoService } from '@jsverse/transloco' import { SharedImports } from '@seed/directives' @Component({ @@ -41,7 +40,6 @@ export class AnalysisComponent implements OnInit { private _organizationService = inject(OrganizationService) private _snackBar = inject(SnackBarService) private _userService = inject(UserService) - private _transloco = inject(TranslocoService) private readonly _unsubscribeAll$ = new Subject() ngOnInit(): void { diff --git a/src/app/modules/inventory/detail/detail.component.html b/src/app/modules/inventory/detail/detail.component.html index 9af360db..d0de8876 100644 --- a/src/app/modules/inventory/detail/detail.component.html +++ b/src/app/modules/inventory/detail/detail.component.html @@ -53,6 +53,14 @@ > } + + @if ( analyses?.length) { + + + } + @if (view?.property) { diff --git a/src/app/modules/inventory/detail/detail.component.ts b/src/app/modules/inventory/detail/detail.component.ts index 45b1e62d..875c4968 100644 --- a/src/app/modules/inventory/detail/detail.component.ts +++ b/src/app/modules/inventory/detail/detail.component.ts @@ -7,6 +7,8 @@ import { ActivatedRoute, Router } from '@angular/router' import { AgGridAngular, AgGridModule } from 'ag-grid-angular' import type { Observable } from 'rxjs' import { forkJoin, Subject, switchMap, take, takeUntil, tap } from 'rxjs' +import type { Analysis } from '@seed/api/analysis' +import { AnalysisService } from '@seed/api/analysis' import type { Column } from '@seed/api/column' import { ColumnService } from '@seed/api/column' import { InventoryService } from '@seed/api/inventory' @@ -17,9 +19,11 @@ import { OrganizationService } from '@seed/api/organization' import type { CurrentUser } from '@seed/api/user' import { UserService } from '@seed/api/user' import { PageComponent } from '@seed/components' +import { SharedImports } from '@seed/directives' import { ConfigService } from '@seed/services' import type { GenericView, InventoryType, Profile, ViewResponse } from '../inventory.types' import { + AnalysesGridComponent, BuildingFilesGridComponent, DocumentsGridComponent, HeaderComponent, @@ -34,6 +38,7 @@ import { imports: [ AgGridAngular, AgGridModule, + AnalysesGridComponent, BuildingFilesGridComponent, CommonModule, DocumentsGridComponent, @@ -44,10 +49,12 @@ import { PageComponent, PairedGridComponent, ScenariosGridComponent, + SharedImports, ], }) export class DetailComponent implements OnDestroy, OnInit { private _activatedRoute = inject(ActivatedRoute) + private _analysisService = inject(AnalysisService) private _columnService = inject(ColumnService) private _configService = inject(ConfigService) private _inventoryService = inject(InventoryService) @@ -56,6 +63,7 @@ export class DetailComponent implements OnDestroy, OnInit { private _router = inject(Router) private _userService = inject(UserService) private readonly _unsubscribeAll$ = new Subject() + analyses: Analysis[] columns: Column[] currentUser: CurrentUser currentProfile: Profile @@ -86,6 +94,7 @@ export class DetailComponent implements OnDestroy, OnInit { switchMap(() => this.getDependencies()), switchMap(() => this.updateOrgUserSettings()), switchMap(() => this.loadView()), + switchMap(() => this.getAnalyses()), ).subscribe() } @@ -134,6 +143,15 @@ export class DetailComponent implements OnDestroy, OnInit { ) } + // retrieve analyses for this property, filtered by the selected cycle + getAnalyses(): Observable { + return this._analysisService.getPropertyAnalyses(this.view.property.id).pipe( + tap((analyses: Analysis[]) => { + this.analyses = analyses.filter((analysis) => analysis.cycles.includes(this.view.cycle.id)) + }), + ) + } + get paired() { if (!this.view) return [] return this.type === 'taxlots' ? this.view.properties : this.view.taxlots diff --git a/src/app/modules/inventory/detail/grid/analyses-grid.component.html b/src/app/modules/inventory/detail/grid/analyses-grid.component.html new file mode 100644 index 00000000..bf43eded --- /dev/null +++ b/src/app/modules/inventory/detail/grid/analyses-grid.component.html @@ -0,0 +1,20 @@ +
+
+
+ + {{ t( 'Analyses' ) }} +
+
+ + @if (rowData.length) { + + } +
diff --git a/src/app/modules/inventory/detail/grid/analyses-grid.component.ts b/src/app/modules/inventory/detail/grid/analyses-grid.component.ts new file mode 100644 index 00000000..6af5b712 --- /dev/null +++ b/src/app/modules/inventory/detail/grid/analyses-grid.component.ts @@ -0,0 +1,168 @@ +import { CommonModule } from '@angular/common' +import type { OnInit, SimpleChanges } from '@angular/core' +import { Component, EventEmitter, inject, Input, Output, isDevMode } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button' +// import { MatDialog } from '@angular/material/dialog' +import { MatIconModule } from '@angular/material/icon' +import { Router } from '@angular/router' +import type { Analysis } from '@seed/api/analysis' +import { AnalysisService } from '@seed/api/analysis' +import { SharedImports } from '@seed/directives' +import { ConfigService } from '@seed/services' +import { AgGridAngular, AgGridModule } from 'ag-grid-angular' +import type { CellClickedEvent, ColDef, GridApi, GridReadyEvent } from 'ag-grid-community' +import { Subject } from 'rxjs' + +@Component({ + selector: 'seed-inventory-detail-analyses-grid', + templateUrl: './analyses-grid.component.html', + imports: [ + AgGridAngular, + AgGridModule, + CommonModule, + MatButtonModule, + MatIconModule, + SharedImports, + ], +}) +export class AnalysesGridComponent implements OnInit { + @Input() analyses: Analysis[] + private _analysisService = inject(AnalysisService) + private _configService = inject(ConfigService) + private _router = inject(Router) + // private _dialog = inject(MatDialog) + private readonly _unsubscribeAll$ = new Subject() + gridApi: GridApi + gridTheme$ = this._configService.gridTheme$ + columnDefs: ColDef[] = [] + rowData: Record[] = [] + defaultColDef = { + sortable: false, + filter: false, + resizable: true, + suppressMovable: true, + } + + ngOnInit(): void { + this.setGrid() + } + + setGrid() { + this.columnDefs = [ + { field: 'name', headerName: 'Analysis Name', width: 150 }, + { field: 'id', hide: true }, + { field: 'run_id', headerName: 'Run ID', width: 60 }, + { field: 'service', headerName: 'Type', width: 100 }, + { field: 'status', headerName: 'Status', width: 100 }, + { + field: 'created_at', + headerName: 'Created', + cellRenderer: this.timestampRenderer, + width: 120, + }, + { + field: 'highlights', + headerName: 'Highlights', + cellRenderer: this.highlightsRenderer, + autoHeight: true, + }, + { field: 'actions', headerName: 'Actions', cellRenderer: this.actionRenderer, width: 70 }, + ] + + for (const { name, id, views, service, status, created_at, highlights } of this.analyses) { + this.rowData.push({ name, id, run_id: views[0], service, status, created_at, highlights }) + } + } + + actionRenderer = () => { + return ` +
+ logout +
+ ` + } + + timestampRenderer = (params: { value: string | number | Date }) => { + if (!params.value) { + return '' // Return empty string if no value + } + const date = new Date(params.value) // Convert the value to a Date object + const formattedDate = new Intl.DateTimeFormat('en-US', { + month: '2-digit', + day: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: true, // Use 24-hour format + }).format(date) + return formattedDate // Return the formatted date + } + + /** + * Renders a list of highlights as an HTML unordered list. + * Each item in `params.value` is an object with the format: `{ name: string, value: string }`. + * + * @param params - The parameters object containing the `value` property. + * @param params.value - An array of objects where each object has the structure: + * `{ name: string, value: string }`. + * @returns A string representing an HTML unordered list with each item's name in bold + * and its value displayed next to it. + */ + highlightsRenderer = (params: { value: unknown }) => { + const container = document.createElement('div') + container.style.whiteSpace = 'normal' // Allow text wrapping + container.style.lineHeight = '1.5' // Adjust line height for better readability + + const highlights = Array.isArray(params.value) + ? params.value.filter( + (item): item is { name: string; value: string } => + typeof item === 'object' && item !== null && 'name' in item && 'value' in item, + ) + : [] + + const ul = document.createElement('ul') + for (const item of highlights) { + const li = document.createElement('li') + li.innerHTML = `${item.name}: ${item.value}` + ul.appendChild(li) + } + + container.appendChild(ul) + return container + } + + get gridHeight() { + const headerHeight = 50 + const height = this.rowData.length * 42 + headerHeight + return Math.min(height, 500) + } + + getRowHeight = (params: any) => { + if (params.data && params.data.highlights) { + return 100 // Adjust this value based on your content + } + return 42 // Default row height + } + + onGridReady(agGrid: GridReadyEvent) { + this.gridApi = agGrid.api + this.gridApi.sizeColumnsToFit() + this.gridApi.addEventListener('cellClicked', this.onCellClicked.bind(this) as (event: CellClickedEvent) => void) + } + + onCellClicked(event: CellClickedEvent) { + if (event.colDef.field !== 'actions') return + const target = event.event.target as HTMLElement + const action = target.getAttribute('data-action') + + const id: number = event.data.id + const _run_id: number = event.data.run_id + + if (action === 'view') { + // take user to analysis run page at /analyses/:id/runs/:runId + if (id && _run_id) { + void this._router.navigate([`/analyses/${id}/runs/${_run_id}`]) + } + } + } +} diff --git a/src/app/modules/inventory/detail/header.component.ts b/src/app/modules/inventory/detail/header.component.ts index eb363da5..4eaed383 100644 --- a/src/app/modules/inventory/detail/header.component.ts +++ b/src/app/modules/inventory/detail/header.component.ts @@ -84,7 +84,7 @@ export class HeaderComponent implements OnInit { setAliGrid() { const inventoryKey = this.type === 'properties' ? 'property' : 'taxlot' - // column defs + // column defs (minus root level) for (const name of this.org.access_level_names.slice(1)) { this.aliColumnDefs.push( { diff --git a/src/app/modules/inventory/detail/index.ts b/src/app/modules/inventory/detail/index.ts index d0b2f2a1..7bf98022 100644 --- a/src/app/modules/inventory/detail/index.ts +++ b/src/app/modules/inventory/detail/index.ts @@ -1,4 +1,5 @@ export * from './detail.component' +export * from './grid/analyses-grid.component' export * from './grid/building-files-grid.component' export * from './grid/documents-grid.component' export * from './grid/history-grid.component' From 991ea6438a1ec0a8158e39fde9f82e61099ec446 Mon Sep 17 00:00:00 2001 From: Ross Perry Date: Thu, 29 May 2025 21:37:21 +0000 Subject: [PATCH 5/5] detail analyses --- .../detail/grid/analyses-grid.component.html | 2 +- .../detail/grid/analyses-grid.component.ts | 33 ++++++++++++------- 2 files changed, 22 insertions(+), 13 deletions(-) diff --git a/src/app/modules/inventory/detail/grid/analyses-grid.component.html b/src/app/modules/inventory/detail/grid/analyses-grid.component.html index bf43eded..44322462 100644 --- a/src/app/modules/inventory/detail/grid/analyses-grid.component.html +++ b/src/app/modules/inventory/detail/grid/analyses-grid.component.html @@ -12,8 +12,8 @@ [rowData]="rowData" [theme]="gridTheme$ | async" [getRowHeight]="getRowHeight" - [domLayout]="'autoHeight'" (gridReady)="onGridReady($event)" + [style.height.px]="gridHeight" (cellClicked)="onCellClicked($event)" > } diff --git a/src/app/modules/inventory/detail/grid/analyses-grid.component.ts b/src/app/modules/inventory/detail/grid/analyses-grid.component.ts index 6af5b712..9ab7a838 100644 --- a/src/app/modules/inventory/detail/grid/analyses-grid.component.ts +++ b/src/app/modules/inventory/detail/grid/analyses-grid.component.ts @@ -1,6 +1,6 @@ import { CommonModule } from '@angular/common' -import type { OnInit, SimpleChanges } from '@angular/core' -import { Component, EventEmitter, inject, Input, Output, isDevMode } from '@angular/core'; +import type { OnInit } from '@angular/core' +import { Component, inject, Input } from '@angular/core' import { MatButtonModule } from '@angular/material/button' // import { MatDialog } from '@angular/material/dialog' import { MatIconModule } from '@angular/material/icon' @@ -53,7 +53,7 @@ export class AnalysesGridComponent implements OnInit { { field: 'id', hide: true }, { field: 'run_id', headerName: 'Run ID', width: 60 }, { field: 'service', headerName: 'Type', width: 100 }, - { field: 'status', headerName: 'Status', width: 100 }, + { field: 'status', headerName: 'Status', width: 100, cellRenderer: this.statusRenderer }, { field: 'created_at', headerName: 'Created', @@ -74,10 +74,19 @@ export class AnalysesGridComponent implements OnInit { } } + statusRenderer = ({ value }: { value: string }) => { + // default to no background + const colorMap = { + Completed: 'bg-green-800', + Failed: 'bg-red-800', + } + + return `
${value}
` + } actionRenderer = () => { return `
- logout + logout
` } @@ -133,15 +142,14 @@ export class AnalysesGridComponent implements OnInit { get gridHeight() { const headerHeight = 50 - const height = this.rowData.length * 42 + headerHeight + const rowHeights = this.rowData.map((row: Analysis) => Math.max(42, row.highlights.length * 25)) + const rowsHeight = rowHeights.reduce((acc, num) => acc + num, 0) + const height = rowsHeight + headerHeight return Math.min(height, 500) } - getRowHeight = (params: any) => { - if (params.data && params.data.highlights) { - return 100 // Adjust this value based on your content - } - return 42 // Default row height + getRowHeight = (params: { data: Analysis }) => { + return Math.max(42, params.data.highlights.length * 25) // Adjust based on the number of highlights } onGridReady(agGrid: GridReadyEvent) { @@ -154,9 +162,10 @@ export class AnalysesGridComponent implements OnInit { if (event.colDef.field !== 'actions') return const target = event.event.target as HTMLElement const action = target.getAttribute('data-action') + const { data } = event.data as { data: { id: number; run_id: number } } - const id: number = event.data.id - const _run_id: number = event.data.run_id + const id: number = data.id + const _run_id: number = data.run_id if (action === 'view') { // take user to analysis run page at /analyses/:id/runs/:runId
{{ t('Run ID') }} {{ t('Run') }} {{ view.id }}