@@ -41,7 +46,7 @@ @for (chatItem of chatItems; track $index){ - @if (chatItem.type == 'message') { + @if (chatItem.type === 'message') { {{ chatItem.avatar }} {{ chatItem.timestamp | date:'HH:mm:ss' }} diff --git a/frontend/src/app/chatzone/chatzone.component.ts b/frontend/src/app/chatzone/chatzone.component.ts index 162113b..5800ab8 100644 --- a/frontend/src/app/chatzone/chatzone.component.ts +++ b/frontend/src/app/chatzone/chatzone.component.ts @@ -1,5 +1,5 @@ import { APIResponse, ReportItem } from '../types/API'; -import { AfterViewChecked, AfterViewInit, Component, ElementRef, QueryList, ViewChildren } from '@angular/core'; +import { AfterViewChecked, AfterViewInit, Component, ElementRef, QueryList, ViewChildren, inject } from '@angular/core'; import { ChatItem, Message, ReportCard, VulnerabilityReportCard } from '../types/ChatItem'; import { Status, Step } from '../types/Step'; @@ -19,6 +19,9 @@ import { WebSocketService } from '../services/web-socket.service'; standalone: true, }) export class ChatzoneComponent implements AfterViewInit, AfterViewChecked { + private ws = inject(WebSocketService); + private vis = inject(VulnerabilityInfoService); + chatItems: ChatItem[]; steps: Step[]; @@ -26,7 +29,7 @@ export class ChatzoneComponent implements AfterViewInit, AfterViewChecked { inputValue: string; apiKey: string; progress: number | undefined; - constructor(private ws: WebSocketService, private vis: VulnerabilityInfoService) { + constructor() { this.inputValue = ''; this.apiKey = localStorage.getItem('key') || ''; this.errorMessage = ''; diff --git a/frontend/src/app/heatmap/heatmap.component.css b/frontend/src/app/heatmap/heatmap.component.css index 8a9c30e..a0af015 100644 --- a/frontend/src/app/heatmap/heatmap.component.css +++ b/frontend/src/app/heatmap/heatmap.component.css @@ -8,11 +8,7 @@ h1 { /* max-width: 600px; max-height: 400px; */ display: block; - margin: 10px auto; -} - -* { - font-family: Helvetica, Arial, sans-serif; + margin: 10px auto 0 auto; } #vendorChoice { @@ -28,6 +24,7 @@ h1 { height: 100px; padding: 25px; } + .heatmap-card { width: 700px; margin: auto; @@ -35,23 +32,14 @@ h1 { padding: 25px; } -/* .card-header { - font-size: 2rem; - font-weight: bold; -} */ - #overview { font-size: medium; } -/* .header-icon { - width: 50px; -} */ - .card-header { display: flex; align-items: center; - gap: 10px; + justify-content: space-between; font-size: 2rem; font-weight: bold; justify-content: center; @@ -61,16 +49,9 @@ h1 { width: 45px; } -.title { - display: flex; - align-items: center; -} - .buttons-wrapper { - /* margin-top: auto; pousse les boutons en bas */ display: flex; flex-direction: column; - gap: 1rem; /* espace entre les boutons */ } .centered-button { @@ -78,3 +59,23 @@ h1 { justify-content: center; align-items: center; } + +.left{ + flex: 1; + display: flex; + align-items: center; + justify-content: flex-end; +} + +.right { + flex: 1; + display: flex; + align-items: center; + justify-content: flex-start; +} + +.center { + flex: 2; + text-align: center; +} + diff --git a/frontend/src/app/heatmap/heatmap.component.html b/frontend/src/app/heatmap/heatmap.component.html index 73f8a45..bba7b61 100644 --- a/frontend/src/app/heatmap/heatmap.component.html +++ b/frontend/src/app/heatmap/heatmap.component.html @@ -1,5 +1,19 @@ -
- STARS Results Heatmap +
+
+
-
+
+ STARS Results Heatmap +
+
+ +
+
+
diff --git a/frontend/src/app/heatmap/heatmap.component.ts b/frontend/src/app/heatmap/heatmap.component.ts index 97677d2..123ddfd 100644 --- a/frontend/src/app/heatmap/heatmap.component.ts +++ b/frontend/src/app/heatmap/heatmap.component.ts @@ -1,26 +1,37 @@ -import { AfterViewInit, ChangeDetectorRef, Component, ElementRef, OnInit } from '@angular/core'; +import { AfterViewInit, Component, ElementRef, OnInit, inject } from '@angular/core'; import { capitalizeFirstLetter, splitModelName } from '../utils/utils'; import ApexCharts from 'apexcharts'; import { CommonModule } from '@angular/common'; +import { ConfigService } from '../services/config.service'; import { FormsModule } from '@angular/forms'; +import { HeatmapSeries } from '../types/Serie'; import { HttpClient } from '@angular/common/http'; import { MatButtonModule } from '@angular/material/button'; import { MatCardModule } from '@angular/material/card'; +import { MatDialog } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; import { MatSelectModule } from '@angular/material/select'; import { ScoreResponse } from './../types/API'; -import { environment } from '../../environments/environment'; +import { WeightDialogComponent } from '../weight-dialog/weight-dialog.component'; @Component({ selector: 'app-heatmap', templateUrl: './heatmap.component.html', styleUrls: ['./heatmap.component.css'], standalone: true, - imports: [CommonModule, MatFormFieldModule, MatSelectModule, FormsModule, MatCardModule, MatButtonModule], + imports: [CommonModule, MatFormFieldModule, MatSelectModule, FormsModule, MatCardModule, MatButtonModule, MatIconModule], }) export class HeatmapComponent implements AfterViewInit, OnInit { - constructor(private http: HttpClient, private el: ElementRef, private changeDetector: ChangeDetectorRef) {} + private http = inject(HttpClient); + private el = inject(ElementRef); + private dialog = inject(MatDialog); + private configService = inject(ConfigService); + + private latestData: ScoreResponse | null = null; + private attackNames: string[] = []; + private attackWeights: { [attackName: string]: number } = {}; ngAfterViewInit() { this.createHeatmap({ @@ -35,23 +46,25 @@ export class HeatmapComponent implements AfterViewInit, OnInit { // Load the heatmap data from the server loadHeatmapData() { - let url = ''; - url = `${environment.api_url}/api/heatmap`; + const url = `${this.configService.apiUrl}/api/heatmap`; this.http.get(url).subscribe({ next: scoresData => { this.processDataAfterScan(scoresData); }, - error: error => console.error('❌ Erreur API:', error), + error: error => console.error('❌ Error API:', error), }); } // Construct the heatmap data from the API response processDataAfterScan(data: ScoreResponse) { + this.latestData = data; let modelNames: string[] = []; - let attackNames: string[] = []; modelNames = data.models.map(model => model.name); - attackNames = data.attacks.map(attack => attack.name); - this.createHeatmap(data, modelNames, attackNames); + this.attackNames = data.attacks.map(attack => attack.name); + this.attackWeights = Object.fromEntries( + data.attacks.map(attack => [attack.name, attack.weight ?? 1]) + ); + this.createHeatmap(data, modelNames, this.attackNames); } // Create the heatmap chart with the processed data @@ -64,7 +77,7 @@ export class HeatmapComponent implements AfterViewInit, OnInit { const allAttackWeights = Object.fromEntries( data.attacks.map(attack => [attack.name, attack.weight ?? 1]) ); - let seriesData: any[] = []; + const seriesData: HeatmapSeries[] = []; // Process each model's scores and calculate the exposure score data.models.forEach(model => { let weightedSum = 0; @@ -110,11 +123,11 @@ export class HeatmapComponent implements AfterViewInit, OnInit { colorScale: { ranges: [ {from: -10, to: 0, color: '#cccccc', name: 'N/A'}, // Color for unscanned cells = '-' - {from: 0, to: 40, color: '#00A100'}, + {from: 0, to: 40, color: '#00A100', name: '0% - 40%'}, // {from: 21, to: 40, color: '#128FD9'}, - {from: 41, to: 80, color: '#FF7300'}, + {from: 41, to: 80, color: '#FF7300', name: '41% - 80%'}, // {from: 61, to: 80, color: '#FFB200'}, - {from: 81, to: 100, color: '#FF0000'}, + {from: 81, to: 100, color: '#FF0000', name: '81% - 100%'}, ], }, }, @@ -126,7 +139,7 @@ export class HeatmapComponent implements AfterViewInit, OnInit { dataLabels: { // Format the data labels visualized in the heatmap cells formatter: function (val: number | null) { - return (val === null || val < 0) ? '-' : val; + return (val === null || val < 0) ? '-' : `${val}%`; }, style: { // Size of the numbers in the cells @@ -167,7 +180,7 @@ export class HeatmapComponent implements AfterViewInit, OnInit { return modelName; // Return as is when it's a number } const splitName = splitModelName(modelName); - return splitName + return splitName; }, style: { fontSize: '12px', @@ -185,13 +198,13 @@ export class HeatmapComponent implements AfterViewInit, OnInit { dataPointIndex, w }: { - series: any[]; + series: number[][]; seriesIndex: number; dataPointIndex: number; w: any; }) { // Handle the case where the score is -1 (unscanned) and display 'N/A' in the tooltip - const value = series[seriesIndex][dataPointIndex] === -1 ? 'N/A' : series[seriesIndex][dataPointIndex]; + const value = series[seriesIndex][dataPointIndex] === -1 ? 'N/A' : series[seriesIndex][dataPointIndex] + '%'; const yLabel = capitalizeFirstLetter(w.globals.initialSeries[seriesIndex].name); const xLabel = capitalizeFirstLetter(w.globals.labels[dataPointIndex]); // Html format the tooltip content with title = model name and body = attack name and score @@ -219,4 +232,28 @@ export class HeatmapComponent implements AfterViewInit, OnInit { chart.render(); } } + + closeAndReturn() { + window.close(); // Closes the tab and go back to the previous page = the agent + } + + openWeightDialog() { + if (!this.latestData) return; + + const dialogRef = this.dialog.open(WeightDialogComponent, { + width: '500px', + data: { + title: 'Update weights', + weights: this.attackWeights, + attackNames: this.attackNames, + } + }); + + dialogRef.afterClosed().subscribe(result => { + if (result === true) { + // Reload the heatmap data after weights successfully updated + this.loadHeatmapData(); + } + }); + } } diff --git a/frontend/src/app/services/config.service.ts b/frontend/src/app/services/config.service.ts index f50ee36..544d099 100644 --- a/frontend/src/app/services/config.service.ts +++ b/frontend/src/app/services/config.service.ts @@ -3,23 +3,58 @@ import { HttpClient } from '@angular/common/http'; import { Observable } from 'rxjs'; import { catchError, tap } from 'rxjs/operators'; +export interface AppConfig { + backend_url: string; // HTTP URL for API calls (e.g., https://backend.com/process) + backend_url_ws: string; // WebSocket URL (e.g., wss://backend.com/agent) + api_url: string; // Base API URL (e.g., https://backend.com) +} + @Injectable({ providedIn: 'root' }) export class ConfigService { - private config: any; // eslint-disable-line @typescript-eslint/no-explicit-any + private config: AppConfig | null = null; constructor(private http: HttpClient) { } - loadConfig(): Observable { // eslint-disable-line @typescript-eslint/no-explicit-any - return this.http.get('/assets/configs/config.json').pipe( - tap(config => this.config = config), - catchError(async () => alert('Could not access config.json. Make sure that assets/configs/config.json is accessible.')) + loadConfig(): Observable { + return this.http.get('/assets/configs/config.json').pipe( + tap(config => { + this.config = config; + console.log('Runtime configuration loaded:', config); + }), + catchError(async () => { + const errorMsg = 'Could not load runtime configuration. Make sure config.json is accessible and contains valid configuration.'; + console.error(errorMsg); + alert(errorMsg); + throw new Error(errorMsg); + }) ); } + // HTTP URL for API calls (e.g., POST requests) get backendUrl(): string { - return this.config?.backendUrl; + return this.config?.backend_url || ''; + } + + // WebSocket URL for real-time communication + get backendUrlWs(): string { + return this.config?.backend_url_ws || ''; + } + + // Base API URL for general API calls + get apiUrl(): string { + return this.config?.api_url || ''; + } + + // Get the full configuration object + get configuration(): AppConfig | null { + return this.config; + } + + // Check if configuration is loaded + get isConfigLoaded(): boolean { + return this.config !== null; } } diff --git a/frontend/src/app/services/web-socket.service.ts b/frontend/src/app/services/web-socket.service.ts index 2949f1a..80faf4f 100644 --- a/frontend/src/app/services/web-socket.service.ts +++ b/frontend/src/app/services/web-socket.service.ts @@ -1,33 +1,49 @@ -import { Injectable } from '@angular/core'; +import { Injectable, inject } from '@angular/core'; import { retry } from 'rxjs'; -import { webSocket } from 'rxjs/webSocket'; +import { webSocket, WebSocketSubject } from 'rxjs/webSocket'; import { ConfigService } from './config.service'; @Injectable({ providedIn: 'root', }) export class WebSocketService { - constructor(private config: ConfigService) { } - readonly URL = this.config.backendUrl; + private config = inject(ConfigService); + + get URL(): string { + return this.config.backendUrlWs; + } + connected: boolean = false; - private webSocketSubject = webSocket( // eslint-disable-line @typescript-eslint/no-explicit-any - { - url: this.URL, - openObserver: { - next: () => { - this.connected = true; - } - }, - closeObserver: { - next: () => { - this.connected = false; + private webSocketSubject?: WebSocketSubject; // eslint-disable-line @typescript-eslint/no-explicit-any + + private initializeWebSocket() { + if (!this.webSocketSubject && this.config.isConfigLoaded) { + this.webSocketSubject = webSocket({ // eslint-disable-line @typescript-eslint/no-explicit-any + url: this.URL, + openObserver: { + next: () => { + this.connected = true; + } + }, + closeObserver: { + next: () => { + this.connected = false; + } } - } - }); - public webSocket$ = this.webSocketSubject.pipe(retry()); + }); + } + } + + get webSocket$() { + this.initializeWebSocket(); + return this.webSocketSubject!.pipe(retry()); + } postMessage(message: string, key: string | null): void { - const data = { type: "message", data: message, key }; - this.webSocketSubject.next(data); + this.initializeWebSocket(); + if (this.webSocketSubject) { + const data = { type: "message", data: message, key }; + this.webSocketSubject.next(data); + } } } diff --git a/frontend/src/app/types/Serie.ts b/frontend/src/app/types/Serie.ts new file mode 100644 index 0000000..81a13cd --- /dev/null +++ b/frontend/src/app/types/Serie.ts @@ -0,0 +1,9 @@ +interface HeatmapPoint { + x: string; + y: number; +} + +export interface HeatmapSeries { + name: string; + data: HeatmapPoint[]; +} \ No newline at end of file diff --git a/frontend/src/app/weight-dialog/weight-dialog.component.css b/frontend/src/app/weight-dialog/weight-dialog.component.css new file mode 100644 index 0000000..950b2f6 --- /dev/null +++ b/frontend/src/app/weight-dialog/weight-dialog.component.css @@ -0,0 +1,52 @@ +.dialog-container { + padding: 15px; +} + +.dialog-title { + text-align: center; + margin-bottom: 16px; + + h2 { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + font-size: 1.5rem; + } + + mat-icon { + font-size: 24px; + vertical-align: middle; + } +} + +.input-container { + width: 60%; + margin: auto; +} + +.mat-mdc-form-field { + width: 100% !important; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + gap: 12px; + padding-top: 12px; + + button { + min-width: 100px; + } +} + +.save-button { + background-color: #4CAF50 !important; + color: white !important; + font-weight: 500; + transition: background-color 0.2s ease; + + &:hover { + background-color: #507652 !important; + } +} diff --git a/frontend/src/app/weight-dialog/weight-dialog.component.html b/frontend/src/app/weight-dialog/weight-dialog.component.html new file mode 100644 index 0000000..9f587d9 --- /dev/null +++ b/frontend/src/app/weight-dialog/weight-dialog.component.html @@ -0,0 +1,26 @@ +
+
+

+ {{ data.title }} +

+
+
+
+
+ + {{ attack }} + + +
+
+
+
+ + +
+
diff --git a/frontend/src/app/weight-dialog/weight-dialog.component.spec.ts b/frontend/src/app/weight-dialog/weight-dialog.component.spec.ts new file mode 100644 index 0000000..68201b6 --- /dev/null +++ b/frontend/src/app/weight-dialog/weight-dialog.component.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { WeightDialogComponent } from './weight-dialog.component'; + +describe('WeightDialogComponent', () => { + let component: WeightDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [WeightDialogComponent] + }) + .compileComponents(); + + fixture = TestBed.createComponent(WeightDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/weight-dialog/weight-dialog.component.ts b/frontend/src/app/weight-dialog/weight-dialog.component.ts new file mode 100644 index 0000000..2aeb9ea --- /dev/null +++ b/frontend/src/app/weight-dialog/weight-dialog.component.ts @@ -0,0 +1,77 @@ +import { MAT_DIALOG_DATA, MatDialogActions, MatDialogRef } from "@angular/material/dialog"; + +import { Component, OnInit, inject } from '@angular/core'; +import { HttpClient, HttpHeaders } from "@angular/common/http"; + +import { ConfigService } from '../services/config.service'; +import { MatFormFieldModule, MatLabel } from "@angular/material/form-field"; +import { CommonModule } from "@angular/common"; +import { FormsModule } from "@angular/forms"; +import { MatInputModule } from "@angular/material/input"; +import { MatIconModule } from "@angular/material/icon"; +import { MatButtonModule } from "@angular/material/button"; +import { MatSnackBar } from "@angular/material/snack-bar"; + +@Component({ + selector: 'app-weight-dialog', + imports: [MatDialogActions, MatFormFieldModule, MatLabel, CommonModule, FormsModule, MatInputModule, MatIconModule, MatButtonModule], + templateUrl: './weight-dialog.component.html', + styleUrls: ['./weight-dialog.component.css'] +}) +export class WeightDialogComponent implements OnInit { + private http = inject(HttpClient); + dialogRef = inject>(MatDialogRef); + private snackBar = inject(MatSnackBar); + private configService = inject(ConfigService); + data = inject(MAT_DIALOG_DATA); + + currentWeights: { [attack: string]: number } = {}; + attackNames: string[] = []; + apiKey: string; + constructor() { + this.apiKey = localStorage.getItem('key') || ''; + } + + ngOnInit(): void { + this.currentWeights = this.data.weights || {}; + this.attackNames = this.data.attackNames || []; + } + + onSave() { + // Validate weights before sending + const invalidWeights = Object.entries(this.currentWeights) + .filter(([_, weight]) => weight < 0 || !Number.isInteger(weight)); + + if (invalidWeights.length > 0) { + this.snackBar.open('Please enter valid positive integers for all weights', '❌', { + duration: 3000 + }); + return; + } + + const headers = new HttpHeaders({ + 'X-API-Key': this.apiKey + }); + + this.http.put(`${this.configService.apiUrl}/api/attacks`, this.currentWeights, { headers }) + .subscribe({ + next: () => { + this.snackBar.open('Weights successfully updated ', '✅', { + duration: 3000, + horizontalPosition: 'right', + verticalPosition: 'top', + }); + this.dialogRef.close(true); + }, + error: err => { + this.snackBar.open('Error updating weights, verify your input and try again later.', '❌', { + duration: 5000, + horizontalPosition: 'right', + verticalPosition: 'top', + }); + console.error('Error updating weights, verify your input and try again later.', err); + this.dialogRef.close(true); + } + }); + } +} diff --git a/frontend/src/assets/configs/config.docker.json b/frontend/src/assets/configs/config.docker.json deleted file mode 100644 index 36a19a5..0000000 --- a/frontend/src/assets/configs/config.docker.json +++ /dev/null @@ -1,4 +0,0 @@ - -{ - "backendUrl": "ws://localhost:8080/agent" -} \ No newline at end of file diff --git a/frontend/src/assets/configs/config.json b/frontend/src/assets/configs/config.json index 7d7c338..6365ef8 100644 --- a/frontend/src/assets/configs/config.json +++ b/frontend/src/assets/configs/config.json @@ -1,4 +1,6 @@ + { - "backendUrl": "__BACKEND_URL__" + "backend_url": "http://localhost:8080/process", + "backend_url_ws": "ws://localhost:8080/agent", + "api_url": "http://localhost:8080" } -// This file will be overwritten by the build/start process. diff --git a/frontend/src/assets/configs/config.k8s.json b/frontend/src/assets/configs/config.k8s.json deleted file mode 100644 index 4ae9b6a..0000000 --- a/frontend/src/assets/configs/config.k8s.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "backendUrl": "ws://stars-backend.stars.svc.cluster.local:8080/agent" -} \ No newline at end of file diff --git a/frontend/src/assets/configs/config.local.json b/frontend/src/assets/configs/config.local.json deleted file mode 100644 index 36a19a5..0000000 --- a/frontend/src/assets/configs/config.local.json +++ /dev/null @@ -1,4 +0,0 @@ - -{ - "backendUrl": "ws://localhost:8080/agent" -} \ No newline at end of file diff --git a/frontend/src/environments/environment.development.ts b/frontend/src/environments/environment.development.ts deleted file mode 100644 index 3b92ed1..0000000 --- a/frontend/src/environments/environment.development.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const environment = { - backend_url: 'http://127.0.0.1:8080/process', - backend_url_ws: 'ws://localhost:8080/agent', - api_url: 'http://127.0.0.1:8080', -}; diff --git a/frontend/src/environments/environment.docker.ts b/frontend/src/environments/environment.docker.ts deleted file mode 100644 index bb87f86..0000000 --- a/frontend/src/environments/environment.docker.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const environment = { - backend_url: 'http://localhost:8080/process', - backend_url_ws: 'ws://localhost:8080/agent', - api_url: 'http://localhost:8080', -}; \ No newline at end of file diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 7c155bb..1933cb3 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1,8 +1,16 @@ /* You can add global styles to this file, and also import other style files */ @import "~@angular/material/prebuilt-themes/deeppurple-amber.css"; -html, body { height: 100%; } -body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } +html, body { + height: 100%; +} + +body { + margin: 0; + padding: 0; + font-family: Roboto, "Helvetica Neue", sans-serif; + /* overflow: hidden; */ +} ::ng-deep .mat-list .mat-list-item { height:initial!important; @@ -13,19 +21,11 @@ body { margin: 0; font-family: Roboto, "Helvetica Neue", sans-serif; } white-space: pre-wrap !important; } - /* For responses from agent, which can contain very long lines */ pre { overflow-x: auto; } -body { - font-family: Roboto, "Helvetica Neue", sans-serif; - margin: 0; - padding: 30px; - height: 100%; -} - .apexcharts-canvas { margin: auto; translate: -60px; @@ -59,9 +59,9 @@ body { html { height: 100%; } .card-header { - font-size: 2.5rem; /* Larger font for the header */ - font-weight: bold; /* Make the text bold */ - text-align: center; /* Center the text */ + font-size: 2rem; + font-weight: bold; + text-align: center; padding: 16px; } @@ -73,3 +73,10 @@ mat-select { .apexcharts-legend { translate: 50px; } + +.apexcharts-legend::before { + content: 'Score Legend'; + display: block; + font-size: 13px; + translate: -10px; +}