Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

NAS-131148 / 25.04 / Audit logs on HA - Add ability to view and export remote controller logs in the WebUI #10746

Merged
merged 2 commits into from
Sep 26, 2024
Merged
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
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
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)">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is similar ix-button-group that accepts options$

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@denysbutenko we may add a new ticket to convert all of these to use <ix-button-group /> then.

Screenshot 2024-09-26 at 13 44 35

<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
Loading