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',
}
```

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## 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.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
This commit is contained in:
Pujit Mehrotra
2025-08-25 13:22:43 -04:00
committed by GitHub
parent 9df6a3f5eb
commit c508366702
10 changed files with 1307 additions and 51 deletions

View File

@@ -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

View File

@@ -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),
};
};

File diff suppressed because it is too large Load Diff

View File

@@ -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))),
};
}

View File

@@ -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;

View File

@@ -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[];

View File

@@ -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);

View File

@@ -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;

View File

@@ -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<ParityCheck>((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')

View File

@@ -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<TObj extends object, TPath extends string>(
obj: TObj,
path: TPath
obj: TObj,
path: TPath
): Get<TObj, TPath> {
return path.split('.').reduce((acc, part) => acc?.[part], obj as any) as Get<TObj, TPath>;
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;
}