Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
4f5b94c
NAS-139093: fix: mutating argument causing error on rsync tasks card
mattwyatt-ix Jan 2, 2026
ec237dd
NAS-139093: feat: add auto-expand to jobs page and improve details on…
mattwyatt-ix Jan 2, 2026
be603b3
NAS-139093: fix: race condition and old `untilDestroyed` pattern in j…
mattwyatt-ix Jan 2, 2026
57a1e87
NAS-139093: fix: don't override already expanded rows
mattwyatt-ix Jan 2, 2026
624d4a2
NAS-139093: fix: prevent frequent updates from re-triggering auto-expand
mattwyatt-ix Jan 2, 2026
037ba4d
NAS-139093: test: fix success/failure mismatch
mattwyatt-ix Jan 2, 2026
381e8b7
NAS-139093: test: update error dialog test with new params functionality
mattwyatt-ix Jan 2, 2026
e065c5c
NAS-139093: test: improve test coverage for `ix-cell-state-button`
mattwyatt-ix Jan 5, 2026
c1c2a81
NAS-139093: feat: implement view/download for errors handled via `sho…
mattwyatt-ix Jan 5, 2026
5720adb
NAS-139093: test: fix tests for jobs panel
mattwyatt-ix Jan 5, 2026
e29b2cf
NAS-139093: feat: improve UX on jobs list page
mattwyatt-ix Jan 6, 2026
a16c5de
NAS-139093: fix: misc. fixes
mattwyatt-ix Jan 6, 2026
067a1fd
NAS-139093: fix: remove impossible branch in `onRowExpanded`
mattwyatt-ix Jan 6, 2026
1bc070a
NAS-139093: fix: replace `first()` by `take(1)` to avoid observable c…
mattwyatt-ix Jan 6, 2026
f3f92ae
NAS-139093: fix: format error messages correctly
mattwyatt-ix Jan 6, 2026
0b7f37c
NAS-139093: fix: pipe `takeUntilDestroyed` to `combineLatest` in jobs…
mattwyatt-ix Jan 6, 2026
87fb5c7
NAS-139093: test: improve coverage
mattwyatt-ix Jan 6, 2026
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
2 changes: 2 additions & 0 deletions src/app/interfaces/error-report.interface.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { Params } from '@angular/router';
import { Job } from 'app/interfaces/job.interface';

export const traceDetailLabel = 'Trace';

export interface ErrorReportAction {
label: string;
route?: string;
params?: Params;
action?: () => void;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,7 @@ describe('ErrorDialog', () => {
const actionButton = await loader.getHarness(MatButtonHarness.with({ text: 'Network Settings' }));
await actionButton.click();

expect(router.navigate).toHaveBeenCalledWith(['/system/network']);
expect(router.navigate).toHaveBeenCalledWith(['/system/network'], { queryParams: undefined });
expect(dialogRef.close).toHaveBeenCalled();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export class ErrorDialog {

protected handleAction(action: ErrorReportAction): void {
if (action.route) {
this.router.navigate([action.route]);
this.router.navigate([action.route], { queryParams: action.params });
this.dialogRef.close();
} else if (action.action) {
action.action();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { DialogService } from 'app/modules/dialog/dialog.service';
import { IxIconHarness } from 'app/modules/ix-icon/ix-icon.harness';
import { IxCellStateButtonComponent } from 'app/modules/ix-table/components/ix-table-body/cells/ix-cell-state-button/ix-cell-state-button.component';
import { selectJobs } from 'app/modules/jobs/store/job.selectors';
import { ErrorHandlerService } from 'app/services/errors/error-handler.service';
import { FailedJobError } from 'app/services/errors/error.classes';

interface TestTableData {
state: JobState;
Expand Down Expand Up @@ -120,4 +122,25 @@ describe('IxCellStateButtonComponent', () => {
const button = spectator.query('button')!;
expect(button.getAttribute('aria-label')).toBe('Label 1 Label 2');
});

it('calls showErrorModal when storing a failed job', async () => {
const job = {
id: 123456,
logs_excerpt: 'failed',
state: JobState.Failed,
error: 'failed',
};

spectator.component.setRow({
state: job.state,
job,
warnings: [{}, {}],
} as TestTableData);

const button = await loader.getHarness(MatButtonHarness);
await button.click();

const expectedError = new FailedJobError(job as Job);
expect(spectator.inject(ErrorHandlerService).showErrorModal).toHaveBeenCalledWith(expectedError);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { JobSlice, selectJob } from 'app/modules/jobs/store/job.selectors';
import { JobStateDisplayPipe } from 'app/modules/pipes/job-state-display/job-state-display.pipe';
import { TestDirective } from 'app/modules/test-id/test.directive';
import { ErrorHandlerService } from 'app/services/errors/error-handler.service';
import { FailedJobError } from 'app/services/errors/error.classes';

interface RowState {
state: {
Expand Down Expand Up @@ -142,7 +143,8 @@ export class IxCellStateButtonComponent<T> extends ColumnComponent<T> implements
}

if (state.error) {
this.dialogService.error({ title: state.state, message: `<pre>${state.error}</pre>` });
const error: FailedJobError = new FailedJobError(this.job());
this.errorHandler.showErrorModal(error);
return;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,14 @@ describe('JobsPanelComponent', () => {
spectator.click(byText('replication.run'));

expect(spectator.inject(DialogService).error).toHaveBeenCalledWith({
message: 'Some error',
message: '<pre>Some error</pre>',
title: 'FAILED',
stackTrack: undefined,
actions: [{
label: 'View Details',
params: { jobId: failedJob.id },
route: '/jobs',
}],
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { mockAuth } from 'app/core/testing/utils/mock-auth.utils';
import { Direction } from 'app/enums/direction.enum';
import { JobState } from 'app/enums/job-state.enum';
import { RsyncMode } from 'app/enums/rsync-mode.enum';
import { Job } from 'app/interfaces/job.interface';
import { RsyncTaskUi } from 'app/interfaces/rsync-task.interface';
import { DialogService } from 'app/modules/dialog/dialog.service';
import { IxTableHarness } from 'app/modules/ix-table/components/ix-table/ix-table.harness';
Expand Down Expand Up @@ -47,7 +46,7 @@ describe('RsyncTaskCardComponent', () => {
delayupdates: true,
job: {
id: 1,
state: JobState.Success,
state: JobState.Failed,
time_finished: {
$date: new Date().getTime() - 50000,
},
Expand Down Expand Up @@ -77,13 +76,7 @@ describe('RsyncTaskCardComponent', () => {
selectors: [
{
selector: selectJobs,
value: [{
id: 1,
state: JobState.Success,
time_finished: {
$date: new Date().getTime() - 50000,
},
} as unknown as Job],
value: rsyncTasks.map((task) => task.job),
},
{
selector: selectSystemConfigState,
Expand Down Expand Up @@ -123,7 +116,7 @@ describe('RsyncTaskCardComponent', () => {
it('should show table rows', async () => {
const expectedRows = [
['Path', 'Remote Host', 'Frequency', 'Next Run', 'Last Run', 'Enabled', 'State', ''],
['/mnt/APPS', 'asd', 'Every hour, every day', 'Disabled', '1 min. ago', '', 'Completed', ''],
['/mnt/APPS', 'asd', 'Every hour, every day', 'Disabled', '1 min. ago', '', 'Failed', ''],
];

const cells = await table.getCellTexts();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,14 @@ export class RsyncTaskCardComponent implements OnInit {
}

private transformRsyncTasks(rsyncTasks: RsyncTaskUi[]): RsyncTaskUi[] {
return rsyncTasks.map((task: RsyncTaskUi) => {
return rsyncTasks.map((rsyncTask: RsyncTaskUi) => {
// make sure we deep-copy `state` and `job` so we aren't overriding the originals
// when we mutate `task`.
const task: RsyncTaskUi = {
...rsyncTask,
state: { ...rsyncTask.state },
job: { ...rsyncTask.job },
};
if (task.job === null) {
task.state = { state: task.locked ? TaskState.Locked : TaskState.Pending };
} else {
Expand Down
1 change: 1 addition & 0 deletions src/app/pages/jobs/jobs-list.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
[columns]="columns"
[dataProvider]="dataProvider"
[isLoading]="!!(isLoading$ | async)"
(expanded)="onRowExpanded($event)"
>
<ng-template
let-job
Expand Down
61 changes: 61 additions & 0 deletions src/app/pages/jobs/jobs-list.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { HarnessLoader } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { MatButtonHarness } from '@angular/material/button/testing';
import { MatSnackBar } from '@angular/material/snack-bar';
import { Router, ActivatedRoute } from '@angular/router';
import { createComponentFactory, mockProvider, Spectator } from '@ngneat/spectator/jest';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { MockComponent } from 'ng-mocks';
Expand All @@ -12,6 +13,7 @@ import { Job } from 'app/interfaces/job.interface';
import { DialogService } from 'app/modules/dialog/dialog.service';
import { IxEmptyRowHarness } from 'app/modules/ix-table/components/ix-empty-row/ix-empty-row.component.harness';
import { IxTableHarness } from 'app/modules/ix-table/components/ix-table/ix-table.harness';
import { IxRowHarness } from 'app/modules/ix-table/components/ix-table/row.harness';
import { jobsInitialState, JobsState } from 'app/modules/jobs/store/job.reducer';
import { selectJobs, selectJobState } from 'app/modules/jobs/store/job.selectors';
import { LocaleService } from 'app/modules/language/locale.service';
Expand Down Expand Up @@ -64,6 +66,9 @@ describe('JobsListComponent', () => {
}),
mockProvider(DialogService),
mockProvider(MatSnackBar),
mockProvider(ActivatedRoute, {
queryParams: of({}),
}),
mockApi([
mockCall('core.job_download_logs', 'http://localhost/download/log'),
]),
Expand Down Expand Up @@ -126,4 +131,60 @@ describe('JobsListComponent', () => {

expect(spectator.queryAll('.expanded')).toHaveLength(1);
});

it('should auto-expand row when jobId query parameter is provided', () => {
const mockActivatedRoute = spectator.inject(ActivatedRoute);
mockActivatedRoute.queryParams = of({ jobId: '446' });

store$.overrideSelector(selectJobs, fakeJobDataSource);
store$.refreshState();
spectator.component.ngOnInit();
spectator.detectChanges();

expect(spectator.queryAll('.expanded')).toHaveLength(1);
expect(spectator.query('.expanded')).toContainText('cloudsync.sync');
});

it('should not expand any row when jobId query parameter does not match any job', () => {
const mockActivatedRoute = spectator.inject(ActivatedRoute);
mockActivatedRoute.queryParams = of({ jobId: '999' });

store$.overrideSelector(selectJobs, fakeJobDataSource);
store$.refreshState();
spectator.component.ngOnInit();
spectator.detectChanges();

expect(spectator.queryAll('.expanded')).toHaveLength(0);
});

it('should not expand any row when no jobId query parameter is provided', () => {
const mockActivatedRoute = spectator.inject(ActivatedRoute);
mockActivatedRoute.queryParams = of({});

store$.overrideSelector(selectJobs, fakeJobDataSource);
store$.refreshState();
spectator.component.ngOnInit();
spectator.detectChanges();

expect(spectator.queryAll('.expanded')).toHaveLength(0);
});

it('sets URL parameters when a row is expanded', async () => {
const route = spectator.inject(ActivatedRoute);

const router = spectator.inject(Router);
const navigateSpy = jest.spyOn(router, 'navigate');
store$.overrideSelector(selectJobs, fakeJobDataSource);
store$.refreshState();

const firstRow = await loader.getHarness(IxRowHarness);
const firstRowButton = await firstRow.getHarness(MatButtonHarness.with({ selector: '[ixTest="toggle-row"]' }));
await firstRowButton.click();

expect(navigateSpy).toHaveBeenCalledWith([], {
relativeTo: route,
queryParams: { jobId: fakeJobDataSource[0].id },
queryParamsHandling: 'merge',
});
});
});
60 changes: 55 additions & 5 deletions src/app/pages/jobs/jobs-list.component.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { AsyncPipe } from '@angular/common';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, inject, signal } from '@angular/core';
import { DestroyRef, ChangeDetectionStrategy, ChangeDetectorRef, Component, OnInit, inject, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { MatButtonToggleGroup, MatButtonToggle } from '@angular/material/button-toggle';
import { UntilDestroy, untilDestroyed } from '@ngneat/until-destroy';
import { Router, ActivatedRoute } from '@angular/router';
import { Store } from '@ngrx/store';
import { TranslateService, TranslateModule } from '@ngx-translate/core';
import {
BehaviorSubject, combineLatest, Observable, of,
} from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { take, map, switchMap } from 'rxjs/operators';
import { UiSearchDirective } from 'app/directives/ui-search.directive';
import { EmptyType } from 'app/enums/empty-type.enum';
import { Job } from 'app/interfaces/job.interface';
Expand Down Expand Up @@ -39,7 +40,6 @@ import { JobNameComponent } from 'app/pages/jobs/job-name/job-name.component';
import { JobTab } from 'app/pages/jobs/job-tab.enum';
import { jobsListElements } from 'app/pages/jobs/jobs-list.elements';

@UntilDestroy()
@Component({
selector: 'ix-jobs-list',
templateUrl: './jobs-list.component.html',
Expand Down Expand Up @@ -69,6 +69,9 @@ export class JobsListComponent implements OnInit {
private translate = inject(TranslateService);
private store$ = inject<Store<JobSlice>>(Store);
private cdr = inject(ChangeDetectorRef);
private route = inject(ActivatedRoute);
private router = inject(Router);
private readonly destroyRef = inject(DestroyRef);

protected readonly searchableElements = jobsListElements;

Expand Down Expand Up @@ -127,12 +130,50 @@ export class JobsListComponent implements OnInit {
);

ngOnInit(): void {
this.selectedJobs$.pipe(untilDestroyed(this)).subscribe((jobs) => {
const jobsTrigger$ = this.selectedJobs$.pipe(
takeUntilDestroyed(this.destroyRef),
);

const queryTrigger$ = this.route.queryParams.pipe(
takeUntilDestroyed(this.destroyRef),
);

// handle jobs changing and update our internal representation inside `this.jobs`
jobsTrigger$.subscribe((jobs) => {
this.jobs = jobs;
this.onListFiltered(this.searchQuery());
this.setDefaultSort();
this.cdr.markForCheck();
});

// handle query updates and expand rows according to URL params.
// we combine `queryTrigger$` with `jobsTrigger$` since, if we
// were to try and run `autoExpandRow` before `this.jobs` was populated, then
// nothing would happen. `combineLatest` is a neat way to ensure that BOTH observables have
// values before doing anything.
//
// the `take(1)` operator is there to ensure that `jobsTrigger$` only ever emits once,
// which will prevent job updates re-triggering row expansion.
combineLatest([jobsTrigger$.pipe(take(1)), queryTrigger$])
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(([_, query]) => {
if (query.jobId) {
const jobId = Number(query.jobId);
if (!Number.isNaN(jobId)) {
this.autoExpandRow(jobId);
}
}

this.cdr.markForCheck();
});
}

protected onRowExpanded(job: Job): void {
this.router.navigate([], {
relativeTo: this.route,
queryParams: { jobId: job.id },
queryParamsHandling: 'merge',
});
}

protected onTabChange(tab: JobTab): void {
Expand All @@ -156,6 +197,15 @@ export class JobsListComponent implements OnInit {
this.dataProvider.setFilter({ list: this.jobs, query, columnKeys: ['method', 'description'] });
}

private autoExpandRow(jobId: number): void {
const jobToExpand = this.jobs.find((job) => job.id === jobId);
if (jobToExpand) {
// set the expanded row and force a re-render
this.dataProvider.expandedRow = jobToExpand;
this.dataProvider.currentPage$.next(this.dataProvider.currentPage$.value);
}
}

private setDefaultSort(): void {
this.dataProvider.setSorting({
active: 1,
Expand Down
Loading
Loading