Skip to content

Commit

Permalink
NAS-131148 / 25.04 / Audit logs on HA - Add ability to view and expor…
Browse files Browse the repository at this point in the history
…t remote controller logs in the WebUI (#10746)

* NAS-131148: Audit logs on HA - Add ability to view and export remote controller logs in the WebUI

* NAS-131148: Audit logs on HA - Add ability to view and export remote controller logs in the WebUI
  • Loading branch information
AlexKarpov98 authored Sep 26, 2024
1 parent d7e8d0c commit 6708d3d
Show file tree
Hide file tree
Showing 99 changed files with 222 additions and 29 deletions.
4 changes: 4 additions & 0 deletions src/app/enums/controller-type.enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export enum ControllerType {
Active = 'ACTIVE',
Standby = 'STAND_BY',
}
1 change: 1 addition & 0 deletions src/app/interfaces/audit/audit.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export interface AuditQueryParams {
services?: AuditService[];
'query-filters'?: QueryFilters<AuditEntry>;
'query-options'?: QueryOptions<AuditEntry>;
remote_controller?: boolean;
}

export interface BaseAuditEntry {
Expand Down
1 change: 1 addition & 0 deletions src/app/interfaces/export-params.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ export interface ExportParams<T, F = ExportFormat> {
'query-filters'?: QueryFilters<T>;
'query-options'?: QueryOptions<T>;
export_format?: F;
remote_controller?: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatButtonHarness } from '@angular/material/button/testing';
import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest';
import { provideMockStore } from '@ngrx/store/testing';
import { mockCall, mockJob, mockWebSocket } from 'app/core/testing/utils/mock-websocket.utils';
import { ControllerType } from 'app/enums/controller-type.enum';
import { JobState } from 'app/enums/job-state.enum';
import { ApiJobMethod } from 'app/interfaces/api/api-job-directory.interface';
import { AuditEntry } from 'app/interfaces/audit/audit.interface';
Expand All @@ -11,6 +13,7 @@ import { ExportButtonComponent } from 'app/modules/buttons/export-button/export-
import { SortDirection } from 'app/modules/ix-table/enums/sort-direction.enum';
import { DownloadService } from 'app/services/download.service';
import { WebSocketService } from 'app/services/ws.service';
import { selectIsHaLicensed } from 'app/store/ha-info/ha-info.selectors';

describe('ExportButtonComponent', () => {
const jobMethod: ApiJobMethod = 'audit.export';
Expand All @@ -29,6 +32,14 @@ describe('ExportButtonComponent', () => {
mockProvider(DownloadService, {
downloadUrl: jest.fn(),
}),
provideMockStore({
selectors: [
{
selector: selectIsHaLicensed,
value: true,
},
],
}),
],
});

Expand Down Expand Up @@ -69,6 +80,7 @@ describe('ExportButtonComponent', () => {
isBasicQuery: true,
query: 'search query',
});
spectator.setInput('controllerType', ControllerType.Standby);
spectator.detectChanges();

const exportButton = await loader.getHarness(MatButtonHarness.with({ text: 'Export As CSV' }));
Expand All @@ -78,6 +90,7 @@ describe('ExportButtonComponent', () => {
export_format: 'CSV',
'query-filters': [['event', '~', '(?i)search query']],
'query-options': { order_by: ['-service'] },
remote_controller: true,
}]);
expect(spectator.inject(WebSocketService).call).toHaveBeenCalledWith('core.download', [jobMethod, [{}], '/path/data.csv']);
expect(spectator.inject(DownloadService).downloadUrl).toHaveBeenLastCalledWith(
Expand Down
25 changes: 16 additions & 9 deletions src/app/modules/buttons/export-button/export-button.component.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import {
ChangeDetectionStrategy, ChangeDetectorRef, Component, Input,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButton } from '@angular/material/button';
import { MatProgressBar } from '@angular/material/progress-bar';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { Store } from '@ngrx/store';
import { TranslateModule } from '@ngx-translate/core';
import { catchError, EMPTY, switchMap } from 'rxjs';
import { ControllerType } from 'app/enums/controller-type.enum';
import { ExportFormat } from 'app/enums/export-format.enum';
import { JobState } from 'app/enums/job-state.enum';
import { ApiCallDirectory } from 'app/interfaces/api/api-call-directory.interface';
Expand All @@ -20,6 +23,8 @@ import { TestIdModule } from 'app/modules/test-id/test-id.module';
import { DownloadService } from 'app/services/download.service';
import { ErrorHandlerService } from 'app/services/error-handler.service';
import { WebSocketService } from 'app/services/ws.service';
import { AppsState } from 'app/store';
import { selectIsHaLicensed } from 'app/store/ha-info/ha-info.selectors';

@UntilDestroy()
@Component({
Expand All @@ -44,16 +49,20 @@ export class ExportButtonComponent<T, M extends ApiJobMethod> {
@Input() fileType = 'csv';
@Input() fileMimeType = 'text/csv';
@Input() addReportNameArgument = false;
@Input() controllerType: ControllerType;
@Input() downloadMethod?: keyof ApiCallDirectory;

isLoading = false;

protected readonly isHaLicensed = toSignal(this.store$.select(selectIsHaLicensed));

constructor(
private ws: WebSocketService,
private cdr: ChangeDetectorRef,
private errorHandler: ErrorHandlerService,
private dialogService: DialogService,
private download: DownloadService,
private store$: Store<AppsState>,
) {}

onExport(): void {
Expand Down Expand Up @@ -104,7 +113,10 @@ export class ExportButtonComponent<T, M extends ApiJobMethod> {
'query-filters': queryFilters,
'query-options': queryOptions,
export_format: ExportFormat.Csv,
}] as unknown as ApiJobParams<M>;
...(this.isHaLicensed() && this.controllerType && {
remote_controller: this.controllerType === ControllerType.Standby,
}),
}] as ApiJobParams<M>;
}

private getQueryFilters(searchQuery: SearchQuery<T>): QueryFilters<T> {
Expand All @@ -116,18 +128,13 @@ export class ExportButtonComponent<T, M extends ApiJobMethod> {
}

private getQueryOptions(sorting: TableSort<T>): QueryOptions<T> {
if (!sorting) {
return {};
}

if (sorting.propertyName === null || sorting.direction === null) {
if (!sorting?.propertyName || !sorting?.direction) {
return {};
}

const orderPrefix = sorting.direction === SortDirection.Desc ? '-' : '';
return {
order_by: [
((sorting.direction === SortDirection.Desc ? '-' : '') + (sorting.propertyName as string)) as PropertyPath<T>,
],
order_by: [`${orderPrefix}${sorting.propertyName as string}` as PropertyPath<T>],
};
}
}
2 changes: 2 additions & 0 deletions src/app/pages/audit/audit.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { AsyncPipe, NgTemplateOutlet } from '@angular/common';
import { NgModule } from '@angular/core';
import { ReactiveFormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatButtonToggleModule } from '@angular/material/button-toggle';
import { MatCardModule } from '@angular/material/card';
import { MatSelectModule } from '@angular/material/select';
import { MatTooltipModule } from '@angular/material/tooltip';
Expand Down Expand Up @@ -53,6 +54,7 @@ import { MetadataDetailsCardComponent } from './components/metadata-details-card
IxTableCellDirective,
IxTablePagerComponent,
IxTableHeadComponent,
MatButtonToggleModule,
],
exports: [],
declarations: [
Expand Down
9 changes: 9 additions & 0 deletions src/app/pages/audit/components/audit/audit.component.html
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
<ix-page-header>
@if (isHaLicensed()) {
<p>{{ 'Controller' | translate }}</p>
<mat-button-toggle-group [value]="controllerType()" (change)="controllerTypeChanged($event)">
<mat-button-toggle [value]="ControllerType.Active">{{ 'Active' | translate }}</mat-button-toggle>
<mat-button-toggle [value]="ControllerType.Standby">{{ 'Standby' | translate }}</mat-button-toggle>
</mat-button-toggle-group>
}

<a ixTest="audit-settings" mat-button fragment="audit-card" [routerLink]="['/system/advanced']">
{{ 'Audit Settings' | translate }}
</a>
Expand Down Expand Up @@ -106,6 +114,7 @@
[searchQuery]="searchQuery"
[sorting]="dataProvider.sorting"
[defaultFilters]="basicQueryFilters"
[controllerType]="controllerType()"
></ix-export-button>
}
</ng-template>
Expand Down
8 changes: 8 additions & 0 deletions src/app/pages/audit/components/audit/audit.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@
}

:host ::ng-deep {
.header-container .title-container .actions-container {
gap: 0 16px;

p {
margin: 0;
}
}

ix-empty-row {
height: 100%;
}
Expand Down
24 changes: 24 additions & 0 deletions src/app/pages/audit/components/audit/audit.component.spec.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatButtonToggleChange, MatButtonToggleModule } from '@angular/material/button-toggle';
import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest';
import { provideMockStore } from '@ngrx/store/testing';
import { MockComponents } from 'ng-mocks';
import { mockCall, mockWebSocket } from 'app/core/testing/utils/mock-websocket.utils';
import { AuditEvent, AuditService } from 'app/enums/audit.enum';
import { ControllerType } from 'app/enums/controller-type.enum';
import { AdvancedConfig } from 'app/interfaces/advanced-config.interface';
import { AuditEntry } from 'app/interfaces/audit/audit.interface';
import { ExportButtonComponent } from 'app/modules/buttons/export-button/export-button.component';
Expand All @@ -17,6 +19,7 @@ import { AuditComponent } from 'app/pages/audit/components/audit/audit.component
import { LogDetailsPanelComponent } from 'app/pages/audit/components/log-details-panel/log-details-panel.component';
import { LocaleService } from 'app/services/locale.service';
import { WebSocketService } from 'app/services/ws.service';
import { selectIsHaLicensed } from 'app/store/ha-info/ha-info.selectors';
import { selectAdvancedConfig } from 'app/store/system-config/system-config.selectors';

describe('AuditComponent', () => {
Expand Down Expand Up @@ -63,6 +66,7 @@ describe('AuditComponent', () => {
imports: [
SearchInputModule,
IxTableCellDirective,
MatButtonToggleModule,
],
declarations: [
MockComponents(
Expand All @@ -89,6 +93,10 @@ describe('AuditComponent', () => {
]),
provideMockStore({
selectors: [
{
selector: selectIsHaLicensed,
value: true,
},
{
selector: selectAdvancedConfig,
value: {
Expand Down Expand Up @@ -140,6 +148,21 @@ describe('AuditComponent', () => {
[{
'query-filters': [['OR', [['event', '~', '(?i)search'], ['username', '~', '(?i)search'], ['service', '~', '(?i)search']]]],
'query-options': { limit: 50, offset: 0, order_by: ['-message_timestamp'] },
remote_controller: false,
}],
);
});

it('runs search when controller type is changed', () => {
spectator.component.controllerTypeChanged({ value: ControllerType.Standby } as MatButtonToggleChange);
spectator.detectChanges();

expect(websocket.call).toHaveBeenLastCalledWith(
'audit.query',
[{
'query-filters': [],
'query-options': { limit: 50, offset: 0, order_by: ['-message_timestamp'] },
remote_controller: true,
}],
);
});
Expand All @@ -160,6 +183,7 @@ describe('AuditComponent', () => {
[{
'query-filters': [['event', '=', 'Authentication'], ['username', '~', 'bob']],
'query-options': { limit: 50, offset: 0, order_by: ['-message_timestamp'] },
remote_controller: false,
}],
);
});
Expand Down
36 changes: 33 additions & 3 deletions src/app/pages/audit/components/audit/audit.component.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import { BreakpointObserver, BreakpointState, Breakpoints } from '@angular/cdk/layout';
import {
ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, OnDestroy, OnInit,
ChangeDetectionStrategy, ChangeDetectorRef, Component, effect, Inject, OnDestroy, OnInit,
signal,
} from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { MatButtonToggleChange } from '@angular/material/button-toggle';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { ActivatedRoute } from '@angular/router';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { Store } from '@ngrx/store';
import { TranslateService } from '@ngx-translate/core';
import { toSvg } from 'jdenticon';
import {
Expand All @@ -15,6 +19,7 @@ import {
import {
AuditEvent, AuditService, auditEventLabels, auditServiceLabels,
} from 'app/enums/audit.enum';
import { ControllerType } from 'app/enums/controller-type.enum';
import { ParamsBuilder } from 'app/helpers/params-builder/params-builder.class';
import { WINDOW } from 'app/helpers/window.helper';
import { AuditEntry, AuditQueryParams } from 'app/interfaces/audit/audit.interface';
Expand Down Expand Up @@ -45,6 +50,8 @@ import { AuditApiDataProvider } from 'app/pages/audit/utils/audit-api-data-provi
import { getLogImportantData } from 'app/pages/audit/utils/get-log-important-data.utils';
import { UrlOptionsService } from 'app/services/url-options.service';
import { WebSocketService } from 'app/services/ws.service';
import { AppsState } from 'app/store';
import { selectIsHaLicensed } from 'app/store/ha-info/ha-info.selectors';

@UntilDestroy()
@Component({
Expand All @@ -55,8 +62,11 @@ import { WebSocketService } from 'app/services/ws.service';
})
export class AuditComponent implements OnInit, OnDestroy {
protected readonly searchableElements = auditElements;
protected readonly controllerType = signal<ControllerType>(ControllerType.Active);

protected dataProvider: AuditApiDataProvider;
protected readonly isHaLicensed = toSignal(this.store$.select(selectIsHaLicensed));
protected readonly ControllerType = ControllerType;
protected readonly advancedSearchPlaceholder = this.translate.instant('Service = "SMB" AND Event = "CLOSE"');
showMobileDetails = false;
isMobileView = false;
Expand Down Expand Up @@ -97,7 +107,7 @@ export class AuditComponent implements OnInit, OnDestroy {
getValue: (row) => this.translate.instant(this.getEventDataForLog(row)),
}),
], {
uniqueRowTag: (row) => 'audit-' + row.service + '-' + row.username + '-' + row.event,
uniqueRowTag: (row) => `audit-${row.service}-${row.username}-${row.event}-${row.audit_id}`,
ariaLabels: (row) => [row.service, row.username, row.event, this.translate.instant('Audit Entry')],
});

Expand All @@ -120,8 +130,16 @@ export class AuditComponent implements OnInit, OnDestroy {
private sanitizer: DomSanitizer,
private activatedRoute: ActivatedRoute,
private urlOptionsService: UrlOptionsService,
private store$: Store<AppsState>,
@Inject(WINDOW) private window: Window,
) {}
) {
effect(() => {
this.dataProvider.selectedControllerType = this.controllerType();
this.dataProvider.isHaLicensed = this.isHaLicensed();

this.dataProvider.load();
});
}

ngOnInit(): void {
this.dataProvider = new AuditApiDataProvider(this.ws);
Expand Down Expand Up @@ -220,6 +238,18 @@ export class AuditComponent implements OnInit, OnDestroy {
return this.sanitizer.bypassSecurityTrustHtml(toSvg(row.username, this.isMobileView ? 15 : 35));
}

controllerTypeChanged(changedValue: MatButtonToggleChange): void {
if (this.controllerType() === changedValue.value) {
return;
}

if (this.controllerType() === ControllerType.Active && changedValue.value === ControllerType.Standby) {
this.controllerType.set(ControllerType.Standby);
} else {
this.controllerType.set(ControllerType.Active);
}
}

private initMobileView(): void {
this.breakpointObserver
.observe([Breakpoints.XSmall, Breakpoints.Small, Breakpoints.Medium])
Expand Down
Loading

0 comments on commit 6708d3d

Please sign in to comment.