mirror of
https://github.com/unraid/api.git
synced 2025-12-31 05:29:48 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
|
||||
1080
api/src/core/modules/array/parity-check-status.test.ts
Normal file
1080
api/src/core/modules/array/parity-check-status.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
72
api/src/core/modules/array/parity-check-status.ts
Normal file
72
api/src/core/modules/array/parity-check-status.ts
Normal 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))),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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[];
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user