diff --git a/docs/dbdiagram.io.txt b/docs/dbdiagram.io.txt
index 23265ad..ce1dd83 100644
--- a/docs/dbdiagram.io.txt
+++ b/docs/dbdiagram.io.txt
@@ -2,6 +2,7 @@
// SQLite Table(s)
Table Device {
+ Archived bool
//GORM attributes, see: http://gorm.io/docs/conventions.html
CreatedAt time
UpdatedAt time
diff --git a/webapp/backend/pkg/database/interface.go b/webapp/backend/pkg/database/interface.go
index 8613eae..ee7066e 100644
--- a/webapp/backend/pkg/database/interface.go
+++ b/webapp/backend/pkg/database/interface.go
@@ -20,6 +20,7 @@ type DeviceRepo interface {
UpdateDevice(ctx context.Context, wwn string, collectorSmartData collector.SmartInfo) (models.Device, error)
UpdateDeviceStatus(ctx context.Context, wwn string, status pkg.DeviceStatus) (models.Device, error)
GetDeviceDetails(ctx context.Context, wwn string) (models.Device, error)
+ UpdateDeviceArchived(ctx context.Context, wwn string, archived bool) error
DeleteDevice(ctx context.Context, wwn string) error
SaveSmartAttributes(ctx context.Context, wwn string, collectorSmartData collector.SmartInfo) (measurements.Smart, error)
diff --git a/webapp/backend/pkg/database/migrations/m20220509170100/device.go b/webapp/backend/pkg/database/migrations/m20220509170100/device.go
index 1134fff..c8be222 100644
--- a/webapp/backend/pkg/database/migrations/m20220509170100/device.go
+++ b/webapp/backend/pkg/database/migrations/m20220509170100/device.go
@@ -5,6 +5,7 @@ import (
"time"
)
+// Deprecated: m20220509170100.Device is deprecated, only used by db migrations
type Device struct {
//GORM attributes, see: http://gorm.io/docs/conventions.html
CreatedAt time.Time
@@ -14,9 +15,9 @@ type Device struct {
WWN string `json:"wwn" gorm:"primary_key"`
DeviceName string `json:"device_name"`
- DeviceUUID string `json:"device_uuid"`
- DeviceSerialID string `json:"device_serial_id"`
- DeviceLabel string `json:"device_label"`
+ DeviceUUID string `json:"device_uuid"`
+ DeviceSerialID string `json:"device_serial_id"`
+ DeviceLabel string `json:"device_label"`
Manufacturer string `json:"manufacturer"`
ModelName string `json:"model_name"`
@@ -38,4 +39,3 @@ type Device struct {
// Data set by Scrutiny
DeviceStatus pkg.DeviceStatus `json:"device_status"`
}
-
diff --git a/webapp/backend/pkg/database/migrations/m20250221084400/device.go b/webapp/backend/pkg/database/migrations/m20250221084400/device.go
new file mode 100644
index 0000000..b0a2e5a
--- /dev/null
+++ b/webapp/backend/pkg/database/migrations/m20250221084400/device.go
@@ -0,0 +1,41 @@
+package m20250221084400
+
+import (
+ "github.com/analogj/scrutiny/webapp/backend/pkg"
+ "time"
+)
+
+type Device struct {
+ Archived bool `json:"archived"`
+ //GORM attributes, see: http://gorm.io/docs/conventions.html
+ CreatedAt time.Time
+ UpdatedAt time.Time
+ DeletedAt *time.Time
+
+ WWN string `json:"wwn" gorm:"primary_key"`
+
+ DeviceName string `json:"device_name"`
+ DeviceUUID string `json:"device_uuid"`
+ DeviceSerialID string `json:"device_serial_id"`
+ DeviceLabel string `json:"device_label"`
+
+ Manufacturer string `json:"manufacturer"`
+ ModelName string `json:"model_name"`
+ InterfaceType string `json:"interface_type"`
+ InterfaceSpeed string `json:"interface_speed"`
+ SerialNumber string `json:"serial_number"`
+ Firmware string `json:"firmware"`
+ RotationSpeed int `json:"rotational_speed"`
+ Capacity int64 `json:"capacity"`
+ FormFactor string `json:"form_factor"`
+ SmartSupport bool `json:"smart_support"`
+ DeviceProtocol string `json:"device_protocol"` //protocol determines which smart attribute types are available (ATA, NVMe, SCSI)
+ DeviceType string `json:"device_type"` //device type is used for querying with -d/t flag, should only be used by collector.
+
+ // User provided metadata
+ Label string `json:"label"`
+ HostId string `json:"host_id"`
+
+ // Data set by Scrutiny
+ DeviceStatus pkg.DeviceStatus `json:"device_status"`
+}
diff --git a/webapp/backend/pkg/database/mock/mock_database.go b/webapp/backend/pkg/database/mock/mock_database.go
index c001d08..f5fefc2 100644
--- a/webapp/backend/pkg/database/mock/mock_database.go
+++ b/webapp/backend/pkg/database/mock/mock_database.go
@@ -52,6 +52,20 @@ func (mr *MockDeviceRepoMockRecorder) Close() *gomock.Call {
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Close", reflect.TypeOf((*MockDeviceRepo)(nil).Close))
}
+// UpdateDeviceArchived mocks base method.
+func (m *MockDeviceRepo) UpdateDeviceArchived(ctx context.Context, wwn string, archived bool) error {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "UpdateDeviceArchived", ctx, wwn)
+ ret0, _ := ret[0].(error)
+ return ret0
+}
+
+// UpdateDeviceArchived indicates an expected call of UpdateDeviceArchived.
+func (mr *MockDeviceRepoMockRecorder) UpdateDeviceArchived(ctx, wwn, archived interface{}) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateDeviceArchived", reflect.TypeOf((*MockDeviceRepo)(nil).UpdateDeviceArchived), ctx, wwn, archived)
+}
+
// DeleteDevice mocks base method.
func (m *MockDeviceRepo) DeleteDevice(ctx context.Context, wwn string) error {
m.ctrl.T.Helper()
diff --git a/webapp/backend/pkg/database/scrutiny_repository_device.go b/webapp/backend/pkg/database/scrutiny_repository_device.go
index 897a3e1..8ba0e8c 100644
--- a/webapp/backend/pkg/database/scrutiny_repository_device.go
+++ b/webapp/backend/pkg/database/scrutiny_repository_device.go
@@ -14,7 +14,7 @@ import (
// Device
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
-//insert device into DB (and update specified columns if device is already registered)
+// insert device into DB (and update specified columns if device is already registered)
// update device fields that may change: (DeviceType, HostID)
func (sr *scrutinyRepository) RegisterDevice(ctx context.Context, dev models.Device) error {
if err := sr.gormClient.WithContext(ctx).Clauses(clause.OnConflict{
@@ -51,7 +51,7 @@ func (sr *scrutinyRepository) UpdateDevice(ctx context.Context, wwn string, coll
return device, sr.gormClient.Model(&device).Updates(device).Error
}
-//Update Device Status
+// Update Device Status
func (sr *scrutinyRepository) UpdateDeviceStatus(ctx context.Context, wwn string, status pkg.DeviceStatus) (models.Device, error) {
var device models.Device
if err := sr.gormClient.WithContext(ctx).Where("wwn = ?", wwn).First(&device).Error; err != nil {
@@ -74,6 +74,16 @@ func (sr *scrutinyRepository) GetDeviceDetails(ctx context.Context, wwn string)
return device, nil
}
+// Update Device Archived State
+func (sr *scrutinyRepository) UpdateDeviceArchived(ctx context.Context, wwn string, archived bool) error {
+ var device models.Device
+ if err := sr.gormClient.WithContext(ctx).Where("wwn = ?", wwn).First(&device).Error; err != nil {
+ return fmt.Errorf("Could not get device from DB: %v", err)
+ }
+
+ return sr.gormClient.Model(&device).Where("wwn = ?", wwn).Update("archived", archived).Error
+}
+
func (sr *scrutinyRepository) DeleteDevice(ctx context.Context, wwn string) error {
if err := sr.gormClient.WithContext(ctx).Where("wwn = ?", wwn).Delete(&models.Device{}).Error; err != nil {
return err
diff --git a/webapp/backend/pkg/database/scrutiny_repository_migrations.go b/webapp/backend/pkg/database/scrutiny_repository_migrations.go
index 9a02574..e12fae8 100644
--- a/webapp/backend/pkg/database/scrutiny_repository_migrations.go
+++ b/webapp/backend/pkg/database/scrutiny_repository_migrations.go
@@ -12,6 +12,7 @@ import (
"github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20220503120000"
"github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20220509170100"
"github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20220716214900"
+ "github.com/analogj/scrutiny/webapp/backend/pkg/database/migrations/m20250221084400"
"github.com/analogj/scrutiny/webapp/backend/pkg/models"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/collector"
"github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements"
@@ -399,6 +400,15 @@ func (sr *scrutinyRepository) Migrate(ctx context.Context) error {
return tx.Create(&defaultSettings).Error
},
},
+ {
+ ID: "m20250221084400", // add archived to device data
+ Migrate: func(tx *gorm.DB) error {
+
+ //migrate the device database.
+ // adding column (archived)
+ return tx.AutoMigrate(m20250221084400.Device{})
+ },
+ },
})
if err := m.Migrate(); err != nil {
diff --git a/webapp/backend/pkg/models/device.go b/webapp/backend/pkg/models/device.go
index 9c84115..a891652 100644
--- a/webapp/backend/pkg/models/device.go
+++ b/webapp/backend/pkg/models/device.go
@@ -14,6 +14,7 @@ type DeviceWrapper struct {
type Device struct {
//GORM attributes, see: http://gorm.io/docs/conventions.html
+ Archived bool `json:"archived"`
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt *time.Time
diff --git a/webapp/backend/pkg/web/handler/archive_device.go b/webapp/backend/pkg/web/handler/archive_device.go
new file mode 100644
index 0000000..494dd38
--- /dev/null
+++ b/webapp/backend/pkg/web/handler/archive_device.go
@@ -0,0 +1,22 @@
+package handler
+
+import (
+ "github.com/analogj/scrutiny/webapp/backend/pkg/database"
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "net/http"
+)
+
+func ArchiveDevice(c *gin.Context) {
+ logger := c.MustGet("LOGGER").(*logrus.Entry)
+ deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
+
+ err := deviceRepo.UpdateDeviceArchived(c, c.Param("wwn"), true)
+ if err != nil {
+ logger.Errorln("An error occurred while archiving device", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"success": false})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
diff --git a/webapp/backend/pkg/web/handler/unarchive_device.go b/webapp/backend/pkg/web/handler/unarchive_device.go
new file mode 100644
index 0000000..dea8781
--- /dev/null
+++ b/webapp/backend/pkg/web/handler/unarchive_device.go
@@ -0,0 +1,22 @@
+package handler
+
+import (
+ "github.com/analogj/scrutiny/webapp/backend/pkg/database"
+ "github.com/gin-gonic/gin"
+ "github.com/sirupsen/logrus"
+ "net/http"
+)
+
+func UnarchiveDevice(c *gin.Context) {
+ logger := c.MustGet("LOGGER").(*logrus.Entry)
+ deviceRepo := c.MustGet("DEVICE_REPOSITORY").(database.DeviceRepo)
+
+ err := deviceRepo.UpdateDeviceArchived(c, c.Param("wwn"), false)
+ if err != nil {
+ logger.Errorln("An error occurred while unarchiving device", err)
+ c.JSON(http.StatusInternalServerError, gin.H{"success": false})
+ return
+ }
+
+ c.JSON(http.StatusOK, gin.H{"success": true})
+}
diff --git a/webapp/backend/pkg/web/server.go b/webapp/backend/pkg/web/server.go
index 3d56fe0..38383c9 100644
--- a/webapp/backend/pkg/web/server.go
+++ b/webapp/backend/pkg/web/server.go
@@ -42,8 +42,10 @@ func (ae *AppEngine) Setup(logger *logrus.Entry) *gin.Engine {
api.GET("/summary/temp", handler.GetDevicesSummaryTempHistory) //used by Dashboard (Temperature history dropdown)
api.POST("/device/:wwn/smart", handler.UploadDeviceMetrics) //used by Collector to upload data
api.POST("/device/:wwn/selftest", handler.UploadDeviceSelfTests)
- api.GET("/device/:wwn/details", handler.GetDeviceDetails) //used by Details
- api.DELETE("/device/:wwn", handler.DeleteDevice) //used by UI to delete device
+ api.GET("/device/:wwn/details", handler.GetDeviceDetails) //used by Details
+ api.POST("/device/:wwn/archive", handler.ArchiveDevice) //used by UI to archive device
+ api.POST("/device/:wwn/unarchive", handler.UnarchiveDevice) //used by UI to unarchive device
+ api.DELETE("/device/:wwn", handler.DeleteDevice) //used by UI to delete device
api.GET("/settings", handler.GetSettings) //used to get settings
api.POST("/settings", handler.SaveSettings) //used to save settings
diff --git a/webapp/frontend/src/app/core/models/device-model.ts b/webapp/frontend/src/app/core/models/device-model.ts
index 7613c3f..c07d61a 100644
--- a/webapp/frontend/src/app/core/models/device-model.ts
+++ b/webapp/frontend/src/app/core/models/device-model.ts
@@ -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;
diff --git a/webapp/frontend/src/app/layout/common/dashboard-device-archive-dialog/dashboard-device-archive-dialog.component.html b/webapp/frontend/src/app/layout/common/dashboard-device-archive-dialog/dashboard-device-archive-dialog.component.html
new file mode 100644
index 0000000..aa8d88e
--- /dev/null
+++ b/webapp/frontend/src/app/layout/common/dashboard-device-archive-dialog/dashboard-device-archive-dialog.component.html
@@ -0,0 +1,11 @@
+
Archive {{data.title}}?
+This will remove the device from Scrutiny dashboard, unless you toggle show archived. Any data about the device
+ itself will remain untouched.
+
+
+
+
diff --git a/webapp/frontend/src/app/layout/common/dashboard-device-archive-dialog/dashboard-device-archive-dialog.component.scss b/webapp/frontend/src/app/layout/common/dashboard-device-archive-dialog/dashboard-device-archive-dialog.component.scss
new file mode 100644
index 0000000..e69de29
diff --git a/webapp/frontend/src/app/layout/common/dashboard-device-archive-dialog/dashboard-device-archive-dialog.component.spec.ts b/webapp/frontend/src/app/layout/common/dashboard-device-archive-dialog/dashboard-device-archive-dialog.component.spec.ts
new file mode 100644
index 0000000..3cffb95
--- /dev/null
+++ b/webapp/frontend/src/app/layout/common/dashboard-device-archive-dialog/dashboard-device-archive-dialog.component.spec.ts
@@ -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;
+
+ 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);
+ });
+});
diff --git a/webapp/frontend/src/app/layout/common/dashboard-device-archive-dialog/dashboard-device-archive-dialog.component.ts b/webapp/frontend/src/app/layout/common/dashboard-device-archive-dialog/dashboard-device-archive-dialog.component.ts
new file mode 100644
index 0000000..979aeb8
--- /dev/null
+++ b/webapp/frontend/src/app/layout/common/dashboard-device-archive-dialog/dashboard-device-archive-dialog.component.ts
@@ -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,
+ @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);
+ });
+
+ }
+}
diff --git a/webapp/frontend/src/app/layout/common/dashboard-device-archive-dialog/dashboard-device-archive-dialog.module.ts b/webapp/frontend/src/app/layout/common/dashboard-device-archive-dialog/dashboard-device-archive-dialog.module.ts
new file mode 100644
index 0000000..44a638f
--- /dev/null
+++ b/webapp/frontend/src/app/layout/common/dashboard-device-archive-dialog/dashboard-device-archive-dialog.module.ts
@@ -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
+{
+}
diff --git a/webapp/frontend/src/app/layout/common/dashboard-device-archive-dialog/dashboard-device-archive-dialog.service.ts b/webapp/frontend/src/app/layout/common/dashboard-device-archive-dialog/dashboard-device-archive-dialog.service.ts
new file mode 100644
index 0000000..a8c1083
--- /dev/null
+++ b/webapp/frontend/src/app/layout/common/dashboard-device-archive-dialog/dashboard-device-archive-dialog.service.ts
@@ -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
+ {
+ return this._httpClient.post( `${getBasePath()}/api/device/${wwn}/archive`, {});
+ }
+
+ unarchiveDevice(wwn: string): Observable
+ {
+ return this._httpClient.post( `${getBasePath()}/api/device/${wwn}/unarchive`, {});
+ }
+}
diff --git a/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.component.html b/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.component.html
index 43f4e0b..01ffb5f 100644
--- a/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.component.html
+++ b/webapp/frontend/src/app/layout/common/dashboard-device/dashboard-device.component.html
@@ -1,5 +1,7 @@
-
+