Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
187 changes: 187 additions & 0 deletions src/@seed/api/analysis/analysis.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
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, AnalysisView, AnalysisViews, ListAnalysesResponse, ListMessagesResponse, OriginalView, PropertyAnalysesResponse, 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<Analysis[]>([]) // BehaviorSubject to hold the analyses
private _views = new BehaviorSubject<View[]>([])
private _originalViews = new BehaviorSubject<OriginalView[]>([])
private _messages = new BehaviorSubject<AnalysesMessage[]>([])
private readonly _unsubscribeAll$ = new Subject<void>()
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<AnalysesViews> {
return this._httpClient
.get<ListAnalysesResponse>(`/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 (for all analyses or for a single one)
getMessages(_analysisId = '0'): Observable<AnalysesMessage[]> {
return this._httpClient
.get<ListMessagesResponse>(`/api/v3/analyses/${_analysisId}/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<Analysis> {
return this._httpClient
.get<AnalysisResponse>(`/api/v3/analyses/${_analysisId}?organization_id=${this.orgId}`)
.pipe(
map((response) => response.analysis),
catchError((error: HttpErrorResponse) => {
return this._errorService.handleError(error, 'Error fetching analyses')
}),
)
}

// get analyses for a property (by property ID)
getPropertyAnalyses(_propertyId): Observable<Analysis[]> {
return this._httpClient
.get<PropertyAnalysesResponse>(`/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<AnalysisView> {
return this._httpClient
.get<AnalysisView>(`/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<AnalysisViews> {
return this._httpClient
.get<AnalysisViews>(`/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}`
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<AnalysesViews> {
const completionStatuses = ['Failed', 'Stopped', 'Completed']
return new Observable<AnalysesViews>((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
})
}
}
89 changes: 89 additions & 0 deletions src/@seed/api/analysis/analysis.types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// 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<string, unknown>; // configuration is different for each analysis type
parsed_results: Record<string, unknown>; // 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<string, unknown>[];
parsed_results: Record<string, unknown>;
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<string, number>

export type ListAnalysesResponse = {
status: 'success';
analyses: Analysis[];
views: View[];
original_views: OriginalView[];
}

export type AnalysisResponse = {
status: 'success';
analysis: Analysis;
}

export type PropertyAnalysesResponse = {
status: 'success';
analyses: Analysis[];
}

export type AnalysesViews = {
analyses: Analysis[];
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;
analysis_property_view: number;
debug_message: string;
type: string;
user_message: string;
}

export type ListMessagesResponse = {
status: 'success';
messages: AnalysesMessage[];
}
2 changes: 2 additions & 0 deletions src/@seed/api/analysis/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './analysis.service'
export * from './analysis.types'
1 change: 1 addition & 0 deletions src/@seed/api/cycle/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './cycle.types'
export * from './cycle.service'
100 changes: 98 additions & 2 deletions src/app/modules/analyses/analyses.component.html
Original file line number Diff line number Diff line change
@@ -1,3 +1,99 @@
<seed-page [config]="{ title: 'Analyses', titleIcon: 'fa-solid:chart-bar' }">
<div>Analyses Content</div>
<seed-page [config]="{ title: 'Analyses', titleIcon: 'fa-solid:chart-bar' }" *transloco="let t">
<div class="flex-auto pt-4 sm:pt-6">
<div class="bg-card flex flex-auto flex-col overflow-hidden rounded-2xl p-6 shadow sm:col-span-2 md:col-span-4">
<div class="overflow-x-auto">
<div class="mx-auto h-screen w-full max-w-screen-2xl">
<mat-tab-group>
<mat-tab label="{{ t('By Analysis') }}">
<div class="mx-0 h-screen w-full max-w-screen-2xl">
<mat-grid-list cols="14" gutterSize="0px" rowHeight="1:1" class="justify-center">
<mat-grid-tile [colspan]="2" class="header-row">{{ t('Analysis Name') }}</mat-grid-tile>
<mat-grid-tile class="header-row">{{ t('Actions') }}</mat-grid-tile>
<mat-grid-tile class="header-row">{{ t('Number of Properties') }}</mat-grid-tile>
<mat-grid-tile class="header-row">{{ t('Type') }}</mat-grid-tile>
<mat-grid-tile [colspan]="3" class="header-row">{{ t('Configuration') }}</mat-grid-tile>
<mat-grid-tile class="header-row">{{ t('Created') }}</mat-grid-tile>
<mat-grid-tile class="header-row">{{ t('Run Status') }}</mat-grid-tile>
<mat-grid-tile class="header-row">{{ t('Run Date') }}</mat-grid-tile>
<mat-grid-tile class="header-row">{{ t('Run Duration') }}</mat-grid-tile>
<mat-grid-tile [colspan]="2" class="header-row">{{ t('Run Cycle') }}</mat-grid-tile>
</mat-grid-list>
<mat-grid-list cols="14" gutterSize="0px" rowHeight="1:3">
@for (analysis of analyses; track trackByIdAndStatus(analysis)) {
<mat-grid-tile [colspan]="2" [class.alt-row]="$odd">
<a class="ml-1 text-primary-500 hover:underline focus:underline" [routerLink]="['/analyses', analysis.id]">{{ analysis.name }}</a>
</mat-grid-tile>
<mat-grid-tile [class.alt-row]="$odd">
<!-- actions -->
<!-- <i ng-show="menu.user.organization.user_role !== 'viewer'" class="glyphicon glyphicon-trash" title="Delete Analysis" aria-hidden="true" ng-click="delete_analysis(analysis.id)"></i> -->
@if (currentUser.org_role !== 'viewer') {
<button (click)="deleteAnalysis(analysis)"
aria-label="Delete Analysis"
title="Delete Analysis">
<mat-icon class="text-secondary icon-size-4" svgIcon="fa-solid:trash-can"></mat-icon>
</button>
}

</mat-grid-tile>
<mat-grid-tile [class.alt-row]="$odd">{{ analysis.number_of_analysis_property_views }}</mat-grid-tile>
<mat-grid-tile [class.alt-row]="$odd">{{ analysis.service }}</mat-grid-tile>
<mat-grid-tile [colspan]="3" [class.alt-row]="$odd">
<div class="tile-content">
<ul class="list-disc list-outside m-0 p-4">
<!-- iterate over the hash display key - value pairs -->
@for (key of getKeys(analysis.configuration); track key) {
<li *ngIf="typeof analysis.configuration[key] !== 'object'"><strong>{{ key }}</strong>: {{ analysis.configuration[key] }}</li>
}
</ul>
</div>
</mat-grid-tile>
<mat-grid-tile [class.alt-row]="$odd">{{ analysis.created_at | date : 'MM-dd-yy HH:mm' }}</mat-grid-tile>
<mat-grid-tile class="analysis-status" [class.alt-row]="$odd" [ngClass]="analysis.status.toLowerCase()">
<i class="fa-solid fa-arrows-rotate fa-spin-pulse fa-fw" style="padding-right: 0" ng-if="!analysis._finished_with_tasks"></i>
{{ analysis.status }}
</mat-grid-tile>
<mat-grid-tile [class.alt-row]="$odd" class="pad-row">{{ analysis.start_time | date : 'MM-dd-yy HH:mm' }}</mat-grid-tile>
<mat-grid-tile [class.alt-row]="$odd">{{ runDuration(analysis) }}</mat-grid-tile>
<mat-grid-tile [colspan]="2" [class.alt-row]="$odd">{{ cycle(analysis.cycles[0]) }}</mat-grid-tile>
}
</mat-grid-list>
</div>
</mat-tab>
<mat-tab label="{{ t('By Property') }}">
<div>
@for (view of views; track view.id) {
<mat-card appearance="outlined" class="mb-2" [class.even-card]="$even">
<mat-card-header>
<mat-card-title-group>
<mat-card-title>
<a class="ml-1 text-primary-500 hover:underline focus:underline" [routerLink]="['/analyses', view.analysis, 'runs', view.id]">Run ID {{ view.id }}</a> -
<a class="ml-1 text-primary-500 hover:underline focus:underline" [routerLink]="['/properties', originalViews[view.id]]">{{ view.display_name || 'Property ' + originalViews[view.id] }}</a>
</mat-card-title>
</mat-card-title-group>
</mat-card-header>
<mat-card-content>
<div class="pl-10">
@if (filteredMessages(view.id); as items) {
<ul class="md:list-disc list-outside m-0 p-4">
@for (message of items; track message.id) {
<li class="pb-2">
{{ message['user_message'] }}
@if (message['debug_message']) {
- {{ message['debug_message'] }}
}
</li>
}
</ul>
}
</div>
</mat-card-content>
</mat-card>
}
</div>
</mat-tab>
</mat-tab-group>
</div>
</div>
</div>
</div>
</seed-page>
Loading