Public/New-DoctorDODeviceReport.ps1
|
function New-DoctorDODeviceReport { <# .SYNOPSIS Writes an offline HTML report of this device's Delivery Optimization health (optionally PDF and JSON). .DESCRIPTION Start here for one device: no DoctorDO deployment is needed, and everything is worked out on the device. New-DoctorDODeviceReport collects once and writes one .html file with the data embedded: the DoctorDO console's device page for this device (Delivery Optimization health, network health and its topology, data flow, download sources and configuration), with the same analysis the console runs. The file is named DoctorDO-<host name>-<yyyyMMdd-HHmmss>.html (UTC), or DoctorDO-report-<yyyyMMdd-HHmmss>.html with -Anonymise; -FileName gives it a fixed name instead, which each run replaces. Open it in Microsoft Edge or Google Chrome. When it has finished, the command opens the report in the default browser, but only when a person is at the session: not when it runs as SYSTEM or a service account, in session 0, in a remote session, in a PowerShell started with -NonInteractive, or from an Intune remediation or platform script, a scheduled task or Configuration Manager. -NoOpen leaves it closed; -Open opens it even where it would not be opened by itself. At a console it also says what it is doing: one line per part of the collection as it finishes, with a progress bar for the part under way, then the connectivity checks counted by area and each one that failed or warned. -InformationAction SilentlyContinue hides that; it is never written where nobody is at the session (as above) or when the output is redirected. -Verbose adds each step with its timing. With -Format Pdf the page is also printed to a PDF with the same name and the extension pdf, by Microsoft Edge (or Google Chrome when Edge is not installed) running headless on this device: every section expanded, every table row drawn, light colours, no buttons, and no header or footer. Nothing is downloaded or installed for it. With -Format Json (or Both, which is Html,Json) the report's data is also written as a JSON file with the same name and the extension json (UTF-8 without a byte order mark, indented), for a script, a ticket or a spreadsheet import. It is exactly the data the HTML embeds, so the two cannot disagree, with two properties added at the top: schema ('doctordo-device-report/1') and collectorVersion. Everything else is the HTML's payload: kind, version, generatedAt, inventory (InventoryV1) and telemetry (an array of one TelemetryV1 report). -Anonymise applies to the JSON and the PDF exactly as it does to the HTML, and the file names have no host name either. The command is read-only: the collector's state file is not read or written, the Delivery Optimization log is not flushed (so DoSvc is not stopped), and nothing is uploaded. The page makes no network requests: its Content-Security-Policy allows none, so it can be opened offline or attached to a ticket. The report identifies the device (host name, Microsoft Entra, tenant and Intune device IDs, AD site, subnet, gateway) unless you add -Anonymise. PAC URLs are never in it, and no user name is read. Where an account turns up in a log line, an error sample or a firewall rule (a profile path, DOMAIN\user, a SID), the forms the scrub recognises are replaced; an unusual form can get through (Get-Help about_DoctorDO.Collector, PRIVACY). .PARAMETER OutputPath The folder the report is written to. It is created if it does not exist. Default: the temp folder of the account running the command. An existing report with the same name is replaced. .PARAMETER Format What to write, one value or several separated by commas: Html (the default), Pdf, Json, or Both. Both is Html and Json, kept for compatibility; prefer Html,Json. Each file is written in -OutputPath with the same name and its own extension, and replaces an existing file with that name. Pdf prints the page with Microsoft Edge, or Google Chrome when Edge is missing, and throws before collecting anything when neither is installed; -Format Pdf on its own keeps only the PDF. Html,Pdf writes both. With Json alone the page (Data\device-report.html) is not needed or read. .PARAMETER FileName The name of the report without a folder or an extension, in place of DoctorDO-<host name>-<yyyyMMdd-HHmmss>. Every format written uses it with its own extension (.html, .json, .pdf), and each run replaces the files of that name in -OutputPath, so a run that repeats, such as an Intune remediation or a scheduled task, keeps one report instead of adding one each time. The name is used exactly as given, also with -Anonymise, so leave the host name out of a report you will share. It must not contain a path separator, a character Windows does not allow in a file name (: * ? " < > |) or an extension (.html, .htm, .json, .pdf), must not start or end with a space or end with a full stop, must not be a name Windows reserves for a device (CON, PRN, AUX, NUL, COM0 to COM9, LPT0 to LPT9), and can be up to 200 characters. The command throws before collecting anything otherwise. .PARAMETER Anonymise A report to share: the local bundle's anonymisation and, in addition, no Entra, tenant or Intune device ID, ConfigMgr site code or AD site, and no host name in the file name. Hashes use a salt made for this report only, so nothing in it can be matched to your fleet data. .PARAMETER SkipConnectivityChecks Do not probe endpoints or download PAC files; the page shows those checks as not collected. .PARAMETER Open Open the report even where the command would not open it by itself, for example in a session it cannot tell is interactive. With -Format Json alone, the JSON file is opened with the program associated with JSON files (it is never opened unasked). Cannot be used with -NoOpen. .PARAMETER NoOpen Do not open the report when it has been written. Without it, the report opens in the default browser when a person is at the session (see the description): the PDF when one was written, otherwise the HTML page. Cannot be used with -Open. .EXAMPLE New-DoctorDODeviceReport Writes the report to the temp folder and opens it in the default browser. Run it from an elevated prompt: without elevation the Delivery Optimization logs and some settings cannot be read, and the report lists them as missing. .EXAMPLE New-DoctorDODeviceReport -OutputPath C:\Temp -Anonymise -Format Html,Pdf Writes a report you can share outside your organisation, for example in a forum post or with a consultant, as a page and as a PDF, and opens the PDF: no host name, no Entra, tenant or Intune IDs, and hashes that cannot be linked to your fleet. .EXAMPLE $r = New-DoctorDODeviceReport -OutputPath "$env:PUBLIC\Documents\DoctorDO" -SkipConnectivityChecks -NoOpen Write-Output ('{0} ({1:N0} KB, {2} facts not collected)' -f $r.Path, ($r.Bytes / 1KB), $r.NotCollected) Writes a report without network probes, for example from an Intune platform script run as SYSTEM on a device in a change-frozen network, to a folder the signed-in user can open, and prints where it went. As SYSTEM the report would not be opened anyway; -NoOpen says so plainly. .EXAMPLE New-DoctorDODeviceReport -OutputPath C:\Temp -Format Both Writes DoctorDO-<host>-<timestamp>.html and DoctorDO-<host>-<timestamp>.json to C:\Temp, from one collection, and opens the page. .EXAMPLE $r = New-DoctorDODeviceReport -Format Json -Anonymise -OutputPath C:\Temp (Get-Content -LiteralPath $r.JsonPath -Raw | ConvertFrom-Json).inventory.dataOrchestration Writes only the anonymised data, no page, and reads one section of it back. Nothing is opened. .EXAMPLE New-DoctorDODeviceReport -OutputPath "$env:ProgramData\Microsoft\IntuneManagementExtension\Logs" -FileName 'DoctorDO-DeviceReport' -Format Html,Json From an Intune remediation run as SYSTEM: writes DoctorDO-DeviceReport.html and DoctorDO-DeviceReport.json into the Intune Management Extension's log folder, which Intune's "Collect diagnostics" gathers, and replaces the files the previous run wrote there, so the folder keeps one report however often the remediation runs. .INPUTS None. You cannot pipe objects to New-DoctorDODeviceReport. .OUTPUTS System.Management.Automation.PSCustomObject with Path (the HTML file; the PDF with -Format Pdf alone; the JSON file with -Format Json alone), Sha256, Bytes (of Path), Format, JsonPath, JsonSha256 and JsonBytes, PdfPath, PdfSha256 and PdfBytes (null unless that file was written), Opened (the file opened, or null), DeviceId, HostName, Anonymised, CollectedAt, NotCollected (the number of facts that could not be read) and DurationMs. .NOTES Runs on Windows only; it throws on other platforms. Elevation is recommended (it warns without it). It throws when the report page (Data\device-report.html) is missing or damaged: reinstall the module. A PDF needs Microsoft Edge or Google Chrome; it throws, naming both, when neither is installed, and when the browser writes no PDF within two minutes. Writes one file per format and changes nothing else. For a PDF alone the page is written beside it for the browser to print and removed again. The page bundles React, lucide-react, Tailwind CSS and the Poppins and Geist Mono fonts, under their own licences (THIRD-PARTY-NOTICES.txt in the module folder). .LINK https://www.doctor-do.com/get-started/ .LINK about_DoctorDO.Collector .LINK Invoke-DoctorDOCollector .LINK Test-DOConnectivity #> [CmdletBinding()] [OutputType([pscustomobject])] param( [string] $OutputPath = [System.IO.Path]::GetTempPath(), [ValidateSet('Html', 'Json', 'Pdf', 'Both')][string[]] $Format = 'Html', # A fixed name instead of the timestamped one (19 Oct 2026), so a scheduled run replaces its report rather than adding one. [ValidateNotNullOrEmpty()][string] $FileName, [switch] $Anonymise, [switch] $SkipConnectivityChecks, [switch] $Open, [switch] $NoOpen ) $sw = [System.Diagnostics.Stopwatch]::StartNew() # The caller's -InformationAction SilentlyContinue: no narration and no "written" lines (they are information too). $quiet = Test-DDInformationQuiet -BoundParameters $PSBoundParameters $DDRun = Start-DDVerboseRun -Command 'New-DoctorDODeviceReport' -Title 'device report' -Sections ($script:DDCollectionSections + 1) -Quiet:$quiet if (-not (Test-DDWindows)) { throw 'New-DoctorDODeviceReport reads Delivery Optimization from this device, so it runs on Windows only.' } if ($Open -and $NoOpen) { throw 'Use -Open or -NoOpen, not both.' } # Checked before anything is collected, like the PDF printer below: a bad name should cost nothing. if ($PSBoundParameters.ContainsKey('FileName')) { $badName = Test-DDReportBaseName -Name $FileName if ($badName) { throw "-FileName '$FileName' cannot be used: $badName." } } # Both is Html and Json (it predates Pdf); any mix can be named, as -Format Html,Pdf. $formats = @($Format | ForEach-Object { if ($_ -eq 'Both') { 'Html'; 'Json' } else { $_ } }) $writeHtml = $formats -contains 'Html' $writeJson = $formats -contains 'Json' $writePdf = $formats -contains 'Pdf' $utf8 = New-Object System.Text.UTF8Encoding($false) $template = $null if ($writeHtml -or $writePdf) { $templatePath = Get-DDReportTemplatePath if (-not (Test-Path -LiteralPath $templatePath -PathType Leaf)) { throw "The device report page (Data/device-report.html) is missing from the module at $templatePath, so the installed copy is incomplete or damaged. Reinstall the module." } $template = [System.IO.File]::ReadAllText($templatePath, $utf8) $unusable = Test-DDReportTemplate -Template $template if ($unusable) { throw "The device report page at $templatePath cannot be used: $unusable. Reinstall the module." } Write-DDVerboseStep -Step 'Report page' -Result { '{0:N0} KB, intact' -f ($template.Length / 1KB) } } $browser = $null if ($writePdf) { # Before collecting: a missing browser should cost seconds, not a full collection. $browser = Find-DDPdfBrowser if ($null -eq $browser) { throw 'A PDF is printed by Microsoft Edge or Google Chrome, and neither is installed on this device. Install Microsoft Edge, or write the page (-Format Html) and print it to PDF from a browser on another device.' } Write-DDVerboseStep -Step 'PDF printer' -Result { '{0}, headless' -f $browser.Name } } if (-not (Test-DDElevated)) { Write-Warning 'Not running elevated: Delivery Optimization logs and some settings cannot be read. The report lists what is missing; run from an elevated prompt for the full picture.' } # A salt for this report only (never the state file's, which reading could move aside) and no endpoint cache. $salt = New-DDRandomHex -Bytes 32 $collection = Get-DDRawCollection -Salt $salt -Anonymise:$Anonymise -SkipConnectivityChecks:$SkipConnectivityChecks -Mode single -IsolateSalt $gaps = Get-DDRequiredFieldGaps -Collection $collection if ($gaps.Count -gt 0) { throw "Required facts could not be read ($($gaps -join ', ')), so there is no report to write." } $inventory = ConvertTo-DDInventory -Collection $collection if ($Anonymise) { $inventory = Protect-DDReportInventory -Inventory $inventory } # One collection is one point in time: the period starts and ends at it, and nothing is a delta (New-DDState). $state = New-DDState $state['lastTelemetryAt'] = $collection.Raw['collectedAt'] $telemetry = New-DDTelemetryFromCollection -Collection $collection -State $state -InventoryHash (Get-DDInventoryHash -Inventory $inventory) -Now $collection.Now $data = [ordered]@{ kind = 'doctordo-device-report'; version = 1; generatedAt = $collection.Raw['collectedAt']; inventory = $inventory; telemetry = @($telemetry) } Write-DDVerboseStep -Step 'Report data' -Result { '{0}; {1} fact(s) not collected' -f $(if ($Anonymise) { 'anonymised' } else { 'identifies the device' }), @($inventory['notCollected']).Count } Start-DDSection -Name 'Report' if (-not (Test-Path -LiteralPath $OutputPath)) { [void](New-Item -ItemType Directory -Path $OutputPath -Force) } $hostName = $inventory['device']['hostname'] $htmlPath = $null $jsonPath = $null $pdfPath = $null $page = $(if ($writeHtml -or $writePdf) { ConvertTo-DDReportHtml -Template $template -Data $data } else { $null }) if ($writeHtml) { $htmlPath = Join-Path -Path $OutputPath -ChildPath (Get-DDReportFileName -HostName $hostName -At $collection.Now -Anonymised:$Anonymise -BaseName $FileName) Write-DDReportFile -Path $htmlPath -Text $page } if ($writeJson) { $jsonPath = Join-Path -Path $OutputPath -ChildPath (Get-DDReportFileName -HostName $hostName -At $collection.Now -Anonymised:$Anonymise -Extension 'json' -BaseName $FileName) Write-DDReportFile -Path $jsonPath -Text (ConvertTo-DDReportJson -Data $data -CollectorVersion (Get-DDCollectorVersion)) Write-DDVerboseStep -Step 'Report JSON' -Result { 'schema doctordo-device-report/1, collector {0}' -f (Get-DDCollectorVersion) } } if ($writePdf) { $pdfPath = Join-Path -Path $OutputPath -ChildPath (Get-DDReportFileName -HostName $hostName -At $collection.Now -Anonymised:$Anonymise -Extension 'pdf' -BaseName $FileName) # The browser prints a file, so a PDF alone still needs the page on disk for a moment: a name of its own, so an # HTML report already in the folder is never touched, removed again whatever happens. $source = $htmlPath $scratch = $null if (-not $writeHtml) { $scratch = Join-Path -Path $OutputPath -ChildPath ('DoctorDO-print-{0}.html' -f [guid]::NewGuid().ToString('N')) Write-DDReportFile -Path $scratch -Text $page $source = $scratch } try { if (Test-Path -LiteralPath $pdfPath) { Remove-Item -LiteralPath $pdfPath -Force } Invoke-DDHeadlessPrint -Browser $browser.Path -Url (Get-DDReportPrintUrl -Path $source) -PdfPath $pdfPath } finally { if ($scratch) { Remove-Item -LiteralPath $scratch -Force -ErrorAction SilentlyContinue } } if (-not (Test-DDPdfFile -Path $pdfPath)) { throw "$($browser.Name) did not write the PDF ($pdfPath). Write the page (-Format Html), open it and print it to PDF from the browser instead." } Write-DDVerboseStep -Step 'Report PDF' -Result { 'printed by {0}, {1:N0} KB' -f $browser.Name, ((Get-Item -LiteralPath $pdfPath).Length / 1KB) } } $path = $(if ($writeHtml) { $htmlPath } elseif ($writePdf) { $pdfPath } else { $jsonPath }) Complete-DDSection -Result { $written = foreach ($f in @(@('HTML', $htmlPath), @('PDF', $pdfPath), @('JSON', $jsonPath))) { if ($f[1]) { '{0} {1:N0} KB' -f $f[0], ((Get-Item -LiteralPath $f[1]).Length / 1KB) } } @($written) -join ', ' } # What to open: the PDF when there is one (it is what was asked for, and what will be sent on), else the page. Only # -Open opens a JSON file: an editor appearing unasked would be a surprise. $view = $(if ($writePdf) { $pdfPath } elseif ($writeHtml) { $htmlPath } else { $null }) $opened = $null if ($Open) { $opened = $(if ($view) { $view } else { $jsonPath }) } elseif ($NoOpen) { Write-DDVerboseStep -Step 'Open' -Result 'not opened (-NoOpen)' } elseif ($null -eq $view) { Write-DDVerboseStep -Step 'Open' -Result 'not opened (JSON only; -Open opens it)' } else { $why = Get-DDNonInteractiveReason if ($why) { Write-DDVerboseStep -Step 'Open' -Result "not opened: $why" } else { $opened = $view } } if ($opened) { Write-DDVerboseStep -Step 'Open' -Result { 'opening {0}' -f (Split-Path -Path $opened -Leaf) } } $result = [pscustomobject]@{ PSTypeName = 'DoctorDO.DeviceReport' Path = $path Sha256 = Get-DDFileSha256 -Path $path Bytes = (Get-Item -LiteralPath $path).Length Format = $Format -join ',' JsonPath = $jsonPath JsonSha256 = $(if ($writeJson) { Get-DDFileSha256 -Path $jsonPath } else { $null }) JsonBytes = $(if ($writeJson) { (Get-Item -LiteralPath $jsonPath).Length } else { $null }) PdfPath = $pdfPath PdfSha256 = $(if ($writePdf) { Get-DDFileSha256 -Path $pdfPath } else { $null }) PdfBytes = $(if ($writePdf) { (Get-Item -LiteralPath $pdfPath).Length } else { $null }) Opened = $opened DeviceId = $inventory['device']['deviceId'] HostName = $hostName Anonymised = [bool]$Anonymise CollectedAt = $collection.Raw['collectedAt'] NotCollected = @($inventory['notCollected']).Count DurationMs = $sw.ElapsedMilliseconds } Write-DDRunSummary -Run $DDRun -Rows $collection.Raw['connectivity'] -NotCollected $result.NotCollected -More 'the report explains each' Stop-DDVerboseRun -Run $DDRun -Command 'New-DoctorDODeviceReport' -Summary ('report written, {0:N0} KB' -f ($result.Bytes / 1KB)) if ($writeHtml -and -not $quiet) { Write-Information -MessageData ('Device report written: {0} ({1:N0} KB)' -f $htmlPath, ((Get-Item -LiteralPath $htmlPath).Length / 1KB)) -InformationAction Continue } if ($writePdf -and -not $quiet) { Write-Information -MessageData ('Device report PDF written: {0} ({1:N0} KB)' -f $pdfPath, ($result.PdfBytes / 1KB)) -InformationAction Continue } if ($writeJson -and -not $quiet) { Write-Information -MessageData ('Device report data (JSON) written: {0} ({1:N0} KB)' -f $jsonPath, ($result.JsonBytes / 1KB)) -InformationAction Continue } if ($opened) { Invoke-Item -LiteralPath $opened } $result } |