diff --git a/.github/workflows/docker-build.yaml b/.github/workflows/docker-build.yaml index bb1becc..e65947b 100644 --- a/.github/workflows/docker-build.yaml +++ b/.github/workflows/docker-build.yaml @@ -1,7 +1,5 @@ name: Docker on: - schedule: - - cron: '36 12 * * *' push: branches: [ master, beta ] # Publish semver tags as releases. diff --git a/.github/workflows/docker-nightly.yaml b/.github/workflows/docker-nightly.yaml new file mode 100644 index 0000000..59eadad --- /dev/null +++ b/.github/workflows/docker-nightly.yaml @@ -0,0 +1,69 @@ +name: Docker +on: + schedule: + - cron: '36 12 * * *' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: ${{ github.repository }} + +jobs: + omnibus: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + - name: "Populate frontend version information" + run: "cd webapp/frontend && ./git.version.sh" + - name: "Generate frontend & version information" + uses: addnab/docker-run-action@v3 + with: + image: node:lts + options: -v ${{ github.workspace }}:/work + run: | + cd /work + make frontend && echo "print contents of /work/dist" && ls -alt /work/dist + + + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + with: + platforms: 'arm64,arm' + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + # Login against a Docker registry except on PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@v2 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v4 + with: + tags: | + type=ref,enable=true,event=branch,suffix=-omnibus-nightly + type=ref,enable=true,event=tag,suffix=-omnibus-nightly + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + # Build and push Docker image with Buildx (don't push on PR) + # https://github.com/docker/build-push-action + - name: Build and push Docker image + uses: docker/build-push-action@v3 + with: + platforms: linux/amd64,linux/arm64 + context: . + file: docker/Dockerfile + push: false + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} +# cache-from: type=gha +# cache-to: type=gha,mode=max \ No newline at end of file diff --git a/webapp/backend/pkg/database/helpers.go b/webapp/backend/pkg/database/helpers.go new file mode 100644 index 0000000..3706d86 --- /dev/null +++ b/webapp/backend/pkg/database/helpers.go @@ -0,0 +1,12 @@ +package database + +import ( + "github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements" + "sort" +) + +func sortSmartMeasurementsDesc(smartResults []measurements.Smart) { + sort.SliceStable(smartResults, func(i, j int) bool { + return smartResults[i].Date.After(smartResults[j].Date) + }) +} diff --git a/webapp/backend/pkg/database/helpers_test.go b/webapp/backend/pkg/database/helpers_test.go new file mode 100644 index 0000000..38587b1 --- /dev/null +++ b/webapp/backend/pkg/database/helpers_test.go @@ -0,0 +1,30 @@ +package database + +import ( + "github.com/analogj/scrutiny/webapp/backend/pkg/models/measurements" + "github.com/stretchr/testify/require" + "testing" + "time" +) + +func Test_sortSmartMeasurementsDesc_LatestFirst(t *testing.T) { + //setup + timeNow := time.Now() + smartResults := []measurements.Smart{ + { + Date: timeNow.AddDate(0, 0, -2), + }, + { + Date: timeNow, + }, + { + Date: timeNow.AddDate(0, 0, -1), + }, + } + + //test + sortSmartMeasurementsDesc(smartResults) + + //assert + require.Equal(t, smartResults[0].Date, timeNow) +} diff --git a/webapp/backend/pkg/database/scrutiny_repository_device_smart_attributes.go b/webapp/backend/pkg/database/scrutiny_repository_device_smart_attributes.go index 18960cb..96015bb 100644 --- a/webapp/backend/pkg/database/scrutiny_repository_device_smart_attributes.go +++ b/webapp/backend/pkg/database/scrutiny_repository_device_smart_attributes.go @@ -29,6 +29,7 @@ func (sr *scrutinyRepository) SaveSmartAttributes(ctx context.Context, wwn strin return deviceSmartData, sr.saveDatapoint(sr.influxWriteApi, "smart", tags, fields, deviceSmartData.Date, ctx) } +// GetSmartAttributeHistory MUST return in sorted order, where newest entries are at the beginning of the list, and oldest are at the end. func (sr *scrutinyRepository) GetSmartAttributeHistory(ctx context.Context, wwn string, durationKey string, attributes []string) ([]measurements.Smart, error) { // Get SMartResults from InfluxDB @@ -64,6 +65,9 @@ func (sr *scrutinyRepository) GetSmartAttributeHistory(ctx context.Context, wwn return nil, err } + //we have to sort the smartResults again, because the `union` command will return multiple 'tables' and only sort the records in each table. + sortSmartMeasurementsDesc(smartResults) + return smartResults, nil //if err := device.SquashHistory(); err != nil { diff --git a/webapp/backend/pkg/database/scrutiny_repository_tasks.go b/webapp/backend/pkg/database/scrutiny_repository_tasks.go index b7f487a..079caff 100644 --- a/webapp/backend/pkg/database/scrutiny_repository_tasks.go +++ b/webapp/backend/pkg/database/scrutiny_repository_tasks.go @@ -109,7 +109,7 @@ func (sr *scrutinyRepository) DownsampleScript(aggregationType string) string { |> toInt() temp_data - |> aggregateWindow(fn: mean, every: aggWindow) + |> aggregateWindow(fn: mean, every: aggWindow, createEmpty: false) |> to(bucket: destBucket, org: destOrg) `, sourceBucket, diff --git a/webapp/backend/pkg/thresholds/scsi_attribute_metadata.go b/webapp/backend/pkg/thresholds/scsi_attribute_metadata.go index 83437fc..38a103c 100644 --- a/webapp/backend/pkg/thresholds/scsi_attribute_metadata.go +++ b/webapp/backend/pkg/thresholds/scsi_attribute_metadata.go @@ -19,7 +19,7 @@ var ScsiMetadata = map[string]ScsiAttributeMetadata{ DisplayType: "", Ideal: "low", Critical: true, - Description: "", + Description: "The grown defect count shows the amount of swapped (defective) blocks since the drive was shipped by it's vendor. Each additional defective block increases the count by one.", }, "read_errors_corrected_by_eccfast": { ID: "read_errors_corrected_by_eccfast", @@ -27,7 +27,7 @@ var ScsiMetadata = map[string]ScsiAttributeMetadata{ DisplayType: "", Ideal: "", Critical: false, - Description: "", + Description: "An error correction was applied to get perfect data (a.k.a. ECC on-the-fly). \"Without substantial delay\" means the correction did not postpone reading of later sectors (e.g. a revolution was not lost). The counter is incremented once for each logical block that requires correction. Two different blocks corrected during the same command are counted as two events.", }, "read_errors_corrected_by_eccdelayed": { ID: "read_errors_corrected_by_eccdelayed", @@ -35,7 +35,7 @@ var ScsiMetadata = map[string]ScsiAttributeMetadata{ DisplayType: "", Ideal: "", Critical: false, - Description: "", + Description: "An error code or algorithm (e.g. ECC, checksum) is applied in order to get perfect data with substantial delay. \"With possible delay\" means the correction took longer than a sector time so that reading/writing of subsequent sectors was delayed (e.g. a lost revolution). The counter is incremented once for each logical block that requires correction. A block with a double error that is correctable counts as one event and two different blocks corrected during the same command count as two events. ", }, "read_errors_corrected_by_rereads_rewrites": { ID: "read_errors_corrected_by_rereads_rewrites", @@ -43,7 +43,7 @@ var ScsiMetadata = map[string]ScsiAttributeMetadata{ DisplayType: "", Ideal: "low", Critical: true, - Description: "", + Description: "This parameter code specifies the counter counting the number of errors that are corrected by applying retries. This counts errors recovered, not the number of retries. If five retries were required to recover one block of data, the counter increments by one, not five. The counter is incremented once for each logical block that is recovered using retries. If an error is not recoverable while applying retries and is recovered by ECC, it isn't counted by this counter; it will be counted by the counter specified by parameter code 01h - Errors Corrected With Possible Delays. ", }, "read_total_errors_corrected": { ID: "read_total_errors_corrected", @@ -51,7 +51,7 @@ var ScsiMetadata = map[string]ScsiAttributeMetadata{ DisplayType: "", Ideal: "", Critical: false, - Description: "", + Description: "This counter counts the total of parameter code errors 00h, 01h and 02h (i.e. error corrected by ECC: fast and delayed plus errors corrected by rereads and rewrites). There is no \"double counting\" of data errors among these three counters. The sum of all correctable errors can be reached by adding parameter code 01h and 02h errors, not by using this total.", }, "read_correction_algorithm_invocations": { ID: "read_correction_algorithm_invocations", @@ -59,7 +59,7 @@ var ScsiMetadata = map[string]ScsiAttributeMetadata{ DisplayType: "", Ideal: "", Critical: false, - Description: "", + Description: "This parameter code specifies the counter that counts the total number of retries, or \"times the retry algorithm is invoked\". If after five attempts a counter 02h type error is recovered, then five is added to this counter. If three retries are required to get stable ECC syndrome before a counter 01h type error is corrected, then those three retries are also counted here. The number of retries applied to unsuccessfully recover an error (counter 06h type error) are also counted by this counter. ", }, "read_total_uncorrected_errors": { ID: "read_total_uncorrected_errors", @@ -67,7 +67,7 @@ var ScsiMetadata = map[string]ScsiAttributeMetadata{ DisplayType: "", Ideal: "low", Critical: true, - Description: "", + Description: "This parameter code specifies the counter that contains the total number of blocks for which an uncorrected data error has occurred. ", }, "write_errors_corrected_by_eccfast": { ID: "write_errors_corrected_by_eccfast", @@ -75,7 +75,7 @@ var ScsiMetadata = map[string]ScsiAttributeMetadata{ DisplayType: "", Ideal: "", Critical: false, - Description: "", + Description: "An error correction was applied to get perfect data (a.k.a. ECC on-the-fly). \"Without substantial delay\" means the correction did not postpone reading of later sectors (e.g. a revolution was not lost). The counter is incremented once for each logical block that requires correction. Two different blocks corrected during the same command are counted as two events. ", }, "write_errors_corrected_by_eccdelayed": { ID: "write_errors_corrected_by_eccdelayed", @@ -83,7 +83,7 @@ var ScsiMetadata = map[string]ScsiAttributeMetadata{ DisplayType: "", Ideal: "", Critical: false, - Description: "", + Description: "An error code or algorithm (e.g. ECC, checksum) is applied in order to get perfect data with substantial delay. \"With possible delay\" means the correction took longer than a sector time so that reading/writing of subsequent sectors was delayed (e.g. a lost revolution). The counter is incremented once for each logical block that requires correction. A block with a double error that is correctable counts as one event and two different blocks corrected during the same command count as two events. ", }, "write_errors_corrected_by_rereads_rewrites": { ID: "write_errors_corrected_by_rereads_rewrites", @@ -91,7 +91,7 @@ var ScsiMetadata = map[string]ScsiAttributeMetadata{ DisplayType: "", Ideal: "low", Critical: true, - Description: "", + Description: "This parameter code specifies the counter counting the number of errors that are corrected by applying retries. This counts errors recovered, not the number of retries. If five retries were required to recover one block of data, the counter increments by one, not five. The counter is incremented once for each logical block that is recovered using retries. If an error is not recoverable while applying retries and is recovered by ECC, it isn't counted by this counter; it will be counted by the counter specified by parameter code 01h - Errors Corrected With Possible Delays.", }, "write_total_errors_corrected": { ID: "write_total_errors_corrected", @@ -99,7 +99,7 @@ var ScsiMetadata = map[string]ScsiAttributeMetadata{ DisplayType: "", Ideal: "", Critical: false, - Description: "", + Description: "This counter counts the total of parameter code errors 00h, 01h and 02h (i.e. error corrected by ECC: fast and delayed plus errors corrected by rereads and rewrites). There is no \"double counting\" of data errors among these three counters. The sum of all correctable errors can be reached by adding parameter code 01h and 02h errors, not by using this total.", }, "write_correction_algorithm_invocations": { ID: "write_correction_algorithm_invocations", @@ -107,7 +107,7 @@ var ScsiMetadata = map[string]ScsiAttributeMetadata{ DisplayType: "", Ideal: "", Critical: false, - Description: "", + Description: "This parameter code specifies the counter that counts the total number of retries, or \"times the retry algorithm is invoked\". If after five attempts a counter 02h type error is recovered, then five is added to this counter. If three retries are required to get stable ECC syndrome before a counter 01h type error is corrected, then those three retries are also counted here. The number of retries applied to unsuccessfully recover an error (counter 06h type error) are also counted by this counter. ", }, "write_total_uncorrected_errors": { ID: "write_total_uncorrected_errors", @@ -115,6 +115,6 @@ var ScsiMetadata = map[string]ScsiAttributeMetadata{ DisplayType: "", Ideal: "low", Critical: true, - Description: "", + Description: " This parameter code specifies the counter that contains the total number of blocks for which an uncorrected data error has occurred.", }, } diff --git a/webapp/frontend/src/app/modules/detail/detail.component.html b/webapp/frontend/src/app/modules/detail/detail.component.html index 1d77ec8..da8ad7a 100644 --- a/webapp/frontend/src/app/modules/detail/detail.component.html +++ b/webapp/frontend/src/app/modules/detail/detail.component.html @@ -143,6 +143,7 @@ @@ -203,7 +204,7 @@ @@ -324,6 +325,72 @@ + + + + + + + + diff --git a/webapp/frontend/src/app/modules/detail/detail.component.scss b/webapp/frontend/src/app/modules/detail/detail.component.scss index 72eb4e1..89b9136 100644 --- a/webapp/frontend/src/app/modules/detail/detail.component.scss +++ b/webapp/frontend/src/app/modules/detail/detail.component.scss @@ -1,7 +1,6 @@ @import 'treo'; detail { - } // ----------------------------------------------------------------------------------------------------- @@ -20,5 +19,35 @@ detail { } +} + +//table { +// width: 100%; +//} + +$primary: map-get($theme, primary); +$is-dark: map-get($theme, is-dark); +tr.attribute-detail-row { + height: 0; +} + +//tr.attribute-row:not(.attribute-expanded-row):hover { +// @if ($is-dark) { +// background: rgba(0, 0, 0, 0.05); +// } @else { +// background: map-get($primary, 50); +// } +//} + +tr.attribute-row:not(.attribute-expanded-row):active { + background: #efefef; +} + +.attribute-row td { + border-bottom-width: 0; +} +.attribute-detail { + overflow: hidden; + display: flex; } diff --git a/webapp/frontend/src/app/modules/detail/detail.component.ts b/webapp/frontend/src/app/modules/detail/detail.component.ts index 020ef4a..78dc558 100644 --- a/webapp/frontend/src/app/modules/detail/detail.component.ts +++ b/webapp/frontend/src/app/modules/detail/detail.component.ts @@ -11,11 +11,28 @@ import {MatDialog} from "@angular/material/dialog"; import humanizeDuration from 'humanize-duration'; import {TreoConfigService} from "../../../@treo/services/config"; import {AppConfig} from "../../core/config/app.config"; +import {animate, state, style, transition, trigger} from '@angular/animations'; +import {formatDate} from "@angular/common"; +import { LOCALE_ID, Inject } from '@angular/core'; + +// from Constants.go - these must match +const AttributeStatusPassed = 0 +const AttributeStatusFailedSmart = 1 +const AttributeStatusWarningScrutiny = 2 +const AttributeStatusFailedScrutiny = 4 + @Component({ selector: 'detail', templateUrl: './detail.component.html', - styleUrls: ['./detail.component.scss'] + styleUrls: ['./detail.component.scss'], + animations: [ + trigger('detailExpand', [ + state('collapsed', style({height: '0px', minHeight: '0'})), + state('expanded', style({height: '*'})), + transition('expanded <=> collapsed', animate('225ms cubic-bezier(0.4, 0.0, 0.2, 1)')), + ]), + ], }) export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { @@ -24,6 +41,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { onlyCritical: boolean = true; // data: any; + expandedAttribute: any | null; metadata: any; device: any; @@ -33,12 +51,12 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { smartAttributeDataSource: MatTableDataSource; smartAttributeTableColumns: string[]; - @ViewChild('smartAttributeTable', {read: MatSort}) smartAttributeTableMatSort: MatSort; // Private private _unsubscribeAll: Subject; + private systemPrefersDark: boolean; /** * Constructor @@ -49,7 +67,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { private _detailService: DetailService, public dialog: MatDialog, private _configService: TreoConfigService, - + @Inject(LOCALE_ID) public locale: string ) { @@ -60,6 +78,9 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { this.smartAttributeDataSource = new MatTableDataSource(); // this.recentTransactionsTableColumns = ['status', 'id', 'name', 'value', 'worst', 'thresh']; this.smartAttributeTableColumns = ['status', 'id', 'name', 'value', 'worst', 'thresh','ideal', 'failure', 'history']; + + this.systemPrefersDark = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches; + } // ----------------------------------------------------------------------------------------------------- @@ -121,26 +142,43 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { // ----------------------------------------------------------------------------------------------------- // @ Private methods // ----------------------------------------------------------------------------------------------------- + getAttributeStatusName(attributeStatus: number): string { // tslint:disable:no-bitwise - // from Constants.go - // AttributeStatusPassed AttributeStatus = 0 - // AttributeStatusFailedSmart AttributeStatus = 1 - // AttributeStatusWarningScrutiny AttributeStatus = 2 - // AttributeStatusFailedScrutiny AttributeStatus = 4 - - if(attributeStatus === 0){ + if(attributeStatus === AttributeStatusPassed){ return 'passed' - } else if ((attributeStatus & 1) !== 0 || (attributeStatus & 4) !== 0 ){ + } else if ((attributeStatus & AttributeStatusFailedScrutiny) !== 0 || (attributeStatus & AttributeStatusFailedSmart) !== 0 ){ return 'failed' - } else if ((attributeStatus & 2) !== 0){ + } else if ((attributeStatus & AttributeStatusWarningScrutiny) !== 0){ return 'warn' } return '' // tslint:enable:no-bitwise } + getAttributeScrutinyStatusName(attributeStatus: number): string { + // tslint:disable:no-bitwise + if ((attributeStatus & AttributeStatusFailedScrutiny) !== 0){ + return 'failed' + } else if ((attributeStatus & AttributeStatusWarningScrutiny) !== 0){ + return 'warn' + } else { + return 'passed' + } + // tslint:enable:no-bitwise + } + + getAttributeSmartStatusName(attributeStatus: number): string { + // tslint:disable:no-bitwise + if ((attributeStatus & AttributeStatusFailedSmart) !== 0){ + return 'failed' + } else { + return 'passed' + } + // tslint:enable:no-bitwise + } + getAttributeName(attribute_data): string { let attribute_metadata = this.metadata[attribute_data.attribute_id] @@ -270,7 +308,7 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { } else { //ATA attributes = latest_smart_result.attrs - this.smartAttributeTableColumns = ['status', 'id', 'name', 'value', 'worst', 'thresh','ideal', 'failure', 'history']; + this.smartAttributeTableColumns = ['status', 'id', 'name', 'value', 'thresh','ideal', 'failure', 'history']; } for(const attrId in attributes){ @@ -282,7 +320,21 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { var attrHistory = [] for (let smart_result of smart_results){ - attrHistory.push(this.getAttributeValue(smart_result.attrs[attrId])) + // attrHistory.push(this.getAttributeValue(smart_result.attrs[attrId])) + + const chartDatapoint = { + x: formatDate(smart_result.date, 'MMMM dd, yyyy - HH:mm', this.locale), + y: this.getAttributeValue(smart_result.attrs[attrId]) + } + const attributeStatusName = this.getAttributeStatusName(smart_result.attrs[attrId].status) + if(attributeStatusName === 'failed') { + chartDatapoint['strokeColor'] = '#F05252' + chartDatapoint['fillColor'] = '#F05252' + } else if (attributeStatusName === 'warn'){ + chartDatapoint['strokeColor'] = '#C27803' + chartDatapoint['fillColor'] = '#C27803' + } + attrHistory.push(chartDatapoint) } // var rawHistory = (attr.history || []).map(hist_attr => this.getAttributeValue(hist_attr)).reverse() @@ -325,12 +377,17 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { enabled: false } }, + // theme:{ + // // @ts-ignore + // // mode: + // mode: 'dark', + // }, tooltip: { fixed: { enabled: false }, x: { - show: false + show: true }, y: { title: { @@ -341,7 +398,9 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { }, marker: { show: false - } + }, + theme: this.determineTheme(this.config) + }, stroke: { width: 2, @@ -350,6 +409,13 @@ export class DetailComponent implements OnInit, AfterViewInit, OnDestroy { }; } + private determineTheme(config:AppConfig): string { + if (config.theme === 'system') { + return this.systemPrefersDark ? 'dark' : 'light' + } else { + return config.theme + } + } // ----------------------------------------------------------------------------------------------------- // @ Public methods // ----------------------------------------------------------------------------------------------------- diff --git a/webapp/frontend/src/styles/styles.scss b/webapp/frontend/src/styles/styles.scss index 54b0075..ab52a08 100644 --- a/webapp/frontend/src/styles/styles.scss +++ b/webapp/frontend/src/styles/styles.scss @@ -17,8 +17,4 @@ color: #0694a2 !important } } - .apexcharts-tooltip { - background: #242b38 !important; - //color: orange; - } }
- + {{getAttributeName(attribute)}} + + + +
+ +
+
+ {{getAttributeDescription(attribute)}} +
+
+
+
+
+
Type
+
Value
+
Worst/Thresh
+
Failure %
+
+ +
+
+
+
Scrutiny
+
+
{{getAttributeValue(attribute)}}
+
--
+
{{(attribute.failure_rate | percent) || '--'}}
+
+ +
+
+
+
Normalized
+
+
{{attribute.value}}
+
{{getAttributeWorst(attribute) || '--' }}/{{getAttributeThreshold(attribute)}}
+
--
+
+ +
+
+
+
Raw
+
+
{{attribute.raw_value}}
+
--
+
--
+
+
+
+
+