@cfour-hi/dsh-plugin-devicex 0.0.0-stage → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,535 @@
1
+ # Device X - Windows fast metrics snapshot.
2
+ #
3
+ # Emits exactly one JSON document on stdout (no other stdout text).
4
+ # Read-only: no admin rights, no network calls, no external modules.
5
+ # CPU usage percent is intentionally NOT computed here: the Node mapper derives
6
+ # it from successive os.cpus() snapshots (same algorithm as the macOS collector).
7
+ $ErrorActionPreference = 'Stop'
8
+
9
+ $errors = @()
10
+
11
+ function To-EpochMs($value) {
12
+ if ($null -eq $value) { return $null }
13
+ try { return ([DateTimeOffset]$value).ToUnixTimeMilliseconds() } catch { return $null }
14
+ }
15
+
16
+ function To-LongOrNull($value) {
17
+ if ($null -eq $value) { return $null }
18
+ try { return [long]$value } catch { return $null }
19
+ }
20
+
21
+ function Invoke-NativeOem([scriptblock]$action) {
22
+ # Native console tools (netsh/tasklist/manage-bde/winget) write OEM/ANSI bytes
23
+ # on a non-English Windows. The bridge sets the console to UTF-8 for the JSON
24
+ # document; temporarily decode native output with the OEM code page so SSIDs,
25
+ # user names and device names are not mojibake, then restore in finally.
26
+ $originalEncoding = [Console]::OutputEncoding
27
+ $oemCodePage = 437
28
+ try {
29
+ $cultureCodePage = [System.Globalization.CultureInfo]::CurrentCulture.TextInfo.OEMCodePage
30
+ if ($cultureCodePage -gt 0) { $oemCodePage = $cultureCodePage }
31
+ [Console]::OutputEncoding = [System.Text.Encoding]::GetEncoding($oemCodePage)
32
+ return & $action
33
+ } finally {
34
+ [Console]::OutputEncoding = $originalEncoding
35
+ }
36
+ }
37
+
38
+ # ---------------------------------------------------------------- host / OS --
39
+ $hostName = $env:COMPUTERNAME
40
+ if (-not $hostName) {
41
+ try { $hostName = [System.Net.Dns]::GetHostName() } catch { $hostName = $null }
42
+ }
43
+
44
+ $osInfo = $null
45
+ $memoryInfo = $null
46
+ try {
47
+ $os = Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop
48
+ $bootMs = To-EpochMs $os.LastBootUpTime
49
+ $displayVersion = $null
50
+ try {
51
+ $winVersionKey = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction Stop
52
+ if ($winVersionKey.DisplayVersion) { $displayVersion = [string]$winVersionKey.DisplayVersion }
53
+ } catch { $displayVersion = $null }
54
+ $osInfo = [ordered]@{
55
+ caption = [string]$os.Caption
56
+ displayVersion = $displayVersion
57
+ version = [string]$os.Version
58
+ build = [string]$os.BuildNumber
59
+ lastBootUpTimeMs = $bootMs
60
+ osArchitecture = [string]$os.OSArchitecture
61
+ }
62
+
63
+ $totalBytes = $null
64
+ $freeBytes = $null
65
+ if ($null -ne $os.TotalVisibleMemorySize) { $totalBytes = To-LongOrNull ([long]$os.TotalVisibleMemorySize * 1024) }
66
+ if ($null -ne $os.FreePhysicalMemory) { $freeBytes = To-LongOrNull ([long]$os.FreePhysicalMemory * 1024) }
67
+
68
+ $cacheBytes = $null
69
+ $availableBytes = $null
70
+ $committedBytes = $null
71
+ $commitLimitBytes = $null
72
+ $poolPagedBytes = $null
73
+ $poolNonPagedBytes = $null
74
+ try {
75
+ $memoryPerf = Get-CimInstance -ClassName Win32_PerfFormattedData_PerfOS_Memory -ErrorAction Stop
76
+ if ($memoryPerf) {
77
+ $cacheBytes = To-LongOrNull $memoryPerf.CacheBytes
78
+ $availableBytes = To-LongOrNull $memoryPerf.AvailableBytes
79
+ $committedBytes = To-LongOrNull $memoryPerf.CommittedBytes
80
+ $commitLimitBytes = To-LongOrNull $memoryPerf.CommitLimit
81
+ $poolPagedBytes = To-LongOrNull $memoryPerf.PoolPagedBytes
82
+ $poolNonPagedBytes = To-LongOrNull $memoryPerf.PoolNonPagedBytes
83
+ }
84
+ } catch { $errors += ('Win32_PerfFormattedData_PerfOS_Memory: ' + $_.Exception.Message) }
85
+
86
+ $pageTotalBytes = $null
87
+ $pageUsedBytes = $null
88
+ try {
89
+ $pageFiles = @(Get-CimInstance -ClassName Win32_PageFileUsage -ErrorAction Stop)
90
+ $allocatedMb = 0
91
+ $usedMb = 0
92
+ $pageFileSeen = $false
93
+ foreach ($pageFile in $pageFiles) {
94
+ if ($null -ne $pageFile.AllocatedBaseSize) { $allocatedMb += [double]$pageFile.AllocatedBaseSize; $pageFileSeen = $true }
95
+ if ($null -ne $pageFile.CurrentUsage) { $usedMb += [double]$pageFile.CurrentUsage }
96
+ }
97
+ if ($pageFileSeen) {
98
+ $pageTotalBytes = [long][math]::Round($allocatedMb * 1MB)
99
+ $pageUsedBytes = [long][math]::Round($usedMb * 1MB)
100
+ }
101
+ } catch { $errors += ('Win32_PageFileUsage: ' + $_.Exception.Message) }
102
+
103
+ $memoryInfo = [ordered]@{
104
+ totalBytes = $totalBytes
105
+ freeBytes = $freeBytes
106
+ cacheBytes = $cacheBytes
107
+ availableBytes = $availableBytes
108
+ committedBytes = $committedBytes
109
+ commitLimitBytes = $commitLimitBytes
110
+ poolPagedBytes = $poolPagedBytes
111
+ poolNonPagedBytes = $poolNonPagedBytes
112
+ pageFileTotalBytes = $pageTotalBytes
113
+ pageFileUsedBytes = $pageUsedBytes
114
+ }
115
+ } catch {
116
+ $errors += ('Win32_OperatingSystem: ' + $_.Exception.Message)
117
+ }
118
+
119
+ # --------------------------------------------------------------------- CPU --
120
+ $cpuInfo = $null
121
+ try {
122
+ $processors = @(Get-CimInstance -ClassName Win32_Processor -ErrorAction Stop)
123
+ if ($processors.Count -gt 0) {
124
+ $first = $processors[0]
125
+ $physicalCores = 0
126
+ $logicalCores = 0
127
+ foreach ($processor in $processors) {
128
+ if ($null -ne $processor.NumberOfCores) { $physicalCores += [int]$processor.NumberOfCores }
129
+ if ($null -ne $processor.NumberOfLogicalProcessors) { $logicalCores += [int]$processor.NumberOfLogicalProcessors }
130
+ }
131
+ $cpuInfo = [ordered]@{
132
+ name = [string]$first.Name
133
+ manufacturer = [string]$first.Manufacturer
134
+ physicalCores = $physicalCores
135
+ logicalCores = $logicalCores
136
+ maxClockSpeedMHz = To-LongOrNull $first.MaxClockSpeed
137
+ architecture = To-LongOrNull $first.Architecture
138
+ }
139
+ }
140
+ } catch { $errors += ('Win32_Processor: ' + $_.Exception.Message) }
141
+
142
+ # -------------------------------------------------------------------- disk --
143
+ $diskInfo = $null
144
+ try {
145
+ $drives = @(Get-CimInstance -ClassName Win32_LogicalDisk -Filter 'DriveType=3' -ErrorAction Stop)
146
+ $totalDisk = [long]0
147
+ $freeDisk = [long]0
148
+ $diskSeen = $false
149
+ foreach ($drive in $drives) {
150
+ if ($null -ne $drive.Size) { $totalDisk += [long]$drive.Size; $diskSeen = $true }
151
+ if ($null -ne $drive.FreeSpace) { $freeDisk += [long]$drive.FreeSpace }
152
+ }
153
+ if ($diskSeen) {
154
+ $diskInfo = [ordered]@{
155
+ totalBytes = $totalDisk
156
+ freeBytes = $freeDisk
157
+ usedBytes = [math]::Max([long]0, $totalDisk - $freeDisk)
158
+ }
159
+ }
160
+ } catch { $errors += ('Win32_LogicalDisk: ' + $_.Exception.Message) }
161
+
162
+ # Cumulative disk byte counters: the Node side turns these into per-second rates.
163
+ $diskIo = $null
164
+ try {
165
+ $diskPerf = @(Get-CimInstance -ClassName Win32_PerfRawData_PerfDisk_PhysicalDisk -ErrorAction Stop)
166
+ $readBytes = [long]0
167
+ $writeBytes = [long]0
168
+ $perfSeen = $false
169
+ $totalRow = $null
170
+ foreach ($row in $diskPerf) {
171
+ if ([string]$row.Name -eq '_Total') { $totalRow = $row; break }
172
+ }
173
+ if ($null -ne $totalRow) {
174
+ $readBytes = To-LongOrNull $totalRow.DiskReadBytesPersec
175
+ $writeBytes = To-LongOrNull $totalRow.DiskWriteBytesPersec
176
+ if ($null -ne $readBytes) { $perfSeen = $true }
177
+ } else {
178
+ foreach ($row in $diskPerf) {
179
+ $readValue = To-LongOrNull $row.DiskReadBytesPersec
180
+ $writeValue = To-LongOrNull $row.DiskWriteBytesPersec
181
+ if ($null -ne $readValue) { $readBytes += $readValue; $perfSeen = $true }
182
+ if ($null -ne $writeValue) { $writeBytes += $writeValue }
183
+ }
184
+ }
185
+ if ($perfSeen) {
186
+ $diskIo = [ordered]@{ read = $readBytes; write = $writeBytes }
187
+ }
188
+ } catch { $errors += ('Win32_PerfRawData_PerfDisk_PhysicalDisk: ' + $_.Exception.Message) }
189
+
190
+ # ----------------------------------------------------------------- network --
191
+ $networkInfo = $null
192
+ try {
193
+ $defaultInterface = $null
194
+ $defaultGateway = $null
195
+ try {
196
+ # The effective Windows route metric is RouteMetric + InterfaceMetric (a
197
+ # two-key Sort-Object compares lexicographically, not by the sum), so sort
198
+ # by the numeric sum to report the real egress adapter (network.setDns uses
199
+ # this alias).
200
+ $routes = @(Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction Stop | Sort-Object -Property @{Expression = { [long]$_.RouteMetric + [long]$_.InterfaceMetric }})
201
+ if ($routes.Count -gt 0) {
202
+ $defaultInterface = [string]$routes[0].InterfaceAlias
203
+ $defaultGateway = [string]$routes[0].NextHop
204
+ }
205
+ } catch { $errors += ('Get-NetRoute: ' + $_.Exception.Message) }
206
+
207
+ $primaryIpv4 = @()
208
+ if ($defaultInterface) {
209
+ try {
210
+ $addresses = @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop)
211
+ foreach ($address in $addresses) {
212
+ if ([string]$address.InterfaceAlias -eq $defaultInterface -and [string]$address.IPAddress -ne '127.0.0.1') {
213
+ $primaryIpv4 += [string]$address.IPAddress
214
+ }
215
+ }
216
+ } catch { $errors += ('Get-NetIPAddress: ' + $_.Exception.Message) }
217
+ }
218
+
219
+ # Cumulative adapter byte counters (loopback excluded) for delta-based rates.
220
+ $counters = $null
221
+ try {
222
+ $statistics = @(Get-NetAdapterStatistics -ErrorAction Stop)
223
+ $receivedBytes = [long]0
224
+ $sentBytes = [long]0
225
+ $counterSeen = $false
226
+ foreach ($statistic in $statistics) {
227
+ $name = [string]$statistic.Name
228
+ if ($name -match 'Loopback') { continue }
229
+ $received = To-LongOrNull $statistic.ReceivedBytes
230
+ $sent = To-LongOrNull $statistic.SentBytes
231
+ if ($null -ne $received) { $receivedBytes += $received; $counterSeen = $true }
232
+ if ($null -ne $sent) { $sentBytes += $sent }
233
+ }
234
+ if ($counterSeen) {
235
+ $counters = [ordered]@{ read = $receivedBytes; write = $sentBytes }
236
+ }
237
+ } catch { $errors += ('Get-NetAdapterStatistics: ' + $_.Exception.Message) }
238
+
239
+ # Wi-Fi via netsh. Field labels are localized by Windows, so the parser keys
240
+ # off the untranslated tokens that netsh always prints ("SSID", "BSSID",
241
+ # "Mbps", "...%") and falls back to positional/value heuristics.
242
+ $wifi = $null
243
+ try {
244
+ $wlanLines = @(Invoke-NativeOem { & netsh wlan show interfaces 2>$null })
245
+ if ($wlanLines.Count -gt 0) {
246
+ $parsedWifi = [ordered]@{}
247
+ $macValues = @()
248
+ $values = @()
249
+ foreach ($line in $wlanLines) {
250
+ $text = [string]$line
251
+ if ($text -match '^\s*([^:]+?)\s*:\s*(.*?)\s*$') {
252
+ $label = $matches[1]
253
+ $value = $matches[2]
254
+ $values += [pscustomobject]@{ label = $label; value = $value }
255
+ if ($value -match '^([0-9A-Fa-f]{2}[:-]){5}[0-9A-Fa-f]{2}$') { $macValues += $value }
256
+ }
257
+ }
258
+ $firstValue = $null
259
+ if ($values.Count -gt 0) { $firstValue = [string]$values[0].value }
260
+ if ($firstValue -and -not $parsedWifi.name) { $parsedWifi.name = $firstValue }
261
+ foreach ($entry in $values) {
262
+ $label = [string]$entry.label
263
+ $value = [string]$entry.value
264
+ if ($label -eq 'SSID') { $parsedWifi.ssid = $value; continue }
265
+ if ($label -eq 'BSSID' -or $label -eq 'AP BSSID') { $parsedWifi.bssid = $value; continue }
266
+ if ($label -eq 'Name') { $parsedWifi.name = $value; continue }
267
+ if ($label -eq 'State') { $parsedWifi.state = $value; continue }
268
+ if ($label -eq 'Authentication') { $parsedWifi.authentication = $value; continue }
269
+ if ($label -eq 'Radio type') { $parsedWifi.radioType = $value; continue }
270
+ if ($value -match '^(\d{1,3})%$') { $parsedWifi.signalPercent = [int]$matches[1]; continue }
271
+ if ($label -match 'Mbps') {
272
+ if (-not $parsedWifi.receiveRateMbps) { $parsedWifi.receiveRateMbps = [double]$value }
273
+ else { $parsedWifi.transmitRateMbps = [double]$value }
274
+ continue
275
+ }
276
+ if ($value -match '^\d{1,3}$') {
277
+ $number = [int]$value
278
+ if ($number -ge 1 -and $number -le 233 -and -not $parsedWifi.channel) { $parsedWifi.channel = $number }
279
+ }
280
+ }
281
+ if (-not $parsedWifi.bssid -and $macValues.Count -gt 1) { $parsedWifi.bssid = [string]$macValues[$macValues.Count - 1] }
282
+ if ($parsedWifi.ssid -and $parsedWifi.ssid.Length -gt 0) {
283
+ if (-not $parsedWifi.state) { $parsedWifi.state = 'connected' }
284
+ $wifi = $parsedWifi
285
+ }
286
+ }
287
+ } catch { $errors += ('netsh wlan show interfaces: ' + $_.Exception.Message) }
288
+
289
+ $networkInfo = [ordered]@{
290
+ defaultInterface = $defaultInterface
291
+ defaultGateway = $defaultGateway
292
+ primaryIpv4 = $primaryIpv4
293
+ counters = $counters
294
+ wifi = $wifi
295
+ }
296
+ } catch { $errors += ('network metrics: ' + $_.Exception.Message) }
297
+
298
+ # ----------------------------------------------------------------- battery --
299
+ $battery = $null
300
+ try {
301
+ $batteries = @(Get-CimInstance -ClassName Win32_Battery -ErrorAction Stop)
302
+ if ($batteries.Count -gt 0) {
303
+ $batteryDevice = $batteries[0]
304
+ $percent = $null
305
+ if ($null -ne $batteryDevice.EstimatedChargeRemaining) { $percent = [int]$batteryDevice.EstimatedChargeRemaining }
306
+ $status = $null
307
+ if ($null -ne $batteryDevice.BatteryStatus) { $status = [int]$batteryDevice.BatteryStatus }
308
+ $powerOnline = $null
309
+ if ($null -ne $batteryDevice.PowerOnline) { $powerOnline = [bool]$batteryDevice.PowerOnline }
310
+ $charging = $false
311
+ if ($status -eq 6 -or $status -eq 7 -or $status -eq 8 -or $status -eq 9) { $charging = $true }
312
+ $plugged = $false
313
+ if ($powerOnline -eq $true) { $plugged = $true }
314
+ if ($status -eq 3 -or $charging) { $plugged = $true }
315
+ $powerSource = 'Battery Power'
316
+ if ($plugged) { $powerSource = 'AC Power' }
317
+ $remaining = $null
318
+ if (-not $plugged -and $null -ne $batteryDevice.EstimatedRunTime) {
319
+ $runTime = [int]$batteryDevice.EstimatedRunTime
320
+ if ($runTime -gt 0 -and $runTime -lt 6000) { $remaining = $runTime }
321
+ }
322
+ $temperatureC = $null
323
+ try {
324
+ $wmib = Get-CimInstance -Namespace 'root/wmi' -ClassName BatteryStatus -ErrorAction Stop
325
+ if ($wmib -and $null -ne $wmib.Temperature) {
326
+ $temperatureC = [math]::Round(([double]$wmib.Temperature / 10.0) - 273.15, 1)
327
+ }
328
+ } catch { $temperatureC = $null }
329
+ $designCapacityMah = $null
330
+ $maxCapacityMah = $null
331
+ $healthPercent = $null
332
+ $designMwh = To-LongOrNull $batteryDevice.DesignCapacity
333
+ $fullMwh = To-LongOrNull $batteryDevice.FullChargeCapacity
334
+ $voltageMv = To-LongOrNull $batteryDevice.DesignVoltage
335
+ # Win32_Battery usually exposes both capacities; fall back to the root/wmi
336
+ # battery classes when a driver only publishes them there (both in mWh).
337
+ if ($null -eq $designMwh -or $null -eq $fullMwh) {
338
+ try {
339
+ $staticBattery = @(Get-CimInstance -Namespace 'root/wmi' -ClassName BatteryStaticData -ErrorAction Stop)
340
+ if ($staticBattery.Count -gt 0) {
341
+ if ($null -eq $designMwh -and $null -ne $staticBattery[0].DesignedCapacity) {
342
+ $designMwh = [double]$staticBattery[0].DesignedCapacity
343
+ }
344
+ if ($null -eq $voltageMv -and $null -ne $staticBattery[0].DesignedVoltage) {
345
+ $voltageMv = [double]$staticBattery[0].DesignedVoltage
346
+ }
347
+ }
348
+ $fullBattery = @(Get-CimInstance -Namespace 'root/wmi' -ClassName BatteryFullChargedCapacity -ErrorAction Stop)
349
+ if ($fullBattery.Count -gt 0 -and $null -eq $fullMwh -and $null -ne $fullBattery[0].BatteryFullChargedCapacity) {
350
+ $fullMwh = [double]$fullBattery[0].BatteryFullChargedCapacity
351
+ }
352
+ } catch { }
353
+ }
354
+ if ($null -ne $designMwh -and $null -ne $voltageMv -and $voltageMv -gt 0) {
355
+ $designCapacityMah = [int][math]::Round(([double]$designMwh * 1000.0) / [double]$voltageMv)
356
+ }
357
+ if ($null -ne $fullMwh -and $null -ne $voltageMv -and $voltageMv -gt 0) {
358
+ $maxCapacityMah = [int][math]::Round(([double]$fullMwh * 1000.0) / [double]$voltageMv)
359
+ }
360
+ if ($null -ne $designMwh -and $null -ne $fullMwh -and $designMwh -gt 0) {
361
+ $healthPercent = [int][math]::Round(([double]$fullMwh / [double]$designMwh) * 100.0)
362
+ }
363
+ $battery = [ordered]@{
364
+ present = $true
365
+ percent = $percent
366
+ charging = $charging
367
+ plugged = $plugged
368
+ powerSource = $powerSource
369
+ timeRemainingMin = $remaining
370
+ temperatureC = $temperatureC
371
+ cycleCount = $null
372
+ healthPercent = $healthPercent
373
+ designCapacityMah = $designCapacityMah
374
+ maxCapacityMah = $maxCapacityMah
375
+ status = $status
376
+ }
377
+ }
378
+ } catch { $errors += ('Win32_Battery: ' + $_.Exception.Message) }
379
+
380
+ # ----------------------------------------------------------------- thermal --
381
+ # Windows exposes ACPI thermal zones through root/wmi MSAcpi_ThermalZoneTemperature
382
+ # (tenths of Kelvin). Many desktops/VMs simply do not publish any zone, which is
383
+ # reported as a null cpuCelsius plus the note 'wmi-unsupported' - never guessed.
384
+ # nvidia-smi is optional: absent/again-failing tooling just leaves gpuCelsius
385
+ # null. The Node mapper mirrors batteryCelsius from the battery block above.
386
+ $thermal = $null
387
+ try {
388
+ $thermalSources = @()
389
+ $thermalZones = @()
390
+ $cpuCelsius = $null
391
+ try {
392
+ $rawZones = @(Get-CimInstance -Namespace 'root/wmi' -ClassName MSAcpi_ThermalZoneTemperature -ErrorAction Stop)
393
+ foreach ($zone in $rawZones) {
394
+ if ($null -eq $zone.CurrentTemperature) { continue }
395
+ $zoneCelsius = [math]::Round(([double]$zone.CurrentTemperature / 10.0) - 273.15, 1)
396
+ if ($zoneCelsius -lt 0 -or $zoneCelsius -gt 120) { continue }
397
+ $thermalZones += [ordered]@{
398
+ name = [string]$zone.InstanceName
399
+ celsius = $zoneCelsius
400
+ }
401
+ if ($null -eq $cpuCelsius -or $zoneCelsius -gt $cpuCelsius) { $cpuCelsius = $zoneCelsius }
402
+ }
403
+ if ($null -ne $cpuCelsius) { $thermalSources += 'wmi-thermal-zone' }
404
+ } catch { $errors += ('MSAcpi_ThermalZoneTemperature: ' + $_.Exception.Message) }
405
+
406
+ $gpuCelsius = $null
407
+ $thermalFans = @()
408
+ try {
409
+ $nvidiaSmi = Get-Command -Name 'nvidia-smi' -ErrorAction SilentlyContinue
410
+ if ($null -ne $nvidiaSmi -and $nvidiaSmi.Source) {
411
+ $nvidiaSmiPath = [string]$nvidiaSmi.Source
412
+ $gpuRows = @(& $nvidiaSmiPath '--query-gpu=temperature.gpu,fan.speed' '--format=csv,noheader,nounits' 2>$null)
413
+ if ($gpuRows.Count -gt 0) {
414
+ $gpuFields = [string]$gpuRows[0] -split ','
415
+ $gpuTemp = $null
416
+ if ($gpuFields.Count -ge 1) {
417
+ $parsedTemp = 0.0
418
+ if ([double]::TryParse($gpuFields[0].Trim(), [ref]$parsedTemp)) {
419
+ if ($parsedTemp -ge -20 -and $parsedTemp -le 150) { $gpuTemp = [math]::Round($parsedTemp, 1) }
420
+ }
421
+ }
422
+ $fanPercent = $null
423
+ if ($gpuFields.Count -ge 2) {
424
+ $parsedFan = 0.0
425
+ if ([double]::TryParse($gpuFields[1].Trim(), [ref]$parsedFan)) { $fanPercent = [int][math]::Round($parsedFan) }
426
+ }
427
+ if ($null -ne $gpuTemp) {
428
+ $gpuCelsius = $gpuTemp
429
+ # nvidia-smi reports fan speed as a percentage; RPM is not available,
430
+ # so the mapper keeps name/rpm and drops the percent detail.
431
+ $thermalFans += [ordered]@{ name = 'GPU'; rpm = $null; percent = $fanPercent }
432
+ $thermalSources += 'nvidia-smi'
433
+ }
434
+ }
435
+ }
436
+ } catch { $errors += ('nvidia-smi: ' + $_.Exception.Message) }
437
+
438
+ $thermalNote = $null
439
+ if ($null -eq $cpuCelsius) { $thermalNote = 'wmi-unsupported' }
440
+ $thermal = [ordered]@{
441
+ zones = $thermalZones
442
+ cpuCelsius = $cpuCelsius
443
+ gpuCelsius = $gpuCelsius
444
+ fans = $thermalFans
445
+ sources = $thermalSources
446
+ note = $thermalNote
447
+ }
448
+ } catch { $errors += ('thermal metrics: ' + $_.Exception.Message) }
449
+
450
+ # --------------------------------------------------------------- processes --
451
+ $processInfo = $null
452
+ try {
453
+ $processNames = @{}
454
+ $processCount = 0
455
+ try {
456
+ $running = @(Get-Process -ErrorAction SilentlyContinue)
457
+ $processCount = $running.Count
458
+ foreach ($runningProcess in $running) {
459
+ try { $processNames[[int]$runningProcess.Id] = [string]$runningProcess.ProcessName } catch { }
460
+ }
461
+ } catch { $processCount = 0 }
462
+
463
+ $candidates = @()
464
+ try {
465
+ $processPerf = @(Get-CimInstance -ClassName Win32_PerfFormattedData_PerfProc_Process -ErrorAction Stop)
466
+ foreach ($row in $processPerf) {
467
+ $rawName = [string]$row.Name
468
+ if (-not $rawName) { continue }
469
+ if ($rawName -eq '_Total' -or $rawName -eq 'Idle') { continue }
470
+ $processId = 0
471
+ if ($null -ne $row.IDProcess) { $processId = [int]$row.IDProcess }
472
+ if ($processId -le 0) { continue }
473
+ $cpuPercent = 0.0
474
+ if ($null -ne $row.PercentProcessorTime) { $cpuPercent = [double]$row.PercentProcessorTime }
475
+ $workingSet = $null
476
+ if ($null -ne $row.WorkingSetPrivate) { $workingSet = To-LongOrNull $row.WorkingSetPrivate }
477
+ if ($null -eq $workingSet -and $null -ne $row.WorkingSet) { $workingSet = To-LongOrNull $row.WorkingSet }
478
+ $name = $rawName -replace '#\d+$', ''
479
+ if ($processNames.ContainsKey($processId)) { $name = $processNames[$processId] }
480
+ $candidates += [pscustomobject]@{
481
+ pid = $processId
482
+ name = $name
483
+ cpuPercent = $cpuPercent
484
+ workingSetBytes = $workingSet
485
+ }
486
+ }
487
+ } catch { $errors += ('Win32_PerfFormattedData_PerfProc_Process: ' + $_.Exception.Message) }
488
+
489
+ # The mapper only consumes metrics.processes.top[0..4]; asking for five rows
490
+ # avoids five unnecessary Win32_Process GetOwner round-trips per poll.
491
+ $top = @($candidates | Sort-Object -Property cpuPercent -Descending | Select-Object -First 5)
492
+ $topList = @()
493
+ foreach ($entry in $top) {
494
+ $userName = $null
495
+ try {
496
+ $ownerProcess = Get-CimInstance -ClassName Win32_Process -Filter ('ProcessId = ' + [string]$entry.pid) -ErrorAction Stop
497
+ if ($ownerProcess) {
498
+ $owner = Invoke-CimMethod -InputObject $ownerProcess -MethodName GetOwner -ErrorAction Stop
499
+ if ($owner -and $owner.User) {
500
+ if ($owner.Domain) { $userName = [string]$owner.Domain + '\' + [string]$owner.User }
501
+ else { $userName = [string]$owner.User }
502
+ }
503
+ }
504
+ } catch { $userName = $null }
505
+ $topList += [ordered]@{
506
+ pid = $entry.pid
507
+ name = $entry.name
508
+ cpuPercent = $entry.cpuPercent
509
+ workingSetBytes = $entry.workingSetBytes
510
+ user = $userName
511
+ }
512
+ }
513
+ $processInfo = [ordered]@{
514
+ total = $processCount
515
+ top = $topList
516
+ }
517
+ } catch { $errors += ('process metrics: ' + $_.Exception.Message) }
518
+
519
+ # -------------------------------------------------------------------- emit --
520
+ $result = [ordered]@{
521
+ generatedAt = ([DateTimeOffset]::UtcNow).ToUnixTimeMilliseconds()
522
+ hostname = $hostName
523
+ os = $osInfo
524
+ cpu = $cpuInfo
525
+ memory = $memoryInfo
526
+ disk = $diskInfo
527
+ diskIo = $diskIo
528
+ network = $networkInfo
529
+ battery = $battery
530
+ thermal = $thermal
531
+ processes = $processInfo
532
+ errors = $errors
533
+ }
534
+
535
+ $result | ConvertTo-Json -Depth 8 -Compress