From 19aa92174aabd6da02cc7f0cd809bb489cc45515 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Mon, 14 Jul 2025 13:33:34 -0400 Subject: [PATCH 1/3] refactor: add ensureWrite functions to unraid-shared/util/file.ts --- api/src/store/store-sync.ts | 9 ++-- packages/unraid-shared/src/util/file.ts | 56 ++++++++++++++++++++++++- 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/api/src/store/store-sync.ts b/api/src/store/store-sync.ts index 977d8a3fe2..7f1590e8e2 100644 --- a/api/src/store/store-sync.ts +++ b/api/src/store/store-sync.ts @@ -1,6 +1,6 @@ -import { writeFileSync } from 'fs'; import { join } from 'path'; +import { ensureWriteSync } from '@unraid/shared/util/file.js'; import { isEqual } from 'lodash-es'; import type { RootState } from '@app/store/index.js'; @@ -27,8 +27,11 @@ export const startStoreSync = async () => { !isEqual(state, lastState) && state.paths['myservers-config-states'] ) { - writeFileSync(join(state.paths.states, 'config.log'), JSON.stringify(state.config, null, 2)); - writeFileSync( + ensureWriteSync( + join(state.paths.states, 'config.log'), + JSON.stringify(state.config, null, 2) + ); + ensureWriteSync( join(state.paths.states, 'graphql.log'), JSON.stringify(state.minigraph, null, 2) ); diff --git a/packages/unraid-shared/src/util/file.ts b/packages/unraid-shared/src/util/file.ts index 17304300c3..57d96b3e7c 100644 --- a/packages/unraid-shared/src/util/file.ts +++ b/packages/unraid-shared/src/util/file.ts @@ -1,11 +1,24 @@ import { accessSync } from 'fs'; -import { access } from 'fs/promises'; +import { access, mkdir, writeFile } from 'fs/promises'; +import { mkdirSync, writeFileSync } from 'fs'; import { F_OK } from 'node:constants'; +import { dirname } from 'path'; +/** + * Checks if a file exists asynchronously. + * @param path - The file path to check + * @returns Promise that resolves to true if file exists, false otherwise + */ export const fileExists = async (path: string) => access(path, F_OK) .then(() => true) .catch(() => false); + +/** + * Checks if a file exists synchronously. + * @param path - The file path to check + * @returns true if file exists, false otherwise + */ export const fileExistsSync = (path: string) => { try { accessSync(path, F_OK); @@ -14,3 +27,44 @@ export const fileExistsSync = (path: string) => { return false; } }; + +/** + * Writes data to a file, creating parent directories if they don't exist. + * + * This function ensures the directory structure exists before writing the file, + * equivalent to `mkdir -p` followed by file writing. + * + * @param path - The file path to write to + * @param data - The data to write (string or Buffer) + * @throws {Error} If path is invalid (null, empty, or not a string) + * @throws {Error} For any file system errors (EACCES, EPERM, ENOSPC, EISDIR, etc.) + */ +export const ensureWrite = async (path: string, data: string | Buffer) => { + if (!path || typeof path !== 'string') { + throw new Error(`Invalid path provided: ${path}`); + } + + await mkdir(dirname(path), { recursive: true }); + return await writeFile(path, data); +}; + +/** + * Writes data to a file synchronously, creating parent directories if they don't exist. + * + * This function ensures the directory structure exists before writing the file, + * equivalent to `mkdir -p` followed by file writing. + * + * @param path - The file path to write to + * @param data - The data to write (string or Buffer) + * @throws {Error} If path is invalid (null, empty, or not a string) + * @throws {Error} For any file system errors (EACCES, EPERM, ENOSPC, EISDIR, etc.) + */ +export const ensureWriteSync = (path: string, data: string | Buffer) => { + if (!path || typeof path !== 'string') { + throw new Error(`Invalid path provided: ${path}`); + } + + mkdirSync(dirname(path), { recursive: true }); + return writeFileSync(path, data); +}; + From 084606f19efb1acca50aa96a0da9d7d7e2eec819 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Mon, 14 Jul 2025 13:45:40 -0400 Subject: [PATCH 2/3] chore: add `just watch` recipe to packages/unraid-shared --- packages/unraid-shared/justfile | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 packages/unraid-shared/justfile diff --git a/packages/unraid-shared/justfile b/packages/unraid-shared/justfile new file mode 100644 index 0000000000..4ce986de5f --- /dev/null +++ b/packages/unraid-shared/justfile @@ -0,0 +1,9 @@ +# Justfile for unraid-shared + +# Default recipe to run when just is called without arguments +default: + @just --list + +# Watch for changes in src files and run clean + build +watch: + watchexec -r -e ts,tsx -w src -- pnpm build From 7ab903d5abd0b23db68bff498088c7bc48aba635 Mon Sep 17 00:00:00 2001 From: Pujit Mehrotra Date: Mon, 14 Jul 2025 13:46:11 -0400 Subject: [PATCH 3/3] fix: make api robust to missing emhttp states on startups --- api/dev/configs/api.json | 6 +- api/generated-schema.graphql | 682 +++++++++--------- .../mothership-proxy/connection.service.ts | 12 +- 3 files changed, 355 insertions(+), 345 deletions(-) diff --git a/api/dev/configs/api.json b/api/dev/configs/api.json index 6664e3ecbf..a0c013c398 100644 --- a/api/dev/configs/api.json +++ b/api/dev/configs/api.json @@ -1,10 +1,12 @@ { - "version": "4.8.0", + "version": "4.9.5", "extraOrigins": [ "https://google.com", "https://test.com" ], "sandbox": true, "ssoSubIds": [], - "plugins": ["unraid-api-plugin-connect"] + "plugins": [ + "unraid-api-plugin-connect" + ] } \ No newline at end of file diff --git a/api/generated-schema.graphql b/api/generated-schema.graphql index 56dda378a5..ae9ddc2ebe 100644 --- a/api/generated-schema.graphql +++ b/api/generated-schema.graphql @@ -247,347 +247,6 @@ A field whose value conforms to the standard URL format as specified in RFC3986: """ scalar URL -type DiskPartition { - """The name of the partition""" - name: String! - - """The filesystem type of the partition""" - fsType: DiskFsType! - - """The size of the partition in bytes""" - size: Float! -} - -"""The type of filesystem on the disk partition""" -enum DiskFsType { - XFS - BTRFS - VFAT - ZFS - EXT4 - NTFS -} - -type Disk implements Node { - id: PrefixedID! - - """The device path of the disk (e.g. /dev/sdb)""" - device: String! - - """The type of disk (e.g. SSD, HDD)""" - type: String! - - """The model name of the disk""" - name: String! - - """The manufacturer of the disk""" - vendor: String! - - """The total size of the disk in bytes""" - size: Float! - - """The number of bytes per sector""" - bytesPerSector: Float! - - """The total number of cylinders on the disk""" - totalCylinders: Float! - - """The total number of heads on the disk""" - totalHeads: Float! - - """The total number of sectors on the disk""" - totalSectors: Float! - - """The total number of tracks on the disk""" - totalTracks: Float! - - """The number of tracks per cylinder""" - tracksPerCylinder: Float! - - """The number of sectors per track""" - sectorsPerTrack: Float! - - """The firmware revision of the disk""" - firmwareRevision: String! - - """The serial number of the disk""" - serialNum: String! - - """The interface type of the disk""" - interfaceType: DiskInterfaceType! - - """The SMART status of the disk""" - smartStatus: DiskSmartStatus! - - """The current temperature of the disk in Celsius""" - temperature: Float - - """The partitions on the disk""" - partitions: [DiskPartition!]! -} - -"""The type of interface the disk uses to connect to the system""" -enum DiskInterfaceType { - SAS - SATA - USB - PCIE - UNKNOWN -} - -""" -The SMART (Self-Monitoring, Analysis and Reporting Technology) status of the disk -""" -enum DiskSmartStatus { - OK - UNKNOWN -} - -type KeyFile { - location: String - contents: String -} - -type Registration implements Node { - id: PrefixedID! - type: registrationType - keyFile: KeyFile - state: RegistrationState - expiration: String - updateExpiration: String -} - -enum registrationType { - BASIC - PLUS - PRO - STARTER - UNLEASHED - LIFETIME - INVALID - TRIAL -} - -enum RegistrationState { - TRIAL - BASIC - PLUS - PRO - STARTER - UNLEASHED - LIFETIME - EEXPIRED - EGUID - EGUID1 - ETRIAL - ENOKEYFILE - ENOKEYFILE1 - ENOKEYFILE2 - ENOFLASH - ENOFLASH1 - ENOFLASH2 - ENOFLASH3 - ENOFLASH4 - ENOFLASH5 - ENOFLASH6 - ENOFLASH7 - EBLACKLISTED - EBLACKLISTED1 - EBLACKLISTED2 - ENOCONN -} - -type Vars implements Node { - id: PrefixedID! - - """Unraid version""" - version: String - maxArraysz: Int - maxCachesz: Int - - """Machine hostname""" - name: String - timeZone: String - comment: String - security: String - workgroup: String - domain: String - domainShort: String - hideDotFiles: Boolean - localMaster: Boolean - enableFruit: String - - """Should a NTP server be used for time sync?""" - useNtp: Boolean - - """NTP Server 1""" - ntpServer1: String - - """NTP Server 2""" - ntpServer2: String - - """NTP Server 3""" - ntpServer3: String - - """NTP Server 4""" - ntpServer4: String - domainLogin: String - sysModel: String - sysArraySlots: Int - sysCacheSlots: Int - sysFlashSlots: Int - useSsl: Boolean - - """Port for the webui via HTTP""" - port: Int - - """Port for the webui via HTTPS""" - portssl: Int - localTld: String - bindMgt: Boolean - - """Should telnet be enabled?""" - useTelnet: Boolean - porttelnet: Int - useSsh: Boolean - portssh: Int - startPage: String - startArray: Boolean - spindownDelay: String - queueDepth: String - spinupGroups: Boolean - defaultFormat: String - defaultFsType: String - shutdownTimeout: Int - luksKeyfile: String - pollAttributes: String - pollAttributesDefault: String - pollAttributesStatus: String - nrRequests: Int - nrRequestsDefault: Int - nrRequestsStatus: String - mdNumStripes: Int - mdNumStripesDefault: Int - mdNumStripesStatus: String - mdSyncWindow: Int - mdSyncWindowDefault: Int - mdSyncWindowStatus: String - mdSyncThresh: Int - mdSyncThreshDefault: Int - mdSyncThreshStatus: String - mdWriteMethod: Int - mdWriteMethodDefault: String - mdWriteMethodStatus: String - shareDisk: String - shareUser: String - shareUserInclude: String - shareUserExclude: String - shareSmbEnabled: Boolean - shareNfsEnabled: Boolean - shareAfpEnabled: Boolean - shareInitialOwner: String - shareInitialGroup: String - shareCacheEnabled: Boolean - shareCacheFloor: String - shareMoverSchedule: String - shareMoverLogging: Boolean - fuseRemember: String - fuseRememberDefault: String - fuseRememberStatus: String - fuseDirectio: String - fuseDirectioDefault: String - fuseDirectioStatus: String - shareAvahiEnabled: Boolean - shareAvahiSmbName: String - shareAvahiSmbModel: String - shareAvahiAfpName: String - shareAvahiAfpModel: String - safeMode: Boolean - startMode: String - configValid: Boolean - configError: ConfigErrorState - joinStatus: String - deviceCount: Int - flashGuid: String - flashProduct: String - flashVendor: String - regCheck: String - regFile: String - regGuid: String - regTy: registrationType - regState: RegistrationState - - """Registration owner""" - regTo: String - regTm: String - regTm2: String - regGen: String - sbName: String - sbVersion: String - sbUpdated: String - sbEvents: Int - sbState: String - sbClean: Boolean - sbSynced: Int - sbSyncErrs: Int - sbSynced2: Int - sbSyncExit: String - sbNumDisks: Int - mdColor: String - mdNumDisks: Int - mdNumDisabled: Int - mdNumInvalid: Int - mdNumMissing: Int - mdNumNew: Int - mdNumErased: Int - mdResync: Int - mdResyncCorr: String - mdResyncPos: String - mdResyncDb: String - mdResyncDt: String - mdResyncAction: String - mdResyncSize: Int - mdState: String - mdVersion: String - cacheNumDevices: Int - cacheSbNumDisks: Int - fsState: String - - """Human friendly string of array events happening""" - fsProgress: String - - """ - Percentage from 0 - 100 while upgrading a disk or swapping parity drives - """ - fsCopyPrcnt: Int - fsNumMounted: Int - fsNumUnmountable: Int - fsUnmountableMask: String - - """Total amount of user shares""" - shareCount: Int - - """Total amount shares with SMB enabled""" - shareSmbCount: Int - - """Total amount shares with NFS enabled""" - shareNfsCount: Int - - """Total amount shares with AFP enabled""" - shareAfpCount: Int - shareMoverActive: Boolean - csrfToken: String -} - -"""Possible error states for configuration""" -enum ConfigErrorState { - UNKNOWN_ERROR - INELIGIBLE - INVALID - NO_KEY_SERVER - WITHDRAWN -} - type Permission { resource: Resource! actions: [String!]! @@ -961,6 +620,102 @@ enum ThemeName { white } +type DiskPartition { + """The name of the partition""" + name: String! + + """The filesystem type of the partition""" + fsType: DiskFsType! + + """The size of the partition in bytes""" + size: Float! +} + +"""The type of filesystem on the disk partition""" +enum DiskFsType { + XFS + BTRFS + VFAT + ZFS + EXT4 + NTFS +} + +type Disk implements Node { + id: PrefixedID! + + """The device path of the disk (e.g. /dev/sdb)""" + device: String! + + """The type of disk (e.g. SSD, HDD)""" + type: String! + + """The model name of the disk""" + name: String! + + """The manufacturer of the disk""" + vendor: String! + + """The total size of the disk in bytes""" + size: Float! + + """The number of bytes per sector""" + bytesPerSector: Float! + + """The total number of cylinders on the disk""" + totalCylinders: Float! + + """The total number of heads on the disk""" + totalHeads: Float! + + """The total number of sectors on the disk""" + totalSectors: Float! + + """The total number of tracks on the disk""" + totalTracks: Float! + + """The number of tracks per cylinder""" + tracksPerCylinder: Float! + + """The number of sectors per track""" + sectorsPerTrack: Float! + + """The firmware revision of the disk""" + firmwareRevision: String! + + """The serial number of the disk""" + serialNum: String! + + """The interface type of the disk""" + interfaceType: DiskInterfaceType! + + """The SMART status of the disk""" + smartStatus: DiskSmartStatus! + + """The current temperature of the disk in Celsius""" + temperature: Float + + """The partitions on the disk""" + partitions: [DiskPartition!]! +} + +"""The type of interface the disk uses to connect to the system""" +enum DiskInterfaceType { + SAS + SATA + USB + PCIE + UNKNOWN +} + +""" +The SMART (Self-Monitoring, Analysis and Reporting Technology) status of the disk +""" +enum DiskSmartStatus { + OK + UNKNOWN +} + type InfoApps implements Node { id: PrefixedID! @@ -1351,6 +1106,60 @@ type Owner { avatar: String! } +type KeyFile { + location: String + contents: String +} + +type Registration implements Node { + id: PrefixedID! + type: registrationType + keyFile: KeyFile + state: RegistrationState + expiration: String + updateExpiration: String +} + +enum registrationType { + BASIC + PLUS + PRO + STARTER + UNLEASHED + LIFETIME + INVALID + TRIAL +} + +enum RegistrationState { + TRIAL + BASIC + PLUS + PRO + STARTER + UNLEASHED + LIFETIME + EEXPIRED + EGUID + EGUID1 + ETRIAL + ENOKEYFILE + ENOKEYFILE1 + ENOKEYFILE2 + ENOFLASH + ENOFLASH1 + ENOFLASH2 + ENOFLASH3 + ENOFLASH4 + ENOFLASH5 + ENOFLASH6 + ENOFLASH7 + EBLACKLISTED + EBLACKLISTED1 + EBLACKLISTED2 + ENOCONN +} + type ProfileModel implements Node { id: PrefixedID! username: String! @@ -1416,6 +1225,197 @@ type Settings implements Node { api: ApiConfig! } +type Vars implements Node { + id: PrefixedID! + + """Unraid version""" + version: String + maxArraysz: Int + maxCachesz: Int + + """Machine hostname""" + name: String + timeZone: String + comment: String + security: String + workgroup: String + domain: String + domainShort: String + hideDotFiles: Boolean + localMaster: Boolean + enableFruit: String + + """Should a NTP server be used for time sync?""" + useNtp: Boolean + + """NTP Server 1""" + ntpServer1: String + + """NTP Server 2""" + ntpServer2: String + + """NTP Server 3""" + ntpServer3: String + + """NTP Server 4""" + ntpServer4: String + domainLogin: String + sysModel: String + sysArraySlots: Int + sysCacheSlots: Int + sysFlashSlots: Int + useSsl: Boolean + + """Port for the webui via HTTP""" + port: Int + + """Port for the webui via HTTPS""" + portssl: Int + localTld: String + bindMgt: Boolean + + """Should telnet be enabled?""" + useTelnet: Boolean + porttelnet: Int + useSsh: Boolean + portssh: Int + startPage: String + startArray: Boolean + spindownDelay: String + queueDepth: String + spinupGroups: Boolean + defaultFormat: String + defaultFsType: String + shutdownTimeout: Int + luksKeyfile: String + pollAttributes: String + pollAttributesDefault: String + pollAttributesStatus: String + nrRequests: Int + nrRequestsDefault: Int + nrRequestsStatus: String + mdNumStripes: Int + mdNumStripesDefault: Int + mdNumStripesStatus: String + mdSyncWindow: Int + mdSyncWindowDefault: Int + mdSyncWindowStatus: String + mdSyncThresh: Int + mdSyncThreshDefault: Int + mdSyncThreshStatus: String + mdWriteMethod: Int + mdWriteMethodDefault: String + mdWriteMethodStatus: String + shareDisk: String + shareUser: String + shareUserInclude: String + shareUserExclude: String + shareSmbEnabled: Boolean + shareNfsEnabled: Boolean + shareAfpEnabled: Boolean + shareInitialOwner: String + shareInitialGroup: String + shareCacheEnabled: Boolean + shareCacheFloor: String + shareMoverSchedule: String + shareMoverLogging: Boolean + fuseRemember: String + fuseRememberDefault: String + fuseRememberStatus: String + fuseDirectio: String + fuseDirectioDefault: String + fuseDirectioStatus: String + shareAvahiEnabled: Boolean + shareAvahiSmbName: String + shareAvahiSmbModel: String + shareAvahiAfpName: String + shareAvahiAfpModel: String + safeMode: Boolean + startMode: String + configValid: Boolean + configError: ConfigErrorState + joinStatus: String + deviceCount: Int + flashGuid: String + flashProduct: String + flashVendor: String + regCheck: String + regFile: String + regGuid: String + regTy: registrationType + regState: RegistrationState + + """Registration owner""" + regTo: String + regTm: String + regTm2: String + regGen: String + sbName: String + sbVersion: String + sbUpdated: String + sbEvents: Int + sbState: String + sbClean: Boolean + sbSynced: Int + sbSyncErrs: Int + sbSynced2: Int + sbSyncExit: String + sbNumDisks: Int + mdColor: String + mdNumDisks: Int + mdNumDisabled: Int + mdNumInvalid: Int + mdNumMissing: Int + mdNumNew: Int + mdNumErased: Int + mdResync: Int + mdResyncCorr: String + mdResyncPos: String + mdResyncDb: String + mdResyncDt: String + mdResyncAction: String + mdResyncSize: Int + mdState: String + mdVersion: String + cacheNumDevices: Int + cacheSbNumDisks: Int + fsState: String + + """Human friendly string of array events happening""" + fsProgress: String + + """ + Percentage from 0 - 100 while upgrading a disk or swapping parity drives + """ + fsCopyPrcnt: Int + fsNumMounted: Int + fsNumUnmountable: Int + fsUnmountableMask: String + + """Total amount of user shares""" + shareCount: Int + + """Total amount shares with SMB enabled""" + shareSmbCount: Int + + """Total amount shares with NFS enabled""" + shareNfsCount: Int + + """Total amount shares with AFP enabled""" + shareAfpCount: Int + shareMoverActive: Boolean + csrfToken: String +} + +"""Possible error states for configuration""" +enum ConfigErrorState { + UNKNOWN_ERROR + INELIGIBLE + INVALID + NO_KEY_SERVER + WITHDRAWN +} + type VmDomain implements Node { """The unique identifier for the vm (uuid)""" id: PrefixedID! diff --git a/packages/unraid-api-plugin-connect/src/mothership-proxy/connection.service.ts b/packages/unraid-api-plugin-connect/src/mothership-proxy/connection.service.ts index 4736c8097c..33a9178cc8 100644 --- a/packages/unraid-api-plugin-connect/src/mothership-proxy/connection.service.ts +++ b/packages/unraid-api-plugin-connect/src/mothership-proxy/connection.service.ts @@ -130,11 +130,19 @@ export class MothershipConnectionService implements OnModuleInit, OnModuleDestro } async onModuleInit() { - // Crash on startup if these config values are not set initially + // Warn on startup if these config values are not set initially const { unraidVersion, flashGuid, apiVersion } = this.configKeys; + const warnings: string[] = []; [unraidVersion, flashGuid, apiVersion].forEach((key) => { - this.configService.getOrThrow(key); + try { + this.configService.getOrThrow(key); + } catch (error) { + warnings.push(`${key} is not set`); + } }); + if (warnings.length > 0) { + this.logger.warn('Missing config values: %s', warnings.join(', ')); + } // Setup IDENTITY_CHANGED & METADATA_CHANGED events this.setupIdentitySubscription(); this.setupMetadataChangedEvent();