diff --git a/agent/ShopDbEndpointAgent.Core/Contracts.cs b/agent/ShopDbEndpointAgent.Core/Contracts.cs index bd2e448e..ec06728f 100644 --- a/agent/ShopDbEndpointAgent.Core/Contracts.cs +++ b/agent/ShopDbEndpointAgent.Core/Contracts.cs @@ -50,6 +50,13 @@ public sealed class EndpointReport // reports, and on a shop floor PC nobody reboots voluntarily. [JsonPropertyName("pendingreboot")] public bool? PendingReboot { get; set; } + // Physical drive health from two sources: MSFT_PhysicalDisk (the only one + // that reports NVMe) and Win32_DiskDrive.Status (ATA/SATA and fallback). + // Each entry carries a precomputed predictfailure so an alert is a boolean + // test rather than a string match against an enum. Null predictfailure + // means the drive did not answer - unknown, not healthy. + [JsonPropertyName("diskhealth")] public object[]? DiskHealth { get; set; } + // Session-scoped data gathered by a probe running as the signed-in user: // mapped drives and user-owned listeners. Null when nobody is signed in or // the probe did not produce a result this cycle - absent means "not diff --git a/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs index c500d260..6d428db3 100644 --- a/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs +++ b/agent/ShopDbEndpointAgent.Core/EndpointCollector.cs @@ -66,6 +66,7 @@ public sealed class EndpointCollector : ICollector Interfaces = GetInterfaces(), Events = GetCuratedEvents(), Disks = GetDisks(), + DiskHealth = GetDiskHealth(), Bios = GetBios(), Memory = GetMemory(), Processor = GetProcessor(), @@ -1019,6 +1020,129 @@ public sealed class EndpointCollector : ICollector return result.ToArray(); } + // Physical drive health, so a failing disk is caught before it takes the + // machine and whatever was local on it. + // + // Two sources, because neither covers the fleet alone: + // + // MSFT_PhysicalDisk (Storage namespace) is the only one that reports NVMe. + // The classic SMART class, MSStorageDriver_FailurePredictStatus, is ATA/SATA + // only and returns NOTHING on an NVMe box - verified on a two-NVMe machine + // here, where it yielded zero instances while the drives were present and + // healthy. Collecting only that class would have reported an empty disk + // health list on every modern endpoint and looked like "no problems". + // + // Win32_DiskDrive.Status is the old reliable string ("OK"/"Pred Fail") and + // covers anything the Storage namespace does not, including machines where + // that namespace is missing or blocked. + // + // Keyed by DeviceId so the server can pair the two views per drive the same + // way mapped drives are paired. + private object[] GetDiskHealth() + { + var result = new List(); + + // MSFT_PhysicalDisk returns raw enum INTEGERS through + // ManagementObjectSearcher, not the friendly strings Get-PhysicalDisk + // prints. Verified on a live box: Health=0 Op=2 for a healthy NVMe. + // Mapping them here keeps the wire self-describing; shipping bare + // integers would make the server guess at an undocumented enum. + try + { + using var searcher = new ManagementObjectSearcher( + @"root\Microsoft\Windows\Storage", + "SELECT DeviceId, FriendlyName, MediaType, BusType, " + + "HealthStatus, OperationalStatus FROM MSFT_PhysicalDisk"); + foreach (var mo in searcher.Get()) + { + var health = ToInt(mo["HealthStatus"]); + result.Add(new + { + deviceid = mo["DeviceId"]?.ToString()?.Trim() ?? "", + model = mo["FriendlyName"]?.ToString()?.Trim() ?? "", + mediatype = PhysicalDiskMediaType(ToInt(mo["MediaType"])), + bustype = PhysicalDiskBusType(ToInt(mo["BusType"])), + healthstatus = PhysicalDiskHealth(health), + // The actionable bit, precomputed so an alert is a direct + // boolean test rather than a string comparison against an + // enum the server would have to keep in sync with this file. + // Null when health is unreadable: unknown is not healthy, + // but it is not a confident failure either. + predictfailure = health is null ? (bool?)null : health != 0, + source = "physicaldisk", + }); + } + } + catch (Exception error) + { + logger.LogDebug(error, "Physical disk health query failed"); + } + + // Win32_DiskDrive as the ATA/SATA and fallback view. Status is a plain + // string here, no enum mapping needed. + try + { + using var searcher = new ManagementObjectSearcher( + "SELECT DeviceID, Model, Status FROM Win32_DiskDrive"); + foreach (var mo in searcher.Get()) + { + var status = mo["Status"]?.ToString()?.Trim() ?? ""; + result.Add(new + { + deviceid = mo["DeviceID"]?.ToString()?.Trim() ?? "", + model = mo["Model"]?.ToString()?.Trim() ?? "", + mediatype = "", + bustype = "", + healthstatus = status, + // "OK" is the only healthy value this class reports; + // "Pred Fail" is the SMART trip. An empty status means the + // drive did not answer, which is unknown rather than failed. + predictfailure = status.Length == 0 + ? (bool?)null + : !status.Equals("OK", StringComparison.OrdinalIgnoreCase), + source = "diskdrive", + }); + } + } + catch (Exception error) + { + logger.LogDebug(error, "Disk drive status query failed"); + } + + return result.ToArray(); + } + + // MSFT_PhysicalDisk HealthStatus: 0 Healthy, 1 Warning, 2 Unhealthy. + private static string PhysicalDiskHealth(int? value) => value switch + { + 0 => "Healthy", + 1 => "Warning", + 2 => "Unhealthy", + _ => "Unknown", + }; + + // MSFT_PhysicalDisk MediaType: 3 HDD, 4 SSD, 5 SCM. + private static string PhysicalDiskMediaType(int? value) => value switch + { + 3 => "HDD", + 4 => "SSD", + 5 => "SCM", + _ => "Unknown", + }; + + // MSFT_PhysicalDisk BusType. Only the values that actually appear on an + // endpoint are named; anything else stays numeric rather than guessed at. + private static string PhysicalDiskBusType(int? value) => value switch + { + 3 => "ATA", + 7 => "USB", + 8 => "RAID", + 9 => "iSCSI", + 11 => "SATA", + 17 => "NVMe", + _ => value is null ? "Unknown" : value.Value.ToString(), + }; + // Firmware identity from Win32_BIOS: vendor, version string, and release // date. Lets the server track BIOS/UEFI levels for a fleet update campaign. private object? GetBios() @@ -1155,6 +1279,14 @@ public sealed class EndpointCollector : ICollector catch { return null; } } + private static int? ToInt(object? value) + { + if (value is null) + return null; + try { return Convert.ToInt32(value); } + catch { return null; } + } + // Win32 CIM datetimes (yyyymmddHHMMSS.ffffff+zzz) render as an ISO-8601 UTC // string; anything unparseable passes through untouched so nothing is lost. private string? ToBiosDate(string? raw)