From c508366702b9fa20d9ed05559fe73da282116aa6 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Mon, 25 Aug 2025 13:22:43 -0400 Subject: [PATCH] feat: add `parityCheckStatus` field to `array` query (#1611) Responds with a ParityCheck: ```ts type ParityCheck { """Date of the parity check""" date: DateTime """Duration of the parity check in seconds""" duration: Int """Speed of the parity check, in MB/s""" speed: String """Status of the parity check""" status: ParityCheckStatus! """Number of errors during the parity check""" errors: Int """Progress percentage of the parity check""" progress: Int """Whether corrections are being written to parity""" correcting: Boolean """Whether the parity check is paused""" paused: Boolean """Whether the parity check is running""" running: Boolean } enum ParityCheckStatus { NEVER_RUN = 'never_run', RUNNING = 'running', PAUSED = 'paused', COMPLETED = 'completed', CANCELLED = 'cancelled', FAILED = 'failed', } ``` ## Summary by CodeRabbit - New Features - Exposes a structured parity-check status for arrays with detailed fields (status enum: NEVER_RUN, RUNNING, PAUSED, COMPLETED, CANCELLED, FAILED), date, duration, speed, progress, errors, and running/paused flags. - Tests - Adds comprehensive unit tests covering all parity-check states, numeric edge cases, speed/date/duration/progress calculations, and real-world scenarios. - Refactor - Safer numeric/date parsing and a new numeric-conversion helper; minor formatting/cleanup in shared utilities. --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- api/generated-schema.graphql | 80 +- api/src/core/modules/array/get-array-data.ts | 2 + .../modules/array/parity-check-status.test.ts | 1080 +++++++++++++++++ .../core/modules/array/parity-check-status.ts | 72 ++ api/src/core/types/states/var.ts | 40 + .../graph/resolvers/array/array.model.ts | 5 + .../resolvers/array/array.service.spec.ts | 8 + .../graph/resolvers/array/parity.model.ts | 12 +- .../graph/resolvers/array/parity.service.ts | 22 +- packages/unraid-shared/src/util/data.ts | 37 +- 10 files changed, 1307 insertions(+), 51 deletions(-) create mode 100644 api/src/core/modules/array/parity-check-status.test.ts create mode 100644 api/src/core/modules/array/parity-check-status.ts diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index f6eabee01..5ed0cb6af 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -14,6 +14,49 @@ directive @usePermissions( possession: AuthPossession ) on FIELD_DEFINITION +type ParityCheck { + """Date of the parity check""" + date: DateTime + + """Duration of the parity check in seconds""" + duration: Int + + """Speed of the parity check, in MB/s""" + speed: String + + """Status of the parity check""" + status: ParityCheckStatus! + + """Number of errors during the parity check""" + errors: Int + + """Progress percentage of the parity check""" + progress: Int + + """Whether corrections are being written to parity""" + correcting: Boolean + + """Whether the parity check is paused""" + paused: Boolean + + """Whether the parity check is running""" + running: Boolean +} + +""" +A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. +""" +scalar DateTime + +enum ParityCheckStatus { + NEVER_RUN + RUNNING + PAUSED + COMPLETED + CANCELLED + FAILED +} + type Capacity { """Free capacity""" free: String! @@ -156,6 +199,9 @@ type UnraidArray implements Node { """Parity disks in the current array""" parities: [ArrayDisk!]! + """Current parity check status""" + parityCheckStatus: ParityCheck! + """Data disks in the current array""" disks: [ArrayDisk!]! @@ -836,40 +882,6 @@ input DeleteRCloneRemoteInput { name: String! } -type ParityCheck { - """Date of the parity check""" - date: DateTime - - """Duration of the parity check in seconds""" - duration: Int - - """Speed of the parity check, in MB/s""" - speed: String - - """Status of the parity check""" - status: String - - """Number of errors during the parity check""" - errors: Int - - """Progress percentage of the parity check""" - progress: Int - - """Whether corrections are being written to parity""" - correcting: Boolean - - """Whether the parity check is paused""" - paused: Boolean - - """Whether the parity check is running""" - running: Boolean -} - -""" -A date-time string at UTC, such as 2019-12-03T09:54:33Z, compliant with the date-time format. -""" -scalar DateTime - type Config implements Node { id: PrefixedID! valid: Boolean diff --git a/api/src/core/modules/array/get-array-data.ts b/api/src/core/modules/array/get-array-data.ts index 8b01d5b60..c8336fa44 100644 --- a/api/src/core/modules/array/get-array-data.ts +++ b/api/src/core/modules/array/get-array-data.ts @@ -1,6 +1,7 @@ import { GraphQLError } from 'graphql'; import { sum } from 'lodash-es'; +import { getParityCheckStatus } from '@app/core/modules/array/parity-check-status.js'; import { store } from '@app/store/index.js'; import { FileLoadStatus } from '@app/store/types.js'; import { @@ -61,5 +62,6 @@ export const getArrayData = (getState = store.getState): UnraidArray => { parities, disks, caches, + parityCheckStatus: getParityCheckStatus(emhttp.var), }; }; diff --git a/api/src/core/modules/array/parity-check-status.test.ts b/api/src/core/modules/array/parity-check-status.test.ts new file mode 100644 index 000000000..88236ed81 --- /dev/null +++ b/api/src/core/modules/array/parity-check-status.test.ts @@ -0,0 +1,1080 @@ +import { describe, expect, it } from 'vitest'; + +import type { Var } from '@app/core/types/states/var.js'; +import { getParityCheckStatus, ParityCheckStatus } from '@app/core/modules/array/parity-check-status.js'; + +const createMockVarData = (overrides: Partial = {}): Var => + ({ + mdResyncPos: 0, + mdResyncDt: '0', + mdResyncDb: '0', + mdResyncSize: 100000, + sbSyncExit: '0', + sbSynced: 0, + sbSynced2: 0, + // Add required fields with default values + bindMgt: null, + cacheNumDevices: 0, + cacheSbNumDisks: 0, + comment: '', + configValid: true, + configErrorState: null, + csrfToken: '', + defaultFormat: '', + defaultFsType: 'xfs' as any, + deviceCount: 0, + domain: '', + domainLogin: '', + domainShort: '', + flashGuid: '', + flashProduct: '', + flashVendor: '', + fsCopyPrcnt: 0, + fsNumMounted: 0, + fsNumUnmountable: 0, + fsProgress: '', + fsState: '', + fsUnmountableMask: '', + fuseDirectio: '', + fuseDirectioDefault: '', + fuseDirectioStatus: '', + fuseRemember: '', + fuseRememberDefault: '', + fuseRememberStatus: '', + hideDotFiles: false, + ...overrides, + }) as Var; + +describe('getParityCheckStatus', () => { + describe('RUNNING status', () => { + it('should return RUNNING when mdResyncPos > 0 and mdResyncDt > 0', () => { + const varData = createMockVarData({ + mdResyncPos: 100, + mdResyncDt: '1000', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.RUNNING); + }); + + it('should return RUNNING when mdResyncPos is very large', () => { + const varData = createMockVarData({ + mdResyncPos: 999999999, + mdResyncDt: '1', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.RUNNING); + }); + + it('should return RUNNING when mdResyncDt is a decimal string', () => { + const varData = createMockVarData({ + mdResyncPos: 1, + mdResyncDt: '0.1', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.RUNNING); + }); + + it('should return RUNNING even with other status fields set', () => { + const varData = createMockVarData({ + mdResyncPos: 50, + mdResyncDt: '500', + sbSynced: 1234567890, + sbSynced2: 1234567900, + sbSyncExit: '0', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.RUNNING); + }); + }); + + describe('PAUSED status', () => { + it('should return PAUSED when mdResyncPos > 0 and mdResyncDt = 0', () => { + const varData = createMockVarData({ + mdResyncPos: 100, + mdResyncDt: '0', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.PAUSED); + }); + + it('should return PAUSED when mdResyncDt is string "0"', () => { + const varData = createMockVarData({ + mdResyncPos: 1, + mdResyncDt: '0', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.PAUSED); + }); + + it('should return PAUSED when mdResyncDt is empty string (converts to 0)', () => { + const varData = createMockVarData({ + mdResyncPos: 1, + mdResyncDt: '', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.PAUSED); + }); + + it('should return PAUSED when mdResyncDt is non-numeric string (converts to NaN, then 0)', () => { + const varData = createMockVarData({ + mdResyncPos: 1, + mdResyncDt: 'not-a-number', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.PAUSED); + }); + + it('should return PAUSED even with completed check history', () => { + const varData = createMockVarData({ + mdResyncPos: 100, + mdResyncDt: '0', + sbSynced: 1234567890, + sbSynced2: 1234567900, + sbSyncExit: '0', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.PAUSED); + }); + }); + + describe('NEVER_RUN status', () => { + it('should return NEVER_RUN when sbSynced is 0 and no active operation', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 0, + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.NEVER_RUN); + }); + + it('should return NEVER_RUN as fallback when all conditions fail', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 100, + sbSynced2: 0, + sbSyncExit: '0', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.NEVER_RUN); + }); + + it('should return NEVER_RUN when sbSynced is exactly 0', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 0, + sbSynced2: 0, + sbSyncExit: '0', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.NEVER_RUN); + }); + }); + + describe('CANCELLED status', () => { + it('should return CANCELLED when sbSyncExit is "-4"', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 1234567890, + sbSynced2: 1234567900, + sbSyncExit: '-4', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.CANCELLED); + }); + + it('should return CANCELLED when sbSyncExit is numeric -4 as string', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 100, + sbSyncExit: '-4', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.CANCELLED); + }); + + it('should return CANCELLED even when sbSynced2 is 0', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 100, + sbSynced2: 0, + sbSyncExit: '-4', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.CANCELLED); + }); + + it('should return CANCELLED when sbSyncExit is "-4.0" (decimal notation)', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 100, + sbSyncExit: '-4.0', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.CANCELLED); + }); + }); + + describe('FAILED status', () => { + it('should return FAILED when sbSyncExit is non-zero and not -4', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 1234567890, + sbSynced2: 1234567900, + sbSyncExit: '1', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.FAILED); + }); + + it('should return FAILED when sbSyncExit is negative but not -4', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 100, + sbSyncExit: '-1', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.FAILED); + }); + + it('should return FAILED when sbSyncExit is positive error code', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 100, + sbSyncExit: '5', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.FAILED); + }); + + it('should return FAILED for large error codes', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 100, + sbSyncExit: '255', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.FAILED); + }); + + it('should return FAILED when sbSyncExit is decimal error code', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 100, + sbSyncExit: '1.5', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.FAILED); + }); + }); + + describe('COMPLETED status', () => { + it('should return COMPLETED when sbSynced2 > 0 and sbSyncExit is 0', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 1234567890, + sbSynced2: 1234567900, + sbSyncExit: '0', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.COMPLETED); + }); + + it('should return COMPLETED when sbSynced2 is very large', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 100, + sbSynced2: 999999999, + sbSyncExit: '0', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.COMPLETED); + }); + + it('should return COMPLETED when sbSynced2 is minimal positive value', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 100, + sbSynced2: 1, + sbSyncExit: '0', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.COMPLETED); + }); + }); + + describe('Corrupt/Invalid Number Handling', () => { + it('should return PAUSED when mdResyncDt is null and mdResyncPos > 0', () => { + const varData = createMockVarData({ + mdResyncPos: 100, + mdResyncDt: null as any, + sbSynced: 100, + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.PAUSED); + }); + + it('should return PAUSED when mdResyncDt is undefined and mdResyncPos > 0', () => { + const varData = createMockVarData({ + mdResyncPos: 100, + mdResyncDt: undefined as any, + sbSynced: 100, + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.PAUSED); + }); + + it('should return NEVER_RUN when sbSyncExit is null (converts to 0)', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 100, + sbSyncExit: null as any, + }); + + // Number(null) = 0, so this behaves like sbSyncExit = '0' + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.NEVER_RUN); + }); + + it('should return NEVER_RUN when sbSyncExit is undefined (NaN treated as 0)', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 100, + sbSyncExit: undefined as any, + }); + + // Number(undefined) = NaN, treated as 0, so behaves like sbSyncExit = '0' + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.NEVER_RUN); + }); + + it('should return NEVER_RUN when sbSyncExit is an object (NaN treated as 0)', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 100, + sbSyncExit: {} as any, + }); + + // Number({}) = NaN, treated as 0, so behaves like sbSyncExit = '0' + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.NEVER_RUN); + }); + + it('should return NEVER_RUN when sbSyncExit is an empty array (converts to 0)', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 100, + sbSyncExit: [] as any, + }); + + // Number([]) = 0, so this behaves like sbSyncExit = '0' + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.NEVER_RUN); + }); + + it('should return NEVER_RUN when sbSyncExit is a non-empty array (NaN treated as 0)', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 100, + sbSyncExit: [1, 2, 3] as any, + }); + + // Number([1,2,3]) = NaN, treated as 0, so behaves like sbSyncExit = '0' + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.NEVER_RUN); + }); + + it('should return FAILED when sbSyncExit is boolean true', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 100, + sbSyncExit: true as any, + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.FAILED); + }); + + it('should return NEVER_RUN when sbSyncExit is boolean false (converts to 0)', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 100, + sbSyncExit: false as any, + }); + + // Number(false) = 0, so sbSyncExitNumber === -4 is false, sbSyncExitNumber !== 0 is false + // Falls through to check sbSynced2 > 0, which is false, so returns NEVER_RUN fallback + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.NEVER_RUN); + }); + + it('should return PAUSED when mdResyncDt contains special characters', () => { + const varData = createMockVarData({ + mdResyncPos: 100, + mdResyncDt: '!@#$%^&*()', + sbSynced: 100, + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.PAUSED); + }); + + it('should return NEVER_RUN when sbSyncExit contains special characters (NaN treated as 0)', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 100, + sbSyncExit: '!@#$%^&*()', + }); + + // Number('!@#$%^&*()') = NaN, treated as 0, so behaves like sbSyncExit = '0' + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.NEVER_RUN); + }); + + it('should return PAUSED when mdResyncDt is Infinity string', () => { + const varData = createMockVarData({ + mdResyncPos: 100, + mdResyncDt: 'Infinity', + sbSynced: 100, + }); + + // Number('Infinity') = Infinity, and Infinity > 0 is true + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.RUNNING); + }); + + it('should return PAUSED when mdResyncDt is -Infinity string', () => { + const varData = createMockVarData({ + mdResyncPos: 100, + mdResyncDt: '-Infinity', + sbSynced: 100, + }); + + // Number('-Infinity') = -Infinity, and -Infinity > 0 is false + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.PAUSED); + }); + + it('should return FAILED when sbSyncExit is Infinity string', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 100, + sbSyncExit: 'Infinity', + }); + + // Number('Infinity') = Infinity, Infinity !== 0 is true, Infinity !== -4 is true + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.FAILED); + }); + + it('should demonstrate NaN-to-0 treatment with completed status', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 100, + sbSynced2: 200, + sbSyncExit: 'completely-invalid-string', + }); + + // Number('completely-invalid-string') = NaN, treated as 0 + // sbSyncExitValue === -4 is false, sbSyncExitValue !== 0 is false + // Falls through to sbSynced2 > 0 check, which is true, so COMPLETED + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.COMPLETED); + }); + }); + + describe('Edge cases and string-to-number conversion', () => { + it('should handle mdResyncDt with whitespace', () => { + const varData = createMockVarData({ + mdResyncPos: 1, + mdResyncDt: ' 100 ', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.RUNNING); + }); + + it('should handle sbSyncExit with whitespace', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 100, + sbSyncExit: ' -4 ', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.CANCELLED); + }); + + it('should handle sbSyncExit as non-numeric string (NaN treated as 0)', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 100, + sbSynced2: 200, + sbSyncExit: 'invalid', + }); + + // Number('invalid') = NaN, treated as 0, then checks sbSynced2 > 0 (true) = COMPLETED + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.COMPLETED); + }); + + it('should handle all zero values', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + mdResyncDt: '0', + sbSynced: 0, + sbSynced2: 0, + sbSyncExit: '0', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.NEVER_RUN); + }); + + it('should prioritize active operation over completed status', () => { + const varData = createMockVarData({ + mdResyncPos: 50, + mdResyncDt: '100', + sbSynced: 1000, + sbSynced2: 2000, + sbSyncExit: '0', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.RUNNING); + }); + + it('should prioritize active paused operation over completed status', () => { + const varData = createMockVarData({ + mdResyncPos: 50, + mdResyncDt: '0', + sbSynced: 1000, + sbSynced2: 2000, + sbSyncExit: '0', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.PAUSED); + }); + + it('should prioritize never run over other statuses when sbSynced is 0', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 0, + sbSynced2: 100, + sbSyncExit: '-4', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.NEVER_RUN); + }); + + it('should prioritize cancelled over failed status', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 100, + sbSynced2: 0, + sbSyncExit: '-4', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.CANCELLED); + }); + + it('should handle very large numbers', () => { + const varData = createMockVarData({ + mdResyncPos: Number.MAX_SAFE_INTEGER, + mdResyncDt: String(Number.MAX_SAFE_INTEGER), + sbSynced: Number.MAX_SAFE_INTEGER, + sbSynced2: Number.MAX_SAFE_INTEGER, + sbSyncExit: '0', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.RUNNING); + }); + + it('should handle negative mdResyncPos as edge case', () => { + const varData = createMockVarData({ + mdResyncPos: -1, + mdResyncDt: '100', + sbSynced: 100, + sbSynced2: 200, + sbSyncExit: '0', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.COMPLETED); + }); + + it('should handle hexadecimal string in sbSyncExit', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + sbSynced: 100, + sbSyncExit: '0x4', + }); + + // Number('0x4') = 4 (valid hex), so 4 !== 0 is true, returns FAILED + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.FAILED); + }); + + it('should handle scientific notation in mdResyncDt', () => { + const varData = createMockVarData({ + mdResyncPos: 1, + mdResyncDt: '1e3', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.RUNNING); + }); + }); + + describe('Real-world scenario tests', () => { + it('should handle fresh Unraid system (never run)', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + mdResyncDt: '0', + sbSynced: 0, + sbSynced2: 0, + sbSyncExit: '0', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.NEVER_RUN); + }); + + it('should handle system with successful completed check', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + mdResyncDt: '0', + sbSynced: 1640995200, // Example timestamp + sbSynced2: 1640998800, // One hour later + sbSyncExit: '0', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.COMPLETED); + }); + + it('should handle user-cancelled check scenario', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + mdResyncDt: '0', + sbSynced: 1640995200, + sbSynced2: 1640996000, // Cancelled after 13 minutes + sbSyncExit: '-4', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.CANCELLED); + }); + + it('should handle failed check with error', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + mdResyncDt: '0', + sbSynced: 1640995200, + sbSynced2: 1640996000, + sbSyncExit: '1', // Generic error + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.FAILED); + }); + + it('should handle currently running check at 50%', () => { + const varData = createMockVarData({ + mdResyncPos: 500000, + mdResyncDt: '1000', + sbSynced: 1640995200, + sbSynced2: 0, // Not completed yet + sbSyncExit: '0', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.RUNNING); + }); + + it('should handle paused check scenario', () => { + const varData = createMockVarData({ + mdResyncPos: 250000, + mdResyncDt: '0', // Paused + sbSynced: 1640995200, + sbSynced2: 0, + sbSyncExit: '0', + }); + + expect(getParityCheckStatus(varData).status).toBe(ParityCheckStatus.PAUSED); + }); + }); + + describe('Speed calculation', () => { + it('should return "0" when deltaTime is 0', () => { + const varData = createMockVarData({ + mdResyncDt: '0', + mdResyncDb: '1000', + }); + + expect(getParityCheckStatus(varData).speed).toBe('0'); + }); + + it('should return "0" when deltaBlocks is 0', () => { + const varData = createMockVarData({ + mdResyncDt: '1000', + mdResyncDb: '0', + }); + + expect(getParityCheckStatus(varData).speed).toBe('0'); + }); + + it('should return "0" when both deltaTime and deltaBlocks are 0', () => { + const varData = createMockVarData({ + mdResyncDt: '0', + mdResyncDb: '0', + }); + + expect(getParityCheckStatus(varData).speed).toBe('0'); + }); + + it('should calculate speed correctly for basic values', () => { + const varData = createMockVarData({ + mdResyncDt: '1', // 1 second + mdResyncDb: '1024', // 1024 blocks = 1024 * 1024 bytes = 1MB + }); + + // Speed = (1024 * 1024) bytes / 1 second / 1024 / 1024 = 1 MB/s + expect(getParityCheckStatus(varData).speed).toBe('1'); + }); + + it('should calculate speed correctly for larger values', () => { + const varData = createMockVarData({ + mdResyncDt: '10', // 10 seconds + mdResyncDb: '20480', // 20480 blocks = 20MB of data + }); + + // Speed = (20480 * 1024) bytes / 10 seconds / 1024 / 1024 = 2 MB/s + expect(getParityCheckStatus(varData).speed).toBe('2'); + }); + + it('should round speed values correctly', () => { + const varData = createMockVarData({ + mdResyncDt: '3', // 3 seconds + mdResyncDb: '1536', // 1536 blocks = 1.5MB of data + }); + + // Speed = (1536 * 1024) bytes / 3 seconds / 1024 / 1024 = 0.5 MB/s + expect(getParityCheckStatus(varData).speed).toBe('1'); // Should round 0.5 to 1 + }); + + it('should handle decimal values in deltaTime', () => { + const varData = createMockVarData({ + mdResyncDt: '2.5', // 2.5 seconds + mdResyncDb: '2560', // 2560 blocks = 2.5MB of data + }); + + // Speed = (2560 * 1024) bytes / 2.5 seconds / 1024 / 1024 = 1 MB/s + expect(getParityCheckStatus(varData).speed).toBe('1'); + }); + + it('should handle very small speed values', () => { + const varData = createMockVarData({ + mdResyncDt: '1000', // 1000 seconds + mdResyncDb: '1', // 1 block = 1024 bytes + }); + + // Speed = (1 * 1024) bytes / 1000 seconds / 1024 / 1024 = ~0.00098 MB/s + expect(getParityCheckStatus(varData).speed).toBe('0'); // Should round to 0 + }); + + it('should handle very large speed values', () => { + const varData = createMockVarData({ + mdResyncDt: '1', // 1 second + mdResyncDb: '1048576', // 1048576 blocks = 1GB of data + }); + + // Speed = (1048576 * 1024) bytes / 1 second / 1024 / 1024 = 1024 MB/s + expect(getParityCheckStatus(varData).speed).toBe('1024'); + }); + + it('should handle invalid deltaTime values gracefully', () => { + const varData = createMockVarData({ + mdResyncDt: 'invalid', + mdResyncDb: '1024', + }); + + // Invalid deltaTime should convert to 0, resulting in 0 speed + expect(getParityCheckStatus(varData).speed).toBe('0'); + }); + + it('should handle invalid deltaBlocks values gracefully', () => { + const varData = createMockVarData({ + mdResyncDt: '1', + mdResyncDb: 'invalid', + }); + + // Invalid deltaBlocks should convert to 0, resulting in 0 speed + expect(getParityCheckStatus(varData).speed).toBe('0'); + }); + + it('should handle negative values in deltaTime', () => { + const varData = createMockVarData({ + mdResyncDt: '-1', + mdResyncDb: '1024', + }); + + // Negative deltaTime results in negative speed + // (1024 * 1024) / -1 / 1024 / 1024 = -1 MB/s + expect(getParityCheckStatus(varData).speed).toBe('-1'); + }); + }); + + describe('Date calculation', () => { + it('should return undefined when sbSynced is 0', () => { + const varData = createMockVarData({ + sbSynced: 0, + }); + + expect(getParityCheckStatus(varData).date).toBeUndefined(); + }); + + it('should return undefined when sbSynced is negative', () => { + const varData = createMockVarData({ + sbSynced: -1, + }); + + expect(getParityCheckStatus(varData).date).toBeUndefined(); + }); + + it('should return valid Date when sbSynced is positive', () => { + const varData = createMockVarData({ + sbSynced: 1640995200, // Jan 1, 2022, 00:00:00 UTC + }); + + const result = getParityCheckStatus(varData); + expect(result.date).toBeInstanceOf(Date); + expect(result.date!.getTime()).toBe(1640995200 * 1000); + }); + + it('should convert Unix timestamp correctly', () => { + const varData = createMockVarData({ + sbSynced: 1609459200, // Jan 1, 2021, 00:00:00 UTC + }); + + const result = getParityCheckStatus(varData); + expect(result.date!.getUTCFullYear()).toBe(2021); + expect(result.date!.getUTCMonth()).toBe(0); // January = 0 + expect(result.date!.getUTCDate()).toBe(1); + }); + + it('should handle large timestamp values', () => { + const varData = createMockVarData({ + sbSynced: 2147483647, // Max 32-bit signed integer (Jan 19, 2038) + }); + + const result = getParityCheckStatus(varData); + expect(result.date).toBeInstanceOf(Date); + expect(result.date!.getFullYear()).toBe(2038); + }); + + it('should handle small positive timestamp values', () => { + const varData = createMockVarData({ + sbSynced: 1, // Jan 1, 1970, 00:00:01 UTC + }); + + const result = getParityCheckStatus(varData); + expect(result.date).toBeInstanceOf(Date); + expect(result.date!.getTime()).toBe(1000); + }); + }); + + describe('Duration calculation', () => { + it('should return undefined when sbSynced is 0', () => { + const varData = createMockVarData({ + sbSynced: 0, + }); + + expect(getParityCheckStatus(varData).duration).toBeUndefined(); + }); + + it('should return undefined when sbSynced is negative', () => { + const varData = createMockVarData({ + sbSynced: -1, + }); + + expect(getParityCheckStatus(varData).duration).toBeUndefined(); + }); + + it('should calculate duration using sbSynced2 when available', () => { + const varData = createMockVarData({ + sbSynced: 1000, + sbSynced2: 1360, // 360 seconds later (6 minutes) + }); + + const result = getParityCheckStatus(varData); + expect(result.duration).toBe(360); + }); + + it('should calculate duration using current time when sbSynced2 is 0', () => { + const mockNow = 1640995560; // Some future time + const originalNow = Date.now; + Date.now = () => mockNow * 1000; // Convert to milliseconds + + const varData = createMockVarData({ + sbSynced: 1640995200, // 360 seconds before mockNow + sbSynced2: 0, + }); + + const result = getParityCheckStatus(varData); + expect(result.duration).toBe(360); + + Date.now = originalNow; // Restore original + }); + + it('should round duration values correctly', () => { + const varData = createMockVarData({ + sbSynced: 1000, + sbSynced2: 1360.7, // 360.7 seconds later + }); + + const result = getParityCheckStatus(varData); + expect(result.duration).toBe(361); // Should round 360.7 to 361 + }); + + it('should handle zero duration', () => { + const varData = createMockVarData({ + sbSynced: 1000, + sbSynced2: 1000, // Same time + }); + + const result = getParityCheckStatus(varData); + expect(result.duration).toBe(0); + }); + + it('should handle negative duration (edge case)', () => { + const varData = createMockVarData({ + sbSynced: 2000, + sbSynced2: 1000, // End time before start time + }); + + const result = getParityCheckStatus(varData); + expect(result.duration).toBe(-1000); // Should handle negative duration + }); + + it('should prefer sbSynced2 over current time when both are available', () => { + const mockNow = 5000; + const originalNow = Date.now; + Date.now = () => mockNow * 1000; + + const varData = createMockVarData({ + sbSynced: 1000, + sbSynced2: 2000, // Should use this, not current time + }); + + const result = getParityCheckStatus(varData); + expect(result.duration).toBe(1000); // 2000 - 1000, not 5000 - 1000 + + Date.now = originalNow; + }); + + it('should handle large duration values', () => { + const varData = createMockVarData({ + sbSynced: 1000, + sbSynced2: 1000000, // 999,000 seconds (about 11.5 days) + }); + + const result = getParityCheckStatus(varData); + expect(result.duration).toBe(999000); + }); + }); + + describe('Progress calculation', () => { + it('should return 0 when mdResyncSize is 0', () => { + const varData = createMockVarData({ + mdResyncPos: 50, + mdResyncSize: 0, + }); + + expect(getParityCheckStatus(varData).progress).toBe(0); + }); + + it('should return 0 when mdResyncSize is negative', () => { + const varData = createMockVarData({ + mdResyncPos: 50, + mdResyncSize: -100, + }); + + expect(getParityCheckStatus(varData).progress).toBe(0); + }); + + it('should calculate progress percentage correctly', () => { + const varData = createMockVarData({ + mdResyncPos: 25, + mdResyncSize: 100, + }); + + expect(getParityCheckStatus(varData).progress).toBe(25); + }); + + it('should handle 0% progress', () => { + const varData = createMockVarData({ + mdResyncPos: 0, + mdResyncSize: 100, + }); + + expect(getParityCheckStatus(varData).progress).toBe(0); + }); + + it('should handle 100% progress', () => { + const varData = createMockVarData({ + mdResyncPos: 100, + mdResyncSize: 100, + }); + + expect(getParityCheckStatus(varData).progress).toBe(100); + }); + + it('should clamp progress above 100% to 100%', () => { + const varData = createMockVarData({ + mdResyncPos: 150, + mdResyncSize: 100, + }); + + expect(getParityCheckStatus(varData).progress).toBe(100); + }); + + it('should clamp negative progress to 0%', () => { + const varData = createMockVarData({ + mdResyncPos: -50, + mdResyncSize: 100, + }); + + expect(getParityCheckStatus(varData).progress).toBe(0); + }); + + it('should round progress values correctly', () => { + const varData = createMockVarData({ + mdResyncPos: 33, + mdResyncSize: 100, + }); + + expect(getParityCheckStatus(varData).progress).toBe(33); + }); + + it('should round decimal progress values', () => { + const varData = createMockVarData({ + mdResyncPos: 333, + mdResyncSize: 1000, // 33.3% + }); + + expect(getParityCheckStatus(varData).progress).toBe(33); + }); + + it('should handle very small progress values', () => { + const varData = createMockVarData({ + mdResyncPos: 1, + mdResyncSize: 1000000, // 0.0001% + }); + + expect(getParityCheckStatus(varData).progress).toBe(0); // Should round down to 0 + }); + + it('should handle very large numbers', () => { + const varData = createMockVarData({ + mdResyncPos: Number.MAX_SAFE_INTEGER / 2, + mdResyncSize: Number.MAX_SAFE_INTEGER, + }); + + expect(getParityCheckStatus(varData).progress).toBe(50); + }); + + it('should handle floating point precision issues', () => { + const varData = createMockVarData({ + mdResyncPos: 1, + mdResyncSize: 3, // 33.333...% + }); + + const result = getParityCheckStatus(varData).progress; + expect(result).toBeGreaterThanOrEqual(33); + expect(result).toBeLessThanOrEqual(34); + }); + + it('should handle when mdResyncPos equals mdResyncSize', () => { + const varData = createMockVarData({ + mdResyncPos: 12345, + mdResyncSize: 12345, + }); + + expect(getParityCheckStatus(varData).progress).toBe(100); + }); + }); +}); diff --git a/api/src/core/modules/array/parity-check-status.ts b/api/src/core/modules/array/parity-check-status.ts new file mode 100644 index 000000000..8afc8cee2 --- /dev/null +++ b/api/src/core/modules/array/parity-check-status.ts @@ -0,0 +1,72 @@ +import { toNumberAlways } from '@unraid/shared/util/data.js'; + +import type { Var } from '@app/core/types/states/var.js'; +import type { ParityCheck } from '@app/unraid-api/graph/resolvers/array/parity.model.js'; + +export enum ParityCheckStatus { + NEVER_RUN = 'never_run', + RUNNING = 'running', + PAUSED = 'paused', + COMPLETED = 'completed', + CANCELLED = 'cancelled', + FAILED = 'failed', +} + +function calculateParitySpeed(deltaTime: number, deltaBlocks: number) { + if (deltaTime === 0 || deltaBlocks === 0) return 0; + const deltaBytes = deltaBlocks * 1024; + const speedMBps = deltaBytes / deltaTime / 1024 / 1024; + return Math.round(speedMBps); +} + +type RelevantVarData = Pick< + Var, + | 'mdResyncPos' + | 'mdResyncDt' + | 'sbSyncExit' + | 'sbSynced' + | 'sbSynced2' + | 'mdResyncDb' + | 'mdResyncSize' +>; + +function getStatusFromVarData(varData: RelevantVarData): ParityCheckStatus { + const { mdResyncPos, mdResyncDt, sbSyncExit, sbSynced, sbSynced2 } = varData; + const mdResyncDtNumber = toNumberAlways(mdResyncDt, 0); + const sbSyncExitNumber = toNumberAlways(sbSyncExit, 0); + + switch (true) { + case mdResyncPos > 0: + return mdResyncDtNumber > 0 ? ParityCheckStatus.RUNNING : ParityCheckStatus.PAUSED; + case sbSynced === 0: + return ParityCheckStatus.NEVER_RUN; + case sbSyncExitNumber === -4: + return ParityCheckStatus.CANCELLED; + case sbSyncExitNumber !== 0: + return ParityCheckStatus.FAILED; + case sbSynced2 > 0: + return ParityCheckStatus.COMPLETED; + default: + return ParityCheckStatus.NEVER_RUN; + } +} + +export function getParityCheckStatus(varData: RelevantVarData): ParityCheck { + const { sbSynced, sbSynced2, mdResyncDt, mdResyncDb, mdResyncPos, mdResyncSize } = varData; + const deltaTime = toNumberAlways(mdResyncDt, 0); + const deltaBlocks = toNumberAlways(mdResyncDb, 0); + + // seconds since epoch (unix timestamp) + const now = sbSynced2 > 0 ? sbSynced2 : Date.now() / 1000; + return { + status: getStatusFromVarData(varData), + speed: String(calculateParitySpeed(deltaTime, deltaBlocks)), + date: sbSynced > 0 ? new Date(sbSynced * 1000) : undefined, + duration: sbSynced > 0 ? Math.round(now - sbSynced) : undefined, + // percentage as integer, clamped to [0, 100] + progress: + mdResyncSize <= 0 + ? 0 + : Math.round(Math.min(100, Math.max(0, (mdResyncPos / mdResyncSize) * 100))), + }; +} diff --git a/api/src/core/types/states/var.ts b/api/src/core/types/states/var.ts index f76d66720..a994dcc4c 100644 --- a/api/src/core/types/states/var.ts +++ b/api/src/core/types/states/var.ts @@ -68,11 +68,24 @@ export type Var = { mdNumStripes: number; mdNumStripesDefault: number; mdNumStripesStatus: string; + /** + * Serves a dual purpose depending on context: + * - Total size of the operation (in sectors/blocks) + * - Running state indicator (0 = paused, >0 = running) + */ mdResync: number; mdResyncAction: string; mdResyncCorr: string; mdResyncDb: string; + /** Average time interval (delta time) in seconds of current parity operations */ mdResyncDt: string; + /** + * Current position in the parity operation (in sectors/blocks). + * When mdResyncPos > 0, a parity operation is active. + * When mdResyncPos = 0, no parity operation is running. + * + * Used to calculate progress percentage. + */ mdResyncPos: number; mdResyncSize: number; mdState: ArrayState; @@ -136,9 +149,36 @@ export type Var = { sbName: string; sbNumDisks: number; sbState: string; + /** + * Unix timestamp when parity operation started. + * When sbSynced = 0, indicates no parity check has ever been run. + * + * Used to calculate elapsed time during active operations. + */ sbSynced: number; sbSynced2: number; + /** + * Unix timestamp when parity operation completed (successfully or with errors). + * Used to display completion time in status messages. + * + * When sbSynced2 = 0, indicates operation started but not yet finished + */ sbSyncErrs: number; + /** + * Exit status code that indicates how the last parity operation completed, following standard Unix conventions. + * + * sbSyncExit = 0 - Successful Completion + * - Parity operation completed normally without errors + * - Used to calculate speed and display success message + * + * sbSyncExit = -4 - Aborted/Cancelled + * - Operation was manually cancelled by user + * - Displays as "aborted" in the UI + * + * sbSyncExit ≠ 0 (other values) - Failed/Incomplete + * - Operation failed due to errors or other issues + * - Displays the numeric error code + */ sbSyncExit: string; sbUpdated: string; sbVersion: string; diff --git a/api/src/unraid-api/graph/resolvers/array/array.model.ts b/api/src/unraid-api/graph/resolvers/array/array.model.ts index 67aa27430..50518ab55 100644 --- a/api/src/unraid-api/graph/resolvers/array/array.model.ts +++ b/api/src/unraid-api/graph/resolvers/array/array.model.ts @@ -5,6 +5,8 @@ import { PrefixedID } from '@unraid/shared/prefixed-id-scalar.js'; import { IsEnum, IsInt, IsOptional, IsString } from 'class-validator'; import { GraphQLBigInt } from 'graphql-scalars'; +import { ParityCheck } from '@app/unraid-api/graph/resolvers/array/parity.model.js'; + @ObjectType() export class Capacity { @Field(() => String, { description: 'Free capacity' }) @@ -142,6 +144,9 @@ export class UnraidArray extends Node { @Field(() => [ArrayDisk], { description: 'Parity disks in the current array' }) parities!: ArrayDisk[]; + @Field(() => ParityCheck, { description: 'Current parity check status' }) + parityCheckStatus!: ParityCheck; + @Field(() => [ArrayDisk], { description: 'Data disks in the current array' }) disks!: ArrayDisk[]; diff --git a/api/src/unraid-api/graph/resolvers/array/array.service.spec.ts b/api/src/unraid-api/graph/resolvers/array/array.service.spec.ts index fc858bb4d..1f8281e8d 100644 --- a/api/src/unraid-api/graph/resolvers/array/array.service.spec.ts +++ b/api/src/unraid-api/graph/resolvers/array/array.service.spec.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ArrayRunningError } from '@app/core/errors/array-running-error.js'; import { getArrayData as getArrayDataUtil } from '@app/core/modules/array/get-array-data.js'; +import { ParityCheckStatus } from '@app/core/modules/array/parity-check-status.js'; import { emcmd } from '@app/core/utils/clients/emcmd.js'; import { ArrayDiskInput, @@ -82,6 +83,13 @@ describe('ArrayService', () => { parities: [], disks: [], caches: [], + parityCheckStatus: { + status: ParityCheckStatus.NEVER_RUN, + progress: 0, + date: undefined, + duration: 0, + speed: '0', + }, }; mockGetArrayDataUtil.mockResolvedValue(mockArrayData); diff --git a/api/src/unraid-api/graph/resolvers/array/parity.model.ts b/api/src/unraid-api/graph/resolvers/array/parity.model.ts index 07230351e..723d9e9b2 100644 --- a/api/src/unraid-api/graph/resolvers/array/parity.model.ts +++ b/api/src/unraid-api/graph/resolvers/array/parity.model.ts @@ -1,4 +1,10 @@ -import { Field, GraphQLISODateTime, Int, ObjectType } from '@nestjs/graphql'; +import { Field, GraphQLISODateTime, Int, ObjectType, registerEnumType } from '@nestjs/graphql'; + +import { ParityCheckStatus } from '@app/core/modules/array/parity-check-status.js'; + +registerEnumType(ParityCheckStatus, { + name: 'ParityCheckStatus', +}); @ObjectType() export class ParityCheck { @@ -11,8 +17,8 @@ export class ParityCheck { @Field(() => String, { nullable: true, description: 'Speed of the parity check, in MB/s' }) speed?: string; - @Field(() => String, { nullable: true, description: 'Status of the parity check' }) - status?: string; + @Field(() => ParityCheckStatus, { description: 'Status of the parity check' }) + status!: ParityCheckStatus; @Field(() => Int, { nullable: true, description: 'Number of errors during the parity check' }) errors?: number; diff --git a/api/src/unraid-api/graph/resolvers/array/parity.service.ts b/api/src/unraid-api/graph/resolvers/array/parity.service.ts index 87ddaf7d2..7adff8702 100644 --- a/api/src/unraid-api/graph/resolvers/array/parity.service.ts +++ b/api/src/unraid-api/graph/resolvers/array/parity.service.ts @@ -1,8 +1,10 @@ import { Injectable } from '@nestjs/common'; import { readFile } from 'fs/promises'; +import { toNumberAlways } from '@unraid/shared/util/data.js'; import { GraphQLError } from 'graphql'; +import { ParityCheckStatus } from '@app/core/modules/array/parity-check-status.js'; import { emcmd } from '@app/core/utils/index.js'; import { ParityCheck } from '@app/unraid-api/graph/resolvers/array/parity.model.js'; @@ -22,16 +24,30 @@ export class ParityService { const lines = history.toString().trim().split('\n').reverse(); return lines.map((line) => { const [date, duration, speed, status, errors = '0'] = line.split('|'); + const parsedDate = new Date(date); + const safeDate = Number.isNaN(parsedDate.getTime()) ? undefined : parsedDate; + const durationNumber = Number(duration); + const safeDuration = Number.isNaN(durationNumber) ? undefined : durationNumber; return { - date: new Date(date), - duration: Number.parseInt(duration, 10), + date: safeDate, + duration: safeDuration, speed: speed ?? 'Unavailable', - status: status === '-4' ? 'Cancelled' : 'OK', + // use http 422 (unprocessable entity) as fallback to differentiate from unix error codes + // when status is not a number. + status: this.statusCodeToStatusEnum(toNumberAlways(status, 422)), errors: Number.parseInt(errors, 10), }; }); } + statusCodeToStatusEnum(statusCode: number): ParityCheckStatus { + return statusCode === -4 + ? ParityCheckStatus.CANCELLED + : toNumberAlways(statusCode, 0) === 0 + ? ParityCheckStatus.COMPLETED + : ParityCheckStatus.FAILED; + } + /** * Updates the parity check state * @param wantedState - The desired state for the parity check ('pause', 'resume', 'cancel', 'start') diff --git a/packages/unraid-shared/src/util/data.ts b/packages/unraid-shared/src/util/data.ts index d249b20d1..bd768ef4d 100644 --- a/packages/unraid-shared/src/util/data.ts +++ b/packages/unraid-shared/src/util/data.ts @@ -16,15 +16,15 @@ import type { Get } from "type-fest"; * @returns An array of strings */ export function csvStringToArray( - csvString?: string | null, - opts: { noEmpty?: boolean } = { noEmpty: true } + csvString?: string | null, + opts: { noEmpty?: boolean } = { noEmpty: true } ): string[] { - if (!csvString) return []; - const result = csvString.split(',').map((item) => item.trim()); - if (opts.noEmpty) { - return result.filter((item) => item !== ''); - } - return result; + if (!csvString) return []; + const result = csvString.split(",").map((item) => item.trim()); + if (opts.noEmpty) { + return result.filter((item) => item !== ""); + } + return result; } /** @@ -41,8 +41,23 @@ export function csvStringToArray( * @returns The nested value or undefined if the path is invalid */ export function getNestedValue( - obj: TObj, - path: TPath + obj: TObj, + path: TPath ): Get { - return path.split('.').reduce((acc, part) => acc?.[part], obj as any) as Get; + return path.split(".").reduce((acc, part) => acc?.[part], obj as any) as Get< + TObj, + TPath + >; +} + +/** + * Converts a value to a number. If the value is NaN, returns the default value. + * + * @param value - The value to convert to a number + * @param defaultValue - The default value to return if the value is NaN. Default is 0. + * @returns The number value or the default value if the value is NaN + */ +export function toNumberAlways(value: unknown, defaultValue = 0): number { + const num = Number(value); + return Number.isNaN(num) ? defaultValue : num; }