491 [FEAT] Allow disks to be hidden/archived

- Add archived to device type & db
- Add archive/unarchive handlers to webapp backend
- Add archive toggle & styling to webapp frontend
This commit is contained in:
Sam
2025-02-21 09:23:48 +01:00
parent a58f9445c1
commit 600cd153e0
24 changed files with 390 additions and 25 deletions
@@ -1,5 +1,6 @@
// maps to webapp/backend/pkg/models/device.go
export interface DeviceModel {
archived: boolean;
wwn: string;
device_name?: string;
device_uuid?: string;
@@ -0,0 +1,11 @@
<h2 mat-dialog-title>Archive {{data.title}}?</h2>
<mat-dialog-content>This will remove the device from Scrutiny dashboard, unless you toggle show archived. <strong>Any data about the device
itself will remain untouched.</strong></mat-dialog-content>
<mat-dialog-actions>
<button mat-button mat-dialog-close>Cancel</button>
<button class="yellow-600" mat-button (click)="onArchiveClick()">
<mat-icon class="icon-size-20 mr-3"
[svgIcon]="'archive'"></mat-icon>
Archive
</button>
</mat-dialog-actions>
@@ -0,0 +1,64 @@
import {async, ComponentFixture, TestBed} from '@angular/core/testing';
import {DashboardDeviceArchiveDialogComponent} from './dashboard-device-archive-dialog.component';
import {HttpClientModule} from '@angular/common/http';
import {MAT_DIALOG_DATA, MatDialogModule, MatDialogRef} from '@angular/material/dialog';
import {MatButtonModule} from '@angular/material/button';
import {MatIconModule} from '@angular/material/icon';
import {SharedModule} from '../../../shared/shared.module';
import {DashboardDeviceArchiveDialogService} from './dashboard-device-archive-dialog.service';
import {of} from 'rxjs';
describe('DashboardDeviceArchiveDialogComponent', () => {
let component: DashboardDeviceArchiveDialogComponent;
let fixture: ComponentFixture<DashboardDeviceArchiveDialogComponent>;
const matDialogRefSpy = jasmine.createSpyObj('MatDialogRef', ['closeDialog', 'close']);
const dashboardDeviceArchiveDialogServiceSpy = jasmine.createSpyObj('DashboardDeviceArchiveDialogService', ['archiveDevice']);
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [
HttpClientModule,
MatDialogModule,
MatButtonModule,
MatIconModule,
SharedModule,
],
providers: [
{provide: MatDialogRef, useValue: matDialogRefSpy},
{provide: MAT_DIALOG_DATA, useValue: {wwn: 'test-wwn', title: 'my-test-device-title'}},
{provide: DashboardDeviceArchiveDialogService, useValue: dashboardDeviceArchiveDialogServiceSpy}
],
declarations: [DashboardDeviceArchiveDialogComponent]
})
.compileComponents()
}));
beforeEach(() => {
fixture = TestBed.createComponent(DashboardDeviceArchiveDialogComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
it('should close the component if cancel is clicked', () => {
matDialogRefSpy.closeDialog.calls.reset();
matDialogRefSpy.closeDialog()
expect(matDialogRefSpy.closeDialog).toHaveBeenCalled();
});
it('should attempt to archive device if archive is clicked', () => {
dashboardDeviceArchiveDialogServiceSpy.archiveDevice.and.returnValue(of({'success': true}));
component.onArchiveClick()
expect(dashboardDeviceArchiveDialogServiceSpy.archiveDevice).toHaveBeenCalledWith('test-wwn');
expect(dashboardDeviceArchiveDialogServiceSpy.archiveDevice.calls.count())
.withContext('one call')
.toBe(1);
});
});
@@ -0,0 +1,29 @@
import {Component, Inject, OnInit} from '@angular/core';
import {MAT_DIALOG_DATA, MatDialogRef} from '@angular/material/dialog';
import {DashboardDeviceArchiveDialogService} from 'app/layout/common/dashboard-device-archive-dialog/dashboard-device-archive-dialog.service';
@Component({
selector: 'app-dashboard-device-archive-dialog',
templateUrl: './dashboard-device-archive-dialog.component.html',
styleUrls: ['./dashboard-device-archive-dialog.component.scss'],
})
export class DashboardDeviceArchiveDialogComponent implements OnInit {
constructor(
public dialogRef: MatDialogRef<DashboardDeviceArchiveDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: {wwn: string, title: string},
private _archiveService: DashboardDeviceArchiveDialogService,
) {
}
ngOnInit(): void {
}
onArchiveClick(): void {
this._archiveService.archiveDevice(this.data.wwn)
.subscribe((data) => {
this.dialogRef.close(data);
});
}
}
@@ -0,0 +1,29 @@
import {NgModule} from '@angular/core';
import {RouterModule} from '@angular/router';
import {MatButtonModule} from '@angular/material/button';
import {MatIconModule} from '@angular/material/icon';
import {SharedModule} from 'app/shared/shared.module';
import {dashboardRoutes} from 'app/modules/dashboard/dashboard.routing';
import {MatDialogModule} from '@angular/material/dialog';
import {DashboardDeviceArchiveDialogComponent} from './dashboard-device-archive-dialog.component';
@NgModule({
declarations: [
DashboardDeviceArchiveDialogComponent
],
imports: [
RouterModule.forChild([]),
RouterModule.forChild(dashboardRoutes),
MatButtonModule,
MatIconModule,
SharedModule,
MatDialogModule
],
exports : [
DashboardDeviceArchiveDialogComponent,
],
providers : []
})
export class DashboardDeviceArchiveDialogModule
{
}
@@ -0,0 +1,38 @@
import {Injectable} from '@angular/core';
import {HttpClient} from '@angular/common/http';
import {Observable} from 'rxjs';
import {getBasePath} from 'app/app.routing';
@Injectable({
providedIn: 'root'
})
export class DashboardDeviceArchiveDialogService
{
/**
* Constructor
*
* @param {HttpClient} _httpClient
*/
constructor(
private _httpClient: HttpClient
)
{
}
// -----------------------------------------------------------------------------------------------------
// @ Public methods
// -----------------------------------------------------------------------------------------------------
archiveDevice(wwn: string): Observable<any>
{
return this._httpClient.post( `${getBasePath()}/api/device/${wwn}/archive`, {});
}
unarchiveDevice(wwn: string): Observable<any>
{
return this._httpClient.post( `${getBasePath()}/api/device/${wwn}/unarchive`, {});
}
}
@@ -1,5 +1,7 @@
<div [ngClass]="{ 'border-green': deviceStatusForModelWithThreshold(deviceSummary.device, !!deviceSummary.smart, config.metrics.status_threshold) == 'passed',
'border-red': deviceStatusForModelWithThreshold(deviceSummary.device, !!deviceSummary.smart, config.metrics.status_threshold) == 'failed' }"
<div
[ngClass]="{ 'border-green': deviceStatusForModelWithThreshold(deviceSummary.device, !!deviceSummary.smart, config.metrics.status_threshold) == 'passed',
'border-red': deviceStatusForModelWithThreshold(deviceSummary.device, !!deviceSummary.smart, config.metrics.status_threshold) == 'failed',
'text-disabled': deviceSummary.device.archived }"
class="relative flex flex-col flex-auto p-6 pr-3 pb-3 bg-card rounded border-l-4 shadow-md overflow-hidden">
<div class="absolute bottom-0 right-0 w-24 h-24 -m-6">
<mat-icon class="icon-size-96 opacity-12 text-green"
@@ -21,6 +23,8 @@
</div>
</div>
<div class="ml-auto" *ngIf="deviceSummary.device">
<mat-icon *ngIf="deviceSummary.device.archived"
[svgIcon]="'archive'"></mat-icon>
<button mat-icon-button
[matMenuTriggerFor]="previousStatementMenu">
<mat-icon [svgIcon]="'more_vert'"></mat-icon>
@@ -33,6 +37,14 @@
<span>View Details</span>
</span>
</a>
<a mat-menu-item
(click)="openArchiveDialog()">
<span class="flex items-center">
<mat-icon class="icon-size-20 mr-3"
[svgIcon]="deviceSummary.device.archived ? 'unarchive':'archive'"></mat-icon>
<span>{{deviceSummary.device.archived ? "Unarchive" : "Archive"}} Device</span>
</span>
</a>
<a mat-menu-item (click)="openDeleteDialog()">
<span class="flex items-center">
<mat-icon class="icon-size-20 mr-3"
@@ -0,0 +1,3 @@
.text-disabled{
opacity: 0.8;
}
@@ -9,6 +9,8 @@ import {DashboardDeviceDeleteDialogComponent} from 'app/layout/common/dashboard-
import {DeviceTitlePipe} from 'app/shared/device-title.pipe';
import {DeviceSummaryModel} from 'app/core/models/device-summary-model';
import {DeviceStatusPipe} from 'app/shared/device-status.pipe';
import {DashboardDeviceArchiveDialogComponent} from '../dashboard-device-archive-dialog/dashboard-device-archive-dialog.component';
import {DashboardDeviceArchiveDialogService} from '../dashboard-device-archive-dialog/dashboard-device-archive-dialog.service';
@Component({
selector: 'app-dashboard-device',
@@ -19,6 +21,7 @@ export class DashboardDeviceComponent implements OnInit {
constructor(
private _configService: ScrutinyConfigService,
private _archiveService: DashboardDeviceArchiveDialogService,
public dialog: MatDialog,
) {
// Set the private defaults
@@ -26,7 +29,8 @@ export class DashboardDeviceComponent implements OnInit {
}
@Input() deviceSummary: DeviceSummaryModel;
@Input() deviceWWN: string;
@Output() deviceArchived = new EventEmitter<string>();
@Output() deviceUnarchived = new EventEmitter<string>();
@Output() deviceDeleted = new EventEmitter<string>();
config: AppConfig;
@@ -69,11 +73,33 @@ export class DashboardDeviceComponent implements OnInit {
}
}
openArchiveDialog(): void {
if(this.deviceSummary.device.archived){
this._archiveService.unarchiveDevice(this.deviceSummary.device.wwn).subscribe((result) => {
if(result) {
this.deviceUnarchived.emit(this.deviceSummary.device.wwn)
}
})
return;
}
const dialogRef = this.dialog.open(DashboardDeviceArchiveDialogComponent, {
data: {
wwn: this.deviceSummary.device.wwn,
title: DeviceTitlePipe.deviceTitleWithFallback(this.deviceSummary.device, this.config.dashboard_display)
}
});
dialogRef.afterClosed().subscribe(result => {
if(result) {
this.deviceArchived.emit(this.deviceSummary.device.wwn);
}
})
}
openDeleteDialog(): void {
const dialogRef = this.dialog.open(DashboardDeviceDeleteDialogComponent, {
// width: '250px',
data: {
wwn: this.deviceWWN,
wwn: this.deviceSummary.device.wwn,
title: DeviceTitlePipe.deviceTitleWithFallback(this.deviceSummary.device, this.config.dashboard_display)
}
});
@@ -81,7 +107,7 @@ export class DashboardDeviceComponent implements OnInit {
dialogRef.afterClosed().subscribe(result => {
console.log('The dialog was closed', result);
if (result.success) {
this.deviceDeleted.emit(this.deviceWWN)
this.deviceDeleted.emit(this.deviceSummary.device.wwn)
}
});
}
@@ -7,6 +7,7 @@ import {DashboardDeviceComponent} from 'app/layout/common/dashboard-device/dashb
import {dashboardRoutes} from '../../../modules/dashboard/dashboard.routing';
import {MatMenuModule} from '@angular/material/menu';
import {DashboardDeviceDeleteDialogModule} from 'app/layout/common/dashboard-device-delete-dialog/dashboard-device-delete-dialog.module';
import {DashboardDeviceArchiveDialogModule} from '../dashboard-device-archive-dialog/dashboard-device-archive-dialog.module';
@NgModule({
declarations: [
@@ -19,7 +20,8 @@ import {DashboardDeviceDeleteDialogModule} from 'app/layout/common/dashboard-dev
MatIconModule,
MatMenuModule,
SharedModule,
DashboardDeviceDeleteDialogModule
DashboardDeviceDeleteDialogModule,
DashboardDeviceArchiveDialogModule
],
exports: [
DashboardDeviceComponent,
@@ -1,4 +1,3 @@
<div *ngIf="summaryData; else emptyDashboard">
<div class="flex flex-col flex-auto w-full p-8 xs:p-2">
@@ -11,7 +10,15 @@
</div>
<!-- Action buttons -->
<div class="flex items-center">
<button matTooltip="not yet implemented" class="xs:hidden" mat-stroked-button>
<button class="xs:hidden" mat-stroked-button
[color]="showArchived ? 'primary' : null"
(click)="showArchived=!showArchived">
<mat-icon class="icon-size-20"
[color]="showArchived ? 'primary' : null"
[svgIcon]="'archive'"></mat-icon>
<span class="ml-2">Archived</span>
</button>
<button matTooltip="not yet implemented" class="ml-2 xs:hidden" mat-stroked-button>
<mat-icon class="icon-size-20"
[svgIcon]="'save'"></mat-icon>
<span class="ml-2">Export</span>
@@ -31,6 +38,12 @@
<mat-icon [svgIcon]="'more_vert'"></mat-icon>
</button>
<mat-menu #actionsMenu="matMenu">
<button mat-menu-item (click)="showArchived=!showArchived">
<mat-icon class="icon-size-20"
[color]="showArchived ? 'primary' : null"
[svgIcon]="'archive'"></mat-icon>
<span class="ml-2">Archived</span>
</button>
<button mat-menu-item
matTooltip="not yet implemented">
<mat-icon class="icon-size-20"
@@ -49,13 +62,16 @@
<div class="flex flex-wrap w-full" *ngFor="let hostId of hostGroups | keyvalue">
<h3 class="ml-4" *ngIf="hostId.key">{{hostId.key}}</h3>
<h3 class="ml-4" *ngIf="hostId.key">{{ hostId.key }}</h3>
<div class="flex flex-wrap w-full">
<app-dashboard-device (deviceDeleted)="onDeviceDeleted($event)"
class="flex gt-sm:w-1/2 min-w-80 p-4"
*ngFor="let deviceSummary of (deviceSummariesForHostGroup(hostId.value) | deviceSort:config.dashboard_sort:config.dashboard_display )"
[deviceWWN]="deviceSummary.device.wwn"
[deviceSummary]="deviceSummary"></app-dashboard-device>
<ng-container *ngFor="let deviceSummary of (deviceSummariesForHostGroup(hostId.value) | deviceSort:config.dashboard_sort:config.dashboard_display )">
<app-dashboard-device *ngIf="showArchived || !deviceSummary.device.archived"
(deviceArchived)="onDeviceArchived($event)"
(deviceUnarchived)="onDeviceUnarchived($event)"
(deviceDeleted)="onDeviceDeleted($event)"
class="flex gt-sm:w-1/2 min-w-80 p-4"
[deviceSummary]="deviceSummary"></app-dashboard-device>
</ng-container>
</div>
</div>
@@ -67,13 +83,13 @@
<div class="flex items-center justify-between">
<div class="flex flex-col">
<div class="font-bold text-md text-secondary uppercase tracking-wider mr-4">Temperature</div>
<div class="text-sm text-hint font-medium">Temperature history for each device </div>
<div class="text-sm text-hint font-medium">Temperature history for each device</div>
</div>
<div>
<button class="h-8 min-h-8 px-2"
mat-button
[matMenuTriggerFor]="tempRangeMenu">
<span class="font-medium text-sm text-hint">{{tempDurationKey}}</span>
<span class="font-medium text-sm text-hint">{{ tempDurationKey }}</span>
</button>
<mat-menu #tempRangeMenu="matMenu">
<button (click)="changeSummaryTempDuration('forever')" mat-menu-item>forever</button>
@@ -109,7 +125,8 @@
src="assets/images/dashboard-placeholder.png">
<h1>No Devices Detected!</h1>
<p style="max-width:700px;">Scrutiny includes a Collector agent that you must run on all of your systems. The Collector is responsible for detecting connected storage devices and collecting S.M.A.R.T data on a configurable schedule.</p>
<p style="max-width:700px;">Scrutiny includes a Collector agent that you must run on all of your systems. The Collector is responsible for detecting connected storage
devices and collecting S.M.A.R.T data on a configurable schedule.</p>
<p><br/>You can trigger the Collector manually by running the following command, then refreshing this page:</p>
<code>scrutiny-collector-metrics run</code>
@@ -34,6 +34,7 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
temperatureOptions: ApexOptions;
tempDurationKey = 'forever'
config: AppConfig;
showArchived: boolean;
// Private
private _unsubscribeAll: Subject<void>;
@@ -248,6 +249,14 @@ export class DashboardComponent implements OnInit, AfterViewInit, OnDestroy
delete this.summaryData[wwn] // remove the device from the summary list.
}
onDeviceArchived(wwn: string): void {
this.summaryData[wwn].device.archived = true;
}
onDeviceUnarchived(wwn: string): void {
this.summaryData[wwn].device.archived = false;
}
/*
DURATION_KEY_WEEK = "week"