From b8150f4fb85b49d0879010a514db5b935e43b512 Mon Sep 17 00:00:00 2001 From: Jonathan Pitre <15003329+JonathanPitre@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:06:47 -0400 Subject: [PATCH 1/7] feat(html): add Top Files chart and NinjaOne dark mode text fixes Render Top Files bar chart above Top Folders per drive when file items exist. Replace hardcoded #666/#888 muted colors in WYSIWYG fragment HTML with stat-desc so NinjaOne dark mode inherits theme text. Closes #1. Closes #2. Co-authored-by: Cursor --- .../Public/ConvertTo-NinjaOneHtml.Tests.ps1 | 26 ++++++++++++++++++- .../src/UltraTree/Private/Configuration.ps1 | 3 ++- .../UltraTree/Private/New-HtmlBarChart.ps1 | 9 ++++--- .../Private/New-HtmlCleanupSuggestions.ps1 | 4 +-- .../Private/New-HtmlDuplicatesTable.ps1 | 11 ++++---- .../src/UltraTree/Private/New-HtmlTable.ps1 | 4 +-- .../Public/ConvertTo-NinjaOneHtml.ps1 | 13 ++++++++-- 7 files changed, 53 insertions(+), 17 deletions(-) diff --git a/Module/src/Tests/Unit/Public/ConvertTo-NinjaOneHtml.Tests.ps1 b/Module/src/Tests/Unit/Public/ConvertTo-NinjaOneHtml.Tests.ps1 index 8a53d8d..c878af1 100644 --- a/Module/src/Tests/Unit/Public/ConvertTo-NinjaOneHtml.Tests.ps1 +++ b/Module/src/Tests/Unit/Public/ConvertTo-NinjaOneHtml.Tests.ps1 @@ -1,4 +1,4 @@ -BeforeAll { +BeforeAll { Set-Location -Path $PSScriptRoot $ModuleName = 'UltraTree' $PathToManifest = [System.IO.Path]::Combine('..', '..', '..', $ModuleName, "$ModuleName.psd1") @@ -75,6 +75,15 @@ Describe 'ConvertTo-NinjaOneHtml' -Tag Unit { IsDirectory = $true LastModified = "2024-01-01" }) + + $mockScanResults.Items.Add([PSCustomObject]@{ + Drive = "C:" + Path = "C:\pagefile.sys" + Size = "8.00 GB" + SizeBytes = 8GB + IsDirectory = $false + LastModified = "2024-01-01" + }) } It 'Returns string output' { @@ -107,6 +116,21 @@ Describe 'ConvertTo-NinjaOneHtml' -Tag Unit { $html | Should -Match 'TreeSize v1.0.0' } + It 'Contains Top Files chart when file items exist' { + $html = ConvertTo-NinjaOneHtml -ScanResults $mockScanResults + $html | Should -Match 'Top Files' + } + + It 'Contains Top Folders chart when folder items exist' { + $html = ConvertTo-NinjaOneHtml -ScanResults $mockScanResults + $html | Should -Match 'Top Folders' + } + + It 'Does not use hardcoded light-theme muted text color in fragments' { + $html = ConvertTo-NinjaOneHtml -ScanResults $mockScanResults + $html | Should -Not -Match 'color: #666' + } + It 'Accepts pipeline input' { $html = $mockScanResults | ConvertTo-NinjaOneHtml $html | Should -BeOfType [string] diff --git a/Module/src/UltraTree/Private/Configuration.ps1 b/Module/src/UltraTree/Private/Configuration.ps1 index ff7ec12..c9446f9 100644 --- a/Module/src/UltraTree/Private/Configuration.ps1 +++ b/Module/src/UltraTree/Private/Configuration.ps1 @@ -1,4 +1,4 @@ -# UltraTree Module Configuration +# UltraTree Module Configuration # Centralized configuration with nested theme/thresholds $script:Config = @{ @@ -19,6 +19,7 @@ $script:Config = @{ MaxDuplicateGroups = 20 # Max duplicate groups to display MaxPathsPerGroup = 5 # Max paths shown per duplicate group MaxTopFolders = 8 # Top folders in bar chart + MaxTopFiles = 8 # Top files in bar chart MaxFileTypes = 10 # Top file types to show MaxResults = 40 # Max items in results table MaxPathLength = 50 # Truncate paths longer than this diff --git a/Module/src/UltraTree/Private/New-HtmlBarChart.ps1 b/Module/src/UltraTree/Private/New-HtmlBarChart.ps1 index 3175162..7f8ceb9 100644 --- a/Module/src/UltraTree/Private/New-HtmlBarChart.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlBarChart.ps1 @@ -1,4 +1,4 @@ -function New-HtmlBarChart { +function New-HtmlBarChart { <# .SYNOPSIS Creates a horizontal bar chart using Charts.css. @@ -8,10 +8,13 @@ function New-HtmlBarChart { Array of objects with Label and Value properties. .PARAMETER Title The chart title. + .PARAMETER CardStyle + Optional inline CSS for the card element. #> param ( [array]$Items, - [string]$Title = "Top Items" + [string]$Title = "Top Items", + [string]$CardStyle = "" ) if ($null -eq $Items -or $Items.Count -eq 0) { return "" } @@ -47,5 +50,5 @@ $($rows -join "`n") "@ - New-HtmlCard -Title $Title -Icon $icon -Body $body -BodyStyle "padding: 8px;" + New-HtmlCard -Title $Title -Icon $icon -Body $body -BodyStyle "padding: 8px;" -CardStyle $CardStyle } diff --git a/Module/src/UltraTree/Private/New-HtmlCleanupSuggestions.ps1 b/Module/src/UltraTree/Private/New-HtmlCleanupSuggestions.ps1 index 4b3bb6a..0605fd6 100644 --- a/Module/src/UltraTree/Private/New-HtmlCleanupSuggestions.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlCleanupSuggestions.ps1 @@ -1,4 +1,4 @@ -function New-HtmlCleanupSuggestions { +function New-HtmlCleanupSuggestions { <# .SYNOPSIS Creates cleanup suggestions display with categorization. @@ -25,7 +25,7 @@ function New-HtmlCleanupSuggestions { $category = $script:CleanupCategories | Where-Object { $_.Name -eq $sug.Category } $displayName = if ($category) { $category.DisplayName } else { $sug.Path } - "
  • $displayName
    $sizeText
  • " + "
  • $displayName
    $sizeText
  • " } $body = @" diff --git a/Module/src/UltraTree/Private/New-HtmlDuplicatesTable.ps1 b/Module/src/UltraTree/Private/New-HtmlDuplicatesTable.ps1 index 238fd47..6a562cf 100644 --- a/Module/src/UltraTree/Private/New-HtmlDuplicatesTable.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlDuplicatesTable.ps1 @@ -1,4 +1,4 @@ -function New-HtmlDuplicatesTable { +function New-HtmlDuplicatesTable { <# .SYNOPSIS Creates a table displaying duplicate file groups. @@ -38,8 +38,7 @@ function New-HtmlDuplicatesTable { }) -join "
    " if ($remaining -gt 0) { - $mutedColor = Get-ThemeColor -Severity "Muted" - $pathList += "
    +$remaining more" + $pathList += "
    +$remaining more" } $severity = Get-WastedSpaceSeverity -WastedBytes $group.WastedSpace @@ -48,15 +47,15 @@ function New-HtmlDuplicatesTable { @" - $fileName
    $fileCount × $sizeText - $pathList + $fileName
    $fileCount × $sizeText + $pathList $wastedText "@ } @" -

    Duplicate Files (Wasted: $totalWastedText)

    +

    Duplicate Files (Wasted: $totalWastedText)

    diff --git a/Module/src/UltraTree/Private/New-HtmlTable.ps1 b/Module/src/UltraTree/Private/New-HtmlTable.ps1 index a352412..ccd8c64 100644 --- a/Module/src/UltraTree/Private/New-HtmlTable.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlTable.ps1 @@ -1,4 +1,4 @@ -function New-HtmlTable { +function New-HtmlTable { <# .SYNOPSIS Creates a table of files/folders with size and type information. @@ -31,7 +31,7 @@ function New-HtmlTable { - + "@ } diff --git a/Module/src/UltraTree/Public/ConvertTo-NinjaOneHtml.ps1 b/Module/src/UltraTree/Public/ConvertTo-NinjaOneHtml.ps1 index 8dcc127..50a6717 100644 --- a/Module/src/UltraTree/Public/ConvertTo-NinjaOneHtml.ps1 +++ b/Module/src/UltraTree/Public/ConvertTo-NinjaOneHtml.ps1 @@ -1,4 +1,4 @@ -function ConvertTo-NinjaOneHtml { +function ConvertTo-NinjaOneHtml { <# .SYNOPSIS Converts scan results to NinjaOne-compatible HTML report. @@ -80,6 +80,7 @@ function ConvertTo-NinjaOneHtml { $freeColor = Get-ThemeColor -Severity "Free" $driveCleanup = @($ScanResults.CleanupSuggestions | Where-Object { $_.Drive -eq $drive.Drive }) + $driveFiles = @($ScanResults.Items | Where-Object { $_.Drive -eq $drive.Drive -and -not $_.IsDirectory } | Select-Object -First $cfg.Display.MaxTopFiles) $driveFolders = @($ScanResults.Items | Where-Object { $_.Drive -eq $drive.Drive -and $_.IsDirectory } | Select-Object -First $cfg.Display.MaxTopFolders) $driveFileTypes = @($ScanResults.FileTypes | Where-Object { $_.Drive -eq $drive.Drive } | Select-Object -First $cfg.Display.MaxFileTypes) @@ -122,11 +123,19 @@ function ConvertTo-NinjaOneHtml { } else { $checkIcon = Get-ThemeIcon -IconName "CheckCircle" - [void]$html.AppendLine("
      No Cleanup Needed

    No significant cleanup opportunities found.

    ") + [void]$html.AppendLine("
      No Cleanup Needed

    No significant cleanup opportunities found.

    ") } [void]$html.AppendLine('') [void]$html.AppendLine('
    ') + if ($driveFiles.Count -gt 0) { + $fileChartItems = $driveFiles | ForEach-Object { + $label = Split-Path $_.Path -Leaf + if ([string]::IsNullOrEmpty($label)) { $label = $_.Path } + @{ Label = $label; Value = $_.SizeBytes } + } + [void]$html.AppendLine((New-HtmlBarChart -Items $fileChartItems -Title "Top Files" -CardStyle "margin-bottom: 12px;")) + } if ($driveFolders.Count -gt 0) { $chartItems = $driveFolders | ForEach-Object { $label = Split-Path $_.Path -Leaf From 5029569bb22c24980c54a7ae29ef49e63bd5833a Mon Sep 17 00:00:00 2001 From: Jonathan Pitre <15003329+JonathanPitre@users.noreply.github.com> Date: Tue, 30 Jun 2026 17:19:57 -0400 Subject: [PATCH 2/7] fix(html): readable info-card text in NinjaOne dark mode Access Errors and other info-card warnings used light inherited text on pale yellow WYSIWYG backgrounds. Force dark title/body colors on info cards; add screenshot for issue #2. Co-authored-by: Cursor --- .../Unit/Private/HtmlGeneration.Tests.ps1 | 4 +- .../Public/ConvertTo-NinjaOneHtml.Tests.ps1 | 67 ++++++++++++------ .../UltraTree/Private/New-HtmlInfoCard.ps1 | 11 ++- .../ninjaone-dark-mode-access-errors.png | Bin 0 -> 6508 bytes 4 files changed, 56 insertions(+), 26 deletions(-) create mode 100644 docs/images/ninjaone-dark-mode-access-errors.png diff --git a/Module/src/Tests/Unit/Private/HtmlGeneration.Tests.ps1 b/Module/src/Tests/Unit/Private/HtmlGeneration.Tests.ps1 index 5cdff87..d4af90d 100644 --- a/Module/src/Tests/Unit/Private/HtmlGeneration.Tests.ps1 +++ b/Module/src/Tests/Unit/Private/HtmlGeneration.Tests.ps1 @@ -1,4 +1,4 @@ -BeforeAll { +BeforeAll { Set-Location -Path $PSScriptRoot $ModuleName = 'UltraTree' $PathToManifest = [System.IO.Path]::Combine('..', '..', '..', $ModuleName, "$ModuleName.psd1") @@ -42,6 +42,8 @@ Describe 'HTML Generation Functions' -Tag Unit { $html = New-HtmlInfoCard -Title "Test" -Description "Desc" -Type "Warning" $html | Should -Match 'class="info-card warning"' $html | Should -Match 'fa-solid fa-triangle-exclamation' + $html | Should -Match 'info-title" style="color: #333;"' + $html | Should -Match 'info-description" style="color: #666;"' } } diff --git a/Module/src/Tests/Unit/Public/ConvertTo-NinjaOneHtml.Tests.ps1 b/Module/src/Tests/Unit/Public/ConvertTo-NinjaOneHtml.Tests.ps1 index c878af1..ab711b8 100644 --- a/Module/src/Tests/Unit/Public/ConvertTo-NinjaOneHtml.Tests.ps1 +++ b/Module/src/Tests/Unit/Public/ConvertTo-NinjaOneHtml.Tests.ps1 @@ -59,31 +59,31 @@ Describe 'ConvertTo-NinjaOneHtml' -Tag Unit { # Add mock drive info $mockScanResults.DriveInfo.Add([PSCustomObject]@{ - Drive = "C:" - TotalSize = 500GB - UsedSpace = 250GB - FreeSpace = 250GB - UsedPercent = 50.0 - }) + Drive = 'C:' + TotalSize = 500GB + UsedSpace = 250GB + FreeSpace = 250GB + UsedPercent = 50.0 + }) # Add mock items $mockScanResults.Items.Add([PSCustomObject]@{ - Drive = "C:" - Path = "C:\Windows" - Size = "25.00 GB" - SizeBytes = 25GB - IsDirectory = $true - LastModified = "2024-01-01" - }) + Drive = 'C:' + Path = 'C:\Windows' + Size = '25.00 GB' + SizeBytes = 25GB + IsDirectory = $true + LastModified = '2024-01-01' + }) $mockScanResults.Items.Add([PSCustomObject]@{ - Drive = "C:" - Path = "C:\pagefile.sys" - Size = "8.00 GB" - SizeBytes = 8GB - IsDirectory = $false - LastModified = "2024-01-01" - }) + Drive = 'C:' + Path = 'C:\pagefile.sys' + Size = '8.00 GB' + SizeBytes = 8GB + IsDirectory = $false + LastModified = '2024-01-01' + }) } It 'Returns string output' { @@ -126,9 +126,32 @@ Describe 'ConvertTo-NinjaOneHtml' -Tag Unit { $html | Should -Match 'Top Folders' } - It 'Does not use hardcoded light-theme muted text color in fragments' { + It 'Does not use hardcoded muted text outside info cards' { $html = ConvertTo-NinjaOneHtml -ScanResults $mockScanResults - $html | Should -Not -Match 'color: #666' + $htmlWithoutInfoCards = ($html -split '
    -
    $Title
    -
    $Description
    +
    $Title
    +
    $Description
    "@ diff --git a/docs/images/ninjaone-dark-mode-access-errors.png b/docs/images/ninjaone-dark-mode-access-errors.png new file mode 100644 index 0000000000000000000000000000000000000000..c8cd2dd87722921ef4533fba377bfd48f5196cf7 GIT binary patch literal 6508 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 literal 0 HcmV?d00001 From f25b7a58337b5c53c135d972a541af0cb6193fd1 Mon Sep 17 00:00:00 2001 From: Jonathan Pitre <15003329+JonathanPitre@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:54:31 -0400 Subject: [PATCH 3/7] feat(html): native NinjaOne ranked tables and two-column layout Replace per-drive bar charts with full-width Top Files / Top Folders ranked tables, Cleanup + File Types in col-xl-6 pairs, and optional All Results via -ShowAllResults. Add -MaxTopFiles, -MaxTopFolders, and -FooterSuffix parameters. Footer uses UltraTree branding with stat-desc. Bump module to 1.0.2. Closes #1. Closes #2. Co-authored-by: Cursor --- .../Public/ConvertTo-NinjaOneHtml.Tests.ps1 | 40 +++++++- .../src/UltraTree/Private/Configuration.ps1 | 6 +- .../UltraTree/Private/New-HtmlRankedStack.ps1 | 31 ++++++ .../Public/ConvertTo-NinjaOneHtml.ps1 | 95 ++++++++++++------- Module/src/UltraTree/UltraTree.psd1 | 4 +- 5 files changed, 135 insertions(+), 41 deletions(-) create mode 100644 Module/src/UltraTree/Private/New-HtmlRankedStack.ps1 diff --git a/Module/src/Tests/Unit/Public/ConvertTo-NinjaOneHtml.Tests.ps1 b/Module/src/Tests/Unit/Public/ConvertTo-NinjaOneHtml.Tests.ps1 index ab711b8..f44e6ef 100644 --- a/Module/src/Tests/Unit/Public/ConvertTo-NinjaOneHtml.Tests.ps1 +++ b/Module/src/Tests/Unit/Public/ConvertTo-NinjaOneHtml.Tests.ps1 @@ -18,6 +18,14 @@ Describe 'ConvertTo-NinjaOneHtml' -Tag Unit { $command = Get-Command ConvertTo-NinjaOneHtml $command.Parameters['ScanResults'].Attributes.ValueFromPipeline | Should -Contain $true } + + It 'Has optional MaxTopFiles, MaxTopFolders, ShowAllResults, and FooterSuffix parameters' { + $command = Get-Command ConvertTo-NinjaOneHtml + $command.Parameters.ContainsKey('MaxTopFiles') | Should -Be $true + $command.Parameters.ContainsKey('MaxTopFolders') | Should -Be $true + $command.Parameters.ContainsKey('ShowAllResults') | Should -Be $true + $command.Parameters.ContainsKey('FooterSuffix') | Should -Be $true + } } Context 'Help documentation' { @@ -111,26 +119,35 @@ Describe 'ConvertTo-NinjaOneHtml' -Tag Unit { $html | Should -Match 'Cleanup Potential' } - It 'Contains version footer' { + It 'Contains UltraTree version footer' { $html = ConvertTo-NinjaOneHtml -ScanResults $mockScanResults - $html | Should -Match 'TreeSize v1.0.0' + $html | Should -Match 'UltraTree v1.0.2' + $html | Should -Not -Match 'TreeSize v' } - It 'Contains Top Files chart when file items exist' { + It 'Contains Top Files ranked table when file items exist' { $html = ConvertTo-NinjaOneHtml -ScanResults $mockScanResults $html | Should -Match 'Top Files' + $html | Should -Match '
    $typeIcon $($item.Path) $($item.Size)$lastMod$lastMod
    ' } - It 'Contains Top Folders chart when folder items exist' { + It 'Contains Top Folders ranked table when folder items exist' { $html = ConvertTo-NinjaOneHtml -ScanResults $mockScanResults $html | Should -Match 'Top Folders' } + It 'Uses two-column Cleanup and File Types layout' { + $html = ConvertTo-NinjaOneHtml -ScanResults $mockScanResults + $html | Should -Match 'col-xl-6 col-lg-6 col-md-12 d-flex flex-column' + $html | Should -Not -Match 'col-xl-4 col-lg-4 col-md-12 d-flex' + } + It 'Does not use hardcoded muted text outside info cards' { $html = ConvertTo-NinjaOneHtml -ScanResults $mockScanResults $htmlWithoutInfoCards = ($html -split '
    + param ( + [string[]]$Sections + ) + + $parts = @($Sections | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + if ($parts.Count -eq 0) { return '' } + + $cards = for ($i = 0; $i -lt $parts.Count; $i++) { + $card = $parts[$i] -replace '
    +
    +$($cards -join "`n") +
    +
    +"@ +} diff --git a/Module/src/UltraTree/Public/ConvertTo-NinjaOneHtml.ps1 b/Module/src/UltraTree/Public/ConvertTo-NinjaOneHtml.ps1 index 50a6717..0d66c69 100644 --- a/Module/src/UltraTree/Public/ConvertTo-NinjaOneHtml.ps1 +++ b/Module/src/UltraTree/Public/ConvertTo-NinjaOneHtml.ps1 @@ -5,10 +5,19 @@ .DESCRIPTION Transforms the output from Get-FolderSizes into a formatted HTML report suitable for display in NinjaOne WYSIWYG custom fields. Includes statistics - cards, charts, tables, and cleanup recommendations. + cards, ranked Top Files / Top Folders tables, cleanup suggestions, and + file type breakdown per drive. .PARAMETER ScanResults The PSCustomObject output from Get-FolderSizes containing Items, FileTypes, CleanupSuggestions, Duplicates, DriveInfo, and statistics. + .PARAMETER MaxTopFiles + Maximum file rows in per-drive Top Files table. Defaults to Display.MaxTopFiles (50). + .PARAMETER MaxTopFolders + Maximum folder rows in per-drive Top Folders table. Defaults to Display.MaxTopFolders (25). + .PARAMETER ShowAllResults + When true, includes the full "All Results by Size" table. Default true for backward compatibility. + .PARAMETER FooterSuffix + Optional text appended after the UltraTree version in the footer (e.g. integrator script version). .OUTPUTS String containing HTML markup for the report. .EXAMPLE @@ -17,7 +26,7 @@ $html | Ninja-Property-Set-Piped treesize .EXAMPLE $results = Get-FolderSizes -DriveLetter C - $html = ConvertTo-NinjaOneHtml -ScanResults $results + $html = ConvertTo-NinjaOneHtml -ScanResults $results -ShowAllResults:$false -FooterSuffix ", Script v1.4.2" $wrappedHtml = New-HtmlWrapper -Content $html -Title "Disk Report" $wrappedHtml | Out-File "report.html" .NOTES @@ -28,12 +37,22 @@ [OutputType([string])] param ( [Parameter(Mandatory = $true, ValueFromPipeline = $true)] - [PSCustomObject]$ScanResults + [PSCustomObject]$ScanResults, + + [int]$MaxTopFiles = 0, + + [int]$MaxTopFolders = 0, + + [bool]$ShowAllResults = $true, + + [string]$FooterSuffix = '' ) process { $html = [System.Text.StringBuilder]::new() $cfg = $script:Config + $maxFiles = if ($MaxTopFiles -gt 0) { $MaxTopFiles } else { $cfg.Display.MaxTopFiles } + $maxFolders = if ($MaxTopFolders -gt 0) { $MaxTopFolders } else { $cfg.Display.MaxTopFolders } # === TOTAL SUMMARY STAT CARDS === $driveCount = $ScanResults.DriveInfo.Count @@ -49,6 +68,8 @@ $searchIcon = Get-ThemeIcon -IconName "Search" $copyIcon = Get-ThemeIcon -IconName "Copy" $broomIcon = Get-ThemeIcon -IconName "Broom" + $fileIcon = Get-ThemeIcon -IconName "File" + $folderIcon = Get-ThemeIcon -IconName "Folder" [void]$html.AppendLine('
    ') [void]$html.AppendLine('
    ') @@ -80,8 +101,18 @@ $freeColor = Get-ThemeColor -Severity "Free" $driveCleanup = @($ScanResults.CleanupSuggestions | Where-Object { $_.Drive -eq $drive.Drive }) - $driveFiles = @($ScanResults.Items | Where-Object { $_.Drive -eq $drive.Drive -and -not $_.IsDirectory } | Select-Object -First $cfg.Display.MaxTopFiles) - $driveFolders = @($ScanResults.Items | Where-Object { $_.Drive -eq $drive.Drive -and $_.IsDirectory } | Select-Object -First $cfg.Display.MaxTopFolders) + $driveFiles = @( + $ScanResults.Items | + Where-Object { $_.Drive -eq $drive.Drive -and -not $_.IsDirectory } | + Sort-Object -Property SizeBytes -Descending | + Select-Object -First $maxFiles + ) + $driveFolders = @( + $ScanResults.Items | + Where-Object { $_.Drive -eq $drive.Drive -and $_.IsDirectory } | + Sort-Object -Property SizeBytes -Descending | + Select-Object -First $maxFolders + ) $driveFileTypes = @($ScanResults.FileTypes | Where-Object { $_.Drive -eq $drive.Drive } | Select-Object -First $cfg.Display.MaxFileTypes) # Row 1: Stat cards @@ -114,10 +145,22 @@ [void]$html.AppendLine('
    ') - # Row 3: Three-column layout + # Row 3: Top Files + Top Folders ranked tables (full width) + $rankedSections = @() + if ($driveFiles.Count -gt 0) { + $rankedSections += New-HtmlTable -Items $driveFiles -Title "Top Files" -Icon $fileIcon + } + if ($driveFolders.Count -gt 0) { + $rankedSections += New-HtmlTable -Items $driveFolders -Title "Top Folders" -Icon $folderIcon + } + if ($rankedSections.Count -gt 0) { + [void]$html.AppendLine((New-HtmlRankedStack -Sections $rankedSections)) + } + + # Row 4: Cleanup + File Types (two columns) [void]$html.AppendLine('
    ') - [void]$html.AppendLine('
    ') + [void]$html.AppendLine('
    ') if ($driveCleanup.Count -gt 0) { [void]$html.AppendLine((New-HtmlCleanupSuggestions -Suggestions $driveCleanup -Compact)) } @@ -127,28 +170,10 @@ } [void]$html.AppendLine('
    ') - [void]$html.AppendLine('
    ') - if ($driveFiles.Count -gt 0) { - $fileChartItems = $driveFiles | ForEach-Object { - $label = Split-Path $_.Path -Leaf - if ([string]::IsNullOrEmpty($label)) { $label = $_.Path } - @{ Label = $label; Value = $_.SizeBytes } - } - [void]$html.AppendLine((New-HtmlBarChart -Items $fileChartItems -Title "Top Files" -CardStyle "margin-bottom: 12px;")) - } - if ($driveFolders.Count -gt 0) { - $chartItems = $driveFolders | ForEach-Object { - $label = Split-Path $_.Path -Leaf - if ([string]::IsNullOrEmpty($label)) { $label = $_.Path } - @{ Label = $label; Value = $_.SizeBytes } - } - [void]$html.AppendLine((New-HtmlBarChart -Items $chartItems -Title "Top Folders")) - } - [void]$html.AppendLine('
    ') - - [void]$html.AppendLine('
    ') + [void]$html.AppendLine('
    ') if ($driveFileTypes.Count -gt 0) { - [void]$html.AppendLine((New-HtmlFileTypeTable -FileTypes $driveFileTypes)) + $fileTypesHtml = (New-HtmlFileTypeTable -FileTypes $driveFileTypes) -replace '
    ', '
    ' + [void]$html.AppendLine($fileTypesHtml) } [void]$html.AppendLine('
    ') @@ -161,13 +186,19 @@ } # === FULL RESULTS TABLE === - $listIcon = Get-ThemeIcon -IconName "List" - [void]$html.AppendLine((New-HtmlTable -Items $ScanResults.Items -Title "All Results by Size" -Icon $listIcon)) + if ($ShowAllResults) { + $listIcon = Get-ThemeIcon -IconName "List" + [void]$html.AppendLine((New-HtmlTable -Items $ScanResults.Items -Title "All Results by Size" -Icon $listIcon)) + } # === FOOTER === $scanTime = Get-Date -Format "yyyy-MM-dd HH:mm" - $mutedColor = Get-ThemeColor -Severity "Muted" - [void]$html.AppendLine("

    TreeSize v$($cfg.Version) | Scanned: $scanTime

    ") + $footerText = "UltraTree v$($cfg.Version)" + if ($FooterSuffix) { + $footerText += $FooterSuffix + } + $footerText += " | Scanned: $scanTime" + [void]$html.AppendLine("

    $footerText

    ") return $html.ToString() } diff --git a/Module/src/UltraTree/UltraTree.psd1 b/Module/src/UltraTree/UltraTree.psd1 index f5f3c1e..6d0bdfa 100644 --- a/Module/src/UltraTree/UltraTree.psd1 +++ b/Module/src/UltraTree/UltraTree.psd1 @@ -1,4 +1,4 @@ -# +# # Module manifest for module 'UltraTree' # # Generated by: Jan Scholte @@ -12,7 +12,7 @@ RootModule = 'UltraTree.psm1' # Version number of this module. -ModuleVersion = '1.0.1' +ModuleVersion = '1.0.2' # Supported PSEditions # CompatiblePSEditions = @() From ad732cd31ced850c711eed0af7eb3851fb2346c9 Mon Sep 17 00:00:00 2001 From: Jonathan Pitre <15003329+JonathanPitre@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:18:10 -0400 Subject: [PATCH 4/7] chore(html): add OutputType and help on Html helper functions Add [OutputType([string])] to New-HtmlWrapper. Align New-HtmlRankedStack with other New-Html* helpers (CmdletBinding, OutputType, DESCRIPTION). UltraTree PSScriptAnalyzer settings: 0 findings. Co-authored-by: Cursor --- Module/src/UltraTree/Private/New-HtmlRankedStack.ps1 | 5 +++++ Module/src/UltraTree/Public/New-HtmlWrapper.ps1 | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Module/src/UltraTree/Private/New-HtmlRankedStack.ps1 b/Module/src/UltraTree/Private/New-HtmlRankedStack.ps1 index ad29de1..2ea4fd7 100644 --- a/Module/src/UltraTree/Private/New-HtmlRankedStack.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlRankedStack.ps1 @@ -2,9 +2,14 @@ <# .SYNOPSIS Stacks ranked table cards full-width for NinjaOne WYSIWYG layout. + .DESCRIPTION + Wraps one or more HTML card fragments in a full-width Bootstrap row so + Top Files and Top Folders tables stack vertically in NinjaOne WYSIWYG fields. .PARAMETER Sections HTML card fragments (e.g. from New-HtmlTable). #> + [CmdletBinding()] + [OutputType([string])] param ( [string[]]$Sections ) diff --git a/Module/src/UltraTree/Public/New-HtmlWrapper.ps1 b/Module/src/UltraTree/Public/New-HtmlWrapper.ps1 index 08ea92f..b56727e 100644 --- a/Module/src/UltraTree/Public/New-HtmlWrapper.ps1 +++ b/Module/src/UltraTree/Public/New-HtmlWrapper.ps1 @@ -1,4 +1,4 @@ -function New-HtmlWrapper { +function New-HtmlWrapper { <# .SYNOPSIS Wraps HTML fragment with full document including CSS/JS dependencies. @@ -16,6 +16,7 @@ function New-HtmlWrapper { Get-FolderSizes -AllDrives | ConvertTo-NinjaOneHtml | New-HtmlWrapper | Out-File "report.html" #> [CmdletBinding()] + [OutputType([string])] param ( [Parameter(ValueFromPipeline)] [string]$Content, From 0670e8717635ff028df6129bd9068b564101a5db Mon Sep 17 00:00:00 2001 From: Jonathan Pitre <15003329+JonathanPitre@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:40:45 -0400 Subject: [PATCH 5/7] fix(html): inline status badge colors for NinjaOne WYSIWYG Critical (expired) red and Warning (disabled) orange badges now use inline background-color and white text in New-HtmlTag. NinjaOne strips wrapper CSS; aligns New-HtmlWrapper warning tag text with white. Co-authored-by: Cursor --- .../Unit/Private/HtmlGeneration.Tests.ps1 | 4 ++++ Module/src/UltraTree/Private/New-HtmlTag.ps1 | 21 +++++++++++++++++-- .../src/UltraTree/Public/New-HtmlWrapper.ps1 | 2 +- 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/Module/src/Tests/Unit/Private/HtmlGeneration.Tests.ps1 b/Module/src/Tests/Unit/Private/HtmlGeneration.Tests.ps1 index d4af90d..e335393 100644 --- a/Module/src/Tests/Unit/Private/HtmlGeneration.Tests.ps1 +++ b/Module/src/Tests/Unit/Private/HtmlGeneration.Tests.ps1 @@ -76,6 +76,8 @@ Describe 'HTML Generation Functions' -Tag Unit { InModuleScope UltraTree { $html = New-HtmlTag -Text "Critical" -Type "expired" $html | Should -Match 'class="tag expired"' + $html | Should -Match 'background-color: #d9534f' + $html | Should -Match 'color: #fff' } } @@ -83,6 +85,8 @@ Describe 'HTML Generation Functions' -Tag Unit { InModuleScope UltraTree { $html = New-HtmlTag -Text "Warning" -Type "disabled" $html | Should -Match 'class="tag disabled"' + $html | Should -Match 'background-color: #f0ad4e' + $html | Should -Match 'color: #fff' } } } diff --git a/Module/src/UltraTree/Private/New-HtmlTag.ps1 b/Module/src/UltraTree/Private/New-HtmlTag.ps1 index ed5071d..1cc9cee 100644 --- a/Module/src/UltraTree/Private/New-HtmlTag.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlTag.ps1 @@ -1,9 +1,10 @@ -function New-HtmlTag { +function New-HtmlTag { <# .SYNOPSIS Creates an inline tag/badge element. .DESCRIPTION Generates HTML for a small tag or badge with optional type styling. + Uses inline colors so NinjaOne WYSIWYG fields render badges without wrapper CSS. .PARAMETER Text The tag text content. .PARAMETER Type @@ -14,6 +15,22 @@ function New-HtmlTag { [ValidateSet("", "disabled", "expired")] [string]$Type = "" ) + $classExtra = if ($Type) { " $Type" } else { "" } - "
    $Text
    " + $baseStyle = 'display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 600;' + + switch ($Type) { + 'expired' { + $bgColor = Get-ThemeColor -Severity 'Danger' + } + 'disabled' { + $bgColor = Get-ThemeColor -Severity 'Warning' + } + default { + $bgColor = Get-ThemeColor -Severity 'Success' + } + } + + $style = "$baseStyle background-color: $bgColor; color: #fff;" + "
    $Text
    " } diff --git a/Module/src/UltraTree/Public/New-HtmlWrapper.ps1 b/Module/src/UltraTree/Public/New-HtmlWrapper.ps1 index b56727e..f4f97bb 100644 --- a/Module/src/UltraTree/Public/New-HtmlWrapper.ps1 +++ b/Module/src/UltraTree/Public/New-HtmlWrapper.ps1 @@ -123,7 +123,7 @@ } .tag.disabled, .tag.warning { background-color: #f0ad4e; - color: #333; + color: white; } table { width: 100%; From e049a8554dbf0b1c4a3039061a2d3b71a43dfec5 Mon Sep 17 00:00:00 2001 From: Jonathan Pitre <15003329+JonathanPitre@users.noreply.github.com> Date: Tue, 25 Aug 2026 19:56:10 -0400 Subject: [PATCH 6/7] chore(html): add SupportsShouldProcess to HTML helper functions Honor PowerShell New-* conventions on string-only HTML generators without changing default output. Co-authored-by: Cursor --- .../Unit/Private/HtmlGeneration.Tests.ps1 | 40 +++++++++---------- .../UltraTree/Private/New-HtmlBarChart.ps1 | 2 + Module/src/UltraTree/Private/New-HtmlCard.ps1 | 5 ++- .../Private/New-HtmlCleanupSuggestions.ps1 | 15 +++---- .../Private/New-HtmlDuplicatesTable.ps1 | 2 + .../Private/New-HtmlFileTypeTable.ps1 | 5 ++- .../UltraTree/Private/New-HtmlInfoCard.ps1 | 3 ++ .../UltraTree/Private/New-HtmlLineChart.ps1 | 5 ++- .../UltraTree/Private/New-HtmlRankedStack.ps1 | 3 +- .../UltraTree/Private/New-HtmlStatCard.ps1 | 5 ++- .../src/UltraTree/Private/New-HtmlTable.ps1 | 3 ++ Module/src/UltraTree/Private/New-HtmlTag.ps1 | 3 ++ .../Public/ConvertTo-NinjaOneHtml.ps1 | 3 +- .../src/UltraTree/Public/New-HtmlWrapper.ps1 | 3 +- 14 files changed, 62 insertions(+), 35 deletions(-) diff --git a/Module/src/Tests/Unit/Private/HtmlGeneration.Tests.ps1 b/Module/src/Tests/Unit/Private/HtmlGeneration.Tests.ps1 index e335393..70ae7ac 100644 --- a/Module/src/Tests/Unit/Private/HtmlGeneration.Tests.ps1 +++ b/Module/src/Tests/Unit/Private/HtmlGeneration.Tests.ps1 @@ -11,7 +11,7 @@ Describe 'HTML Generation Functions' -Tag Unit { Context 'New-HtmlStatCard' { It 'Generates valid stat card HTML' { InModuleScope UltraTree { - $html = New-HtmlStatCard -Value "100 GB" -Description "Test Desc" -Color "#ff0000" + $html = New-HtmlStatCard -Value '100 GB' -Description 'Test Desc' -Color '#ff0000' $html | Should -Match 'class="stat-card"' $html | Should -Match 'class="stat-value"' $html | Should -Match 'class="stat-desc"' @@ -23,14 +23,14 @@ Describe 'HTML Generation Functions' -Tag Unit { It 'Includes icon when provided' { InModuleScope UltraTree { - $html = New-HtmlStatCard -Value "5" -Description "Items" -Icon "fas fa-folder" + $html = New-HtmlStatCard -Value '5' -Description 'Items' -Icon 'fas fa-folder' $html | Should -Match 'fas fa-folder' } } It 'Uses default color when not provided' { InModuleScope UltraTree { - $html = New-HtmlStatCard -Value "5" -Description "Items" + $html = New-HtmlStatCard -Value '5' -Description 'Items' $html | Should -Match '#337ab7' } } @@ -39,7 +39,7 @@ Describe 'HTML Generation Functions' -Tag Unit { Context 'New-HtmlInfoCard' { It 'Generates info card with Warning type' { InModuleScope UltraTree { - $html = New-HtmlInfoCard -Title "Test" -Description "Desc" -Type "Warning" + $html = New-HtmlInfoCard -Title 'Test' -Description 'Desc' -Type 'Warning' $html | Should -Match 'class="info-card warning"' $html | Should -Match 'fa-solid fa-triangle-exclamation' $html | Should -Match 'info-title" style="color: #333;"' @@ -49,14 +49,14 @@ Describe 'HTML Generation Functions' -Tag Unit { It 'Generates info card with Danger type' { InModuleScope UltraTree { - $html = New-HtmlInfoCard -Title "Test" -Description "Desc" -Type "Danger" + $html = New-HtmlInfoCard -Title 'Test' -Description 'Desc' -Type 'Danger' $html | Should -Match 'class="info-card danger"' } } It 'Info type has no extra class' { InModuleScope UltraTree { - $html = New-HtmlInfoCard -Title "Test" -Description "Desc" -Type "Info" + $html = New-HtmlInfoCard -Title 'Test' -Description 'Desc' -Type 'Info' $html | Should -Match 'class="info-card"' $html | Should -Not -Match 'class="info-card info"' } @@ -66,7 +66,7 @@ Describe 'HTML Generation Functions' -Tag Unit { Context 'New-HtmlTag' { It 'Generates basic tag' { InModuleScope UltraTree { - $html = New-HtmlTag -Text "Healthy" + $html = New-HtmlTag -Text 'Healthy' $html | Should -Match 'class="tag"' $html | Should -Match 'Healthy' } @@ -74,7 +74,7 @@ Describe 'HTML Generation Functions' -Tag Unit { It 'Adds expired type class' { InModuleScope UltraTree { - $html = New-HtmlTag -Text "Critical" -Type "expired" + $html = New-HtmlTag -Text 'Critical' -Type 'expired' $html | Should -Match 'class="tag expired"' $html | Should -Match 'background-color: #d9534f' $html | Should -Match 'color: #fff' @@ -83,7 +83,7 @@ Describe 'HTML Generation Functions' -Tag Unit { It 'Adds disabled type class' { InModuleScope UltraTree { - $html = New-HtmlTag -Text "Warning" -Type "disabled" + $html = New-HtmlTag -Text 'Warning' -Type 'disabled' $html | Should -Match 'class="tag disabled"' $html | Should -Match 'background-color: #f0ad4e' $html | Should -Match 'color: #fff' @@ -94,7 +94,7 @@ Describe 'HTML Generation Functions' -Tag Unit { Context 'New-HtmlCard' { It 'Generates card with title and body' { InModuleScope UltraTree { - $html = New-HtmlCard -Title "Test Card" -Body "

    Content

    " + $html = New-HtmlCard -Title 'Test Card' -Body '

    Content

    ' $html | Should -Match 'class="card flex-grow-1"' $html | Should -Match 'class="card-title-box"' $html | Should -Match 'Test Card' @@ -104,14 +104,14 @@ Describe 'HTML Generation Functions' -Tag Unit { It 'Includes icon when provided' { InModuleScope UltraTree { - $html = New-HtmlCard -Title "Test" -Icon "fas fa-folder" -Body "Content" + $html = New-HtmlCard -Title 'Test' -Icon 'fas fa-folder' -Body 'Content' $html | Should -Match 'fas fa-folder' } } It 'Applies body style' { InModuleScope UltraTree { - $html = New-HtmlCard -Title "Test" -Body "Content" -BodyStyle "padding: 0;" + $html = New-HtmlCard -Title 'Test' -Body 'Content' -BodyStyle 'padding: 0;' $html | Should -Match 'style="padding: 0;"' } } @@ -119,20 +119,20 @@ Describe 'HTML Generation Functions' -Tag Unit { Context 'New-HtmlBarChart' { It 'Returns empty string for null items' { - InModuleScope UltraTree { New-HtmlBarChart -Items $null | Should -Be "" } + InModuleScope UltraTree { New-HtmlBarChart -Items $null | Should -Be '' } } It 'Returns empty string for empty array' { - InModuleScope UltraTree { New-HtmlBarChart -Items @() | Should -Be "" } + InModuleScope UltraTree { New-HtmlBarChart -Items @() | Should -Be '' } } It 'Generates chart with items' { InModuleScope UltraTree { $items = @( - @{ Label = "Folder1"; Value = 1GB } - @{ Label = "Folder2"; Value = 500MB } + @{ Label = 'Folder1'; Value = 1GB } + @{ Label = 'Folder2'; Value = 500MB } ) - $html = New-HtmlBarChart -Items $items -Title "Test Chart" + $html = New-HtmlBarChart -Items $items -Title 'Test Chart' $html | Should -Match 'charts-css bar' $html | Should -Match 'Test Chart' $html | Should -Match 'Folder1' @@ -143,11 +143,11 @@ Describe 'HTML Generation Functions' -Tag Unit { Context 'New-HtmlDuplicatesTable' { It 'Returns empty string for null groups' { - InModuleScope UltraTree { New-HtmlDuplicatesTable -DuplicateGroups $null -TotalWasted 0 | Should -Be "" } + InModuleScope UltraTree { New-HtmlDuplicatesTable -DuplicateGroups $null -TotalWasted 0 | Should -Be '' } } It 'Returns empty string for empty groups' { - InModuleScope UltraTree { New-HtmlDuplicatesTable -DuplicateGroups @() -TotalWasted 0 | Should -Be "" } + InModuleScope UltraTree { New-HtmlDuplicatesTable -DuplicateGroups @() -TotalWasted 0 | Should -Be '' } } It 'Generates table with duplicate groups' { @@ -156,7 +156,7 @@ Describe 'HTML Generation Functions' -Tag Unit { [PSCustomObject]@{ FileSize = 100MB WastedSpace = 100MB - Files = @("C:\path1\file.exe", "C:\path2\file.exe") + Files = @('C:\path1\file.exe', 'C:\path2\file.exe') } ) $html = New-HtmlDuplicatesTable -DuplicateGroups $groups -TotalWasted 100MB diff --git a/Module/src/UltraTree/Private/New-HtmlBarChart.ps1 b/Module/src/UltraTree/Private/New-HtmlBarChart.ps1 index 7f8ceb9..97fcbbe 100644 --- a/Module/src/UltraTree/Private/New-HtmlBarChart.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlBarChart.ps1 @@ -11,6 +11,7 @@ .PARAMETER CardStyle Optional inline CSS for the card element. #> + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] param ( [array]$Items, [string]$Title = "Top Items", @@ -18,6 +19,7 @@ ) if ($null -eq $Items -or $Items.Count -eq 0) { return "" } + if (-not $PSCmdlet.ShouldProcess($Title, 'Generate HTML bar chart')) { return '' } # Find max value (can't use Measure-Object with hashtables) $maxValue = 0 diff --git a/Module/src/UltraTree/Private/New-HtmlCard.ps1 b/Module/src/UltraTree/Private/New-HtmlCard.ps1 index f209a23..05b3592 100644 --- a/Module/src/UltraTree/Private/New-HtmlCard.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlCard.ps1 @@ -1,4 +1,4 @@ -function New-HtmlCard { +function New-HtmlCard { <# .SYNOPSIS Base card template - other card functions should use this. @@ -15,6 +15,7 @@ function New-HtmlCard { .PARAMETER CardStyle Optional inline CSS for the card element. #> + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] param ( [string]$Title, [string]$Icon = "", @@ -23,6 +24,8 @@ function New-HtmlCard { [string]$CardStyle = "" ) + if (-not $PSCmdlet.ShouldProcess($Title, 'Generate HTML card')) { return '' } + $iconHtml = if ($Icon) { "  " } else { "" } $bodyStyleAttr = if ($BodyStyle) { " style=`"$BodyStyle`"" } else { "" } $cardStyleAttr = if ($CardStyle) { " style=`"$CardStyle`"" } else { "" } diff --git a/Module/src/UltraTree/Private/New-HtmlCleanupSuggestions.ps1 b/Module/src/UltraTree/Private/New-HtmlCleanupSuggestions.ps1 index 0605fd6..32bb466 100644 --- a/Module/src/UltraTree/Private/New-HtmlCleanupSuggestions.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlCleanupSuggestions.ps1 @@ -9,14 +9,16 @@ .PARAMETER Compact Switch to use compact list format instead of full cards. #> + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] param ( [array]$Suggestions, [switch]$Compact ) - if ($null -eq $Suggestions -or $Suggestions.Count -eq 0) { return "" } + if ($null -eq $Suggestions -or $Suggestions.Count -eq 0) { return '' } + if (-not $PSCmdlet.ShouldProcess('cleanup suggestions', 'Generate HTML cleanup suggestions')) { return '' } - $broomIcon = Get-ThemeIcon -IconName "Broom" + $broomIcon = Get-ThemeIcon -IconName 'Broom' if ($Compact) { $items = foreach ($sug in $Suggestions) { @@ -33,20 +35,19 @@ $($items -join "`n ") "@ - New-HtmlCard -Title "Cleanup" -Icon $broomIcon -Body $body -BodyStyle "padding: 12px;" - } - else { + New-HtmlCard -Title 'Cleanup' -Icon $broomIcon -Body $body -BodyStyle 'padding: 12px;' + } else { $cards = foreach ($sug in $Suggestions) { $sizeText = Format-ByteSize -Bytes $sug.Size $category = $script:CleanupCategories | Where-Object { $_.Name -eq $sug.Category } $displayName = if ($category) { $category.DisplayName } else { $sug.Path } $description = if ($category) { $category.Description } else { $sug.Description } - $severity = if ($category) { $category.Severity } else { "Info" } + $severity = if ($category) { $category.Severity } else { 'Info' } New-HtmlInfoCard -Title "$displayName`: $sizeText" -Description $description -Type $severity } $body = $cards -join "`n " - New-HtmlCard -Title "Cleanup Suggestions" -Icon $broomIcon -Body $body -CardStyle "margin-bottom: 16px;" + New-HtmlCard -Title 'Cleanup Suggestions' -Icon $broomIcon -Body $body -CardStyle 'margin-bottom: 16px;' } } diff --git a/Module/src/UltraTree/Private/New-HtmlDuplicatesTable.ps1 b/Module/src/UltraTree/Private/New-HtmlDuplicatesTable.ps1 index 6a562cf..3f3dc70 100644 --- a/Module/src/UltraTree/Private/New-HtmlDuplicatesTable.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlDuplicatesTable.ps1 @@ -9,12 +9,14 @@ .PARAMETER TotalWasted Total wasted space across all duplicate groups. #> + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] param ( [array]$DuplicateGroups, [long]$TotalWasted ) if ($null -eq $DuplicateGroups -or $DuplicateGroups.Count -eq 0) { return "" } + if (-not $PSCmdlet.ShouldProcess('duplicate files', 'Generate HTML duplicates table')) { return '' } $totalWastedText = Format-ByteSize -Bytes $TotalWasted $cfg = $script:Config.Display diff --git a/Module/src/UltraTree/Private/New-HtmlFileTypeTable.ps1 b/Module/src/UltraTree/Private/New-HtmlFileTypeTable.ps1 index 7c9b4e5..817fb6f 100644 --- a/Module/src/UltraTree/Private/New-HtmlFileTypeTable.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlFileTypeTable.ps1 @@ -1,4 +1,4 @@ -function New-HtmlFileTypeTable { +function New-HtmlFileTypeTable { <# .SYNOPSIS Creates a table showing file types by total size. @@ -7,8 +7,11 @@ function New-HtmlFileTypeTable { .PARAMETER FileTypes Array of file type objects with Extension, FileCount, and TotalSize properties. #> + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] param ([array]$FileTypes) + if (-not $PSCmdlet.ShouldProcess('file types', 'Generate HTML file type table')) { return '' } + $colors = @("#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEAA7", "#DDA0DD", "#98D8C8", "#F7DC6F", "#BB8FCE", "#85C1E9", "#F8B500", "#00CED1", "#FF7F50", "#9370DB", "#20B2AA") $rows = for ($i = 0; $i -lt $FileTypes.Count; $i++) { diff --git a/Module/src/UltraTree/Private/New-HtmlInfoCard.ps1 b/Module/src/UltraTree/Private/New-HtmlInfoCard.ps1 index 4f5ea1b..d5f7296 100644 --- a/Module/src/UltraTree/Private/New-HtmlInfoCard.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlInfoCard.ps1 @@ -11,6 +11,7 @@ .PARAMETER Type The card type: Info, Warning, Danger, or Success. #> + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] param ( [string]$Title, [string]$Description, @@ -18,6 +19,8 @@ [string]$Type = "Info" ) + if (-not $PSCmdlet.ShouldProcess($Title, 'Generate HTML info card')) { return '' } + $style = Get-SeverityStyle -Severity $Type $classExtra = if ($Type -eq "Info") { "" } else { " $($Type.ToLower())" } diff --git a/Module/src/UltraTree/Private/New-HtmlLineChart.ps1 b/Module/src/UltraTree/Private/New-HtmlLineChart.ps1 index c1bdf6b..11c3e0a 100644 --- a/Module/src/UltraTree/Private/New-HtmlLineChart.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlLineChart.ps1 @@ -1,4 +1,4 @@ -function New-HtmlLineChart { +function New-HtmlLineChart { <# .SYNOPSIS Creates a line/segment chart showing disk usage. @@ -9,11 +9,14 @@ function New-HtmlLineChart { .PARAMETER Total The total value for calculating percentages. #> + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] param ( [array]$Segments, [long]$Total ) + if (-not $PSCmdlet.ShouldProcess('disk usage chart', 'Generate HTML line chart')) { return '' } + if ($Total -eq 0) { $Total = 1 } $bars = foreach ($seg in $Segments) { diff --git a/Module/src/UltraTree/Private/New-HtmlRankedStack.ps1 b/Module/src/UltraTree/Private/New-HtmlRankedStack.ps1 index 2ea4fd7..aca7380 100644 --- a/Module/src/UltraTree/Private/New-HtmlRankedStack.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlRankedStack.ps1 @@ -8,7 +8,7 @@ .PARAMETER Sections HTML card fragments (e.g. from New-HtmlTable). #> - [CmdletBinding()] + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] [OutputType([string])] param ( [string[]]$Sections @@ -16,6 +16,7 @@ $parts = @($Sections | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) if ($parts.Count -eq 0) { return '' } + if (-not $PSCmdlet.ShouldProcess('ranked tables', 'Generate HTML ranked stack')) { return '' } $cards = for ($i = 0; $i -lt $parts.Count; $i++) { $card = $parts[$i] -replace '
    + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] param ( [string]$Value, [string]$Description, @@ -20,6 +21,8 @@ function New-HtmlStatCard { [string]$Icon = "" ) + if (-not $PSCmdlet.ShouldProcess($Description, 'Generate HTML stat card')) { return '' } + if (-not $Color) { $Color = Get-ThemeColor -Severity "Primary" } $iconHtml = if ($Icon) { "" } else { "" } diff --git a/Module/src/UltraTree/Private/New-HtmlTable.ps1 b/Module/src/UltraTree/Private/New-HtmlTable.ps1 index ccd8c64..6b8abc4 100644 --- a/Module/src/UltraTree/Private/New-HtmlTable.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlTable.ps1 @@ -11,12 +11,15 @@ .PARAMETER Icon Optional FontAwesome icon class for the title. #> + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] param ( [array]$Items, [string]$Title = "Results", [string]$Icon = "" ) + if (-not $PSCmdlet.ShouldProcess($Title, 'Generate HTML table')) { return '' } + if (-not $Icon) { $Icon = Get-ThemeIcon -IconName "Folder" } $folderIcon = Get-ThemeIcon -IconName "Folder" $fileIcon = Get-ThemeIcon -IconName "File" diff --git a/Module/src/UltraTree/Private/New-HtmlTag.ps1 b/Module/src/UltraTree/Private/New-HtmlTag.ps1 index 1cc9cee..0a1c4d6 100644 --- a/Module/src/UltraTree/Private/New-HtmlTag.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlTag.ps1 @@ -10,12 +10,15 @@ .PARAMETER Type Optional type: empty string (default), "disabled", or "expired". #> + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] param ( [string]$Text, [ValidateSet("", "disabled", "expired")] [string]$Type = "" ) + if (-not $PSCmdlet.ShouldProcess($Text, 'Generate HTML tag')) { return '' } + $classExtra = if ($Type) { " $Type" } else { "" } $baseStyle = 'display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 600;' diff --git a/Module/src/UltraTree/Public/ConvertTo-NinjaOneHtml.ps1 b/Module/src/UltraTree/Public/ConvertTo-NinjaOneHtml.ps1 index 0d66c69..01c27ab 100644 --- a/Module/src/UltraTree/Public/ConvertTo-NinjaOneHtml.ps1 +++ b/Module/src/UltraTree/Public/ConvertTo-NinjaOneHtml.ps1 @@ -163,8 +163,7 @@ [void]$html.AppendLine('
    ') if ($driveCleanup.Count -gt 0) { [void]$html.AppendLine((New-HtmlCleanupSuggestions -Suggestions $driveCleanup -Compact)) - } - else { + } else { $checkIcon = Get-ThemeIcon -IconName "CheckCircle" [void]$html.AppendLine("
      No Cleanup Needed

    No significant cleanup opportunities found.

    ") } diff --git a/Module/src/UltraTree/Public/New-HtmlWrapper.ps1 b/Module/src/UltraTree/Public/New-HtmlWrapper.ps1 index f4f97bb..cd8f606 100644 --- a/Module/src/UltraTree/Public/New-HtmlWrapper.ps1 +++ b/Module/src/UltraTree/Public/New-HtmlWrapper.ps1 @@ -15,7 +15,7 @@ .EXAMPLE Get-FolderSizes -AllDrives | ConvertTo-NinjaOneHtml | New-HtmlWrapper | Out-File "report.html" #> - [CmdletBinding()] + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] [OutputType([string])] param ( [Parameter(ValueFromPipeline)] @@ -24,6 +24,7 @@ ) process { + if (-not $PSCmdlet.ShouldProcess($Title, 'Wrap HTML content')) { return } @" From 990450c808c69fba869107eee9469ae25621748b Mon Sep 17 00:00:00 2001 From: Jonathan Pitre <15003329+JonathanPitre@users.noreply.github.com> Date: Wed, 26 Aug 2026 14:19:26 -0400 Subject: [PATCH 7/7] =?UTF-8?q?Fix:=20address=20PR=20#3=20review=20?= =?UTF-8?q?=E2=80=94=20badge=20contrast,=20version=20source,=20CI=20format?= =?UTF-8?q?ting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert SupportsShouldProcess on New-Html* helpers, fix badge foreground colors for WYSIWYG contrast, drop duplicate Config.Version in favor of module manifest, document -Top interaction for Top Files tables, fix Stroustrup else braces, update tests/docs, and remove misnamed screenshot. Co-authored-by: Cursor --- Module/README.md | 5 +- Module/docs/CHANGELOG.md | 17 +++++- Module/docs/configuration.md | 7 ++- .../docs/functions/convertto-ninjaonehtml.md | 50 +++++++++++++----- .../Unit/Private/HtmlGeneration.Tests.ps1 | 3 +- .../Public/ConvertTo-NinjaOneHtml.Tests.ps1 | 5 +- .../src/UltraTree/Private/Configuration.ps1 | 2 - .../UltraTree/Private/New-HtmlBarChart.ps1 | 10 ++-- Module/src/UltraTree/Private/New-HtmlCard.ps1 | 4 +- .../Private/New-HtmlCleanupSuggestions.ps1 | 6 +-- .../Private/New-HtmlDuplicatesTable.ps1 | 3 +- .../Private/New-HtmlFileTypeTable.ps1 | 4 +- .../UltraTree/Private/New-HtmlInfoCard.ps1 | 4 +- .../UltraTree/Private/New-HtmlLineChart.ps1 | 4 +- .../UltraTree/Private/New-HtmlRankedStack.ps1 | 3 +- .../UltraTree/Private/New-HtmlStatCard.ps1 | 4 +- .../src/UltraTree/Private/New-HtmlTable.ps1 | 4 +- Module/src/UltraTree/Private/New-HtmlTag.ps1 | 9 ++-- .../Public/ConvertTo-NinjaOneHtml.ps1 | 15 ++++-- .../src/UltraTree/Public/Get-FolderSizes.ps1 | 6 ++- .../src/UltraTree/Public/New-HtmlWrapper.ps1 | 5 +- .../ninjaone-dark-mode-access-errors.png | Bin 6508 -> 0 bytes 22 files changed, 105 insertions(+), 65 deletions(-) delete mode 100644 docs/images/ninjaone-dark-mode-access-errors.png diff --git a/Module/README.md b/Module/README.md index 63c8463..328437e 100644 --- a/Module/README.md +++ b/Module/README.md @@ -520,11 +520,14 @@ UltraTree uses internal configuration that can be customized by modifying `Priva |---------|---------|-------------| | `MaxDuplicateGroups` | 20 | Max duplicate groups in HTML report | | `MaxPathsPerGroup` | 5 | Max file paths shown per duplicate group | -| `MaxTopFolders` | 8 | Top folders displayed in bar chart | +| `MaxTopFolders` | 25 | Top folders in ranked table per drive | +| `MaxTopFiles` | 50 | Top files in ranked table per drive | | `MaxFileTypes` | 10 | Top file types to display | | `MaxResults` | 40 | Max items in results table | | `MaxPathLength` | 50 | Truncate paths longer than this | +`Get-FolderSizes` returns `Items` as one mixed file/folder list capped by `-Top` (default 40). For HTML reports that need many Top Files rows, pass a larger `-Top` (for example `-Top 200`) to `Get-FolderSizes` before `ConvertTo-NinjaOneHtml`. + ### Disk Health Thresholds | Setting | Default | Description | diff --git a/Module/docs/CHANGELOG.md b/Module/docs/CHANGELOG.md index 8f4967f..a1db065 100644 --- a/Module/docs/CHANGELOG.md +++ b/Module/docs/CHANGELOG.md @@ -7,11 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -- TBD +## [1.0.2] + +### Added + +- `ConvertTo-NinjaOneHtml`: `-MaxTopFiles`, `-MaxTopFolders`, `-ShowAllResults`, `-FooterSuffix` parameters +- Per-drive ranked **Top Files** and **Top Folders** tables (full width) +- Two-column **Cleanup** + **File Types** layout per drive +- `New-HtmlRankedStack` helper for stacked ranked tables in NinjaOne WYSIWYG + +### Changed + +- NinjaOne dark mode: `stat-desc` for muted text; explicit dark text on info-card titles/descriptions +- Status badges use inline background and foreground colors (WYSIWYG-safe contrast) +- Footer shows UltraTree version from the module manifest; optional `-FooterSuffix` for caller branding +- Removed duplicate `$script:Config.Version`; version comes from `UltraTree.psd1` only ## [0.2.0] ### Added - Initial release. - diff --git a/Module/docs/configuration.md b/Module/docs/configuration.md index 1f61b51..6470564 100644 --- a/Module/docs/configuration.md +++ b/Module/docs/configuration.md @@ -23,12 +23,17 @@ Control how much data appears in HTML reports: |---------|---------|-------------| | `MaxDuplicateGroups` | 20 | Maximum duplicate groups shown in report | | `MaxPathsPerGroup` | 5 | Maximum file paths shown per duplicate group | -| `MaxTopFolders` | 8 | Number of folders in the bar chart | +| `MaxTopFolders` | 25 | Top folders in ranked table per drive | +| `MaxTopFiles` | 50 | Top files in ranked table per drive | | `MaxFileTypes` | 10 | Number of file types in the table | | `MaxResults` | 40 | Maximum items in the full results table | | `MaxPathLength` | 50 | Truncate paths longer than this in display | | `MaxLabelLength` | 12 | Truncate chart labels longer than this | +### Top Files / Top Folders and `-Top` + +`ConvertTo-NinjaOneHtml` builds per-drive Top Files and Top Folders tables from `Get-FolderSizes` output. `Items` is a **single mixed list** of files and folders, sorted by size and truncated by `-Top` (default 40) across all drives. Folders usually fill most of that list, so raise `-Top` when you need more file rows (for example `-Top 200`). + ## Disk Health Thresholds Control health status indicators: diff --git a/Module/docs/functions/convertto-ninjaonehtml.md b/Module/docs/functions/convertto-ninjaonehtml.md index 2206da3..ed519a1 100644 --- a/Module/docs/functions/convertto-ninjaonehtml.md +++ b/Module/docs/functions/convertto-ninjaonehtml.md @@ -6,6 +6,10 @@ Converts scan results from `Get-FolderSizes` into an HTML report optimized for N ```powershell ConvertTo-NinjaOneHtml [-ScanResults] + [[-MaxTopFiles] ] + [[-MaxTopFolders] ] + [[-ShowAllResults] ] + [[-FooterSuffix] ] ``` ## Parameters @@ -13,32 +17,43 @@ ConvertTo-NinjaOneHtml [-ScanResults] | Parameter | Type | Required | Description | |-----------|------|----------|-------------| | **ScanResults** | PSCustomObject | Yes | Output from `Get-FolderSizes`. Supports pipeline input. | +| **MaxTopFiles** | int | No | Max file rows per drive in Top Files table. Default: 50 (`Display.MaxTopFiles`). | +| **MaxTopFolders** | int | No | Max folder rows per drive in Top Folders table. Default: 25 (`Display.MaxTopFolders`). | +| **ShowAllResults** | bool | No | Include the full "All Results by Size" table. Default: `$true`. | +| **FooterSuffix** | string | No | Text appended after the UltraTree version in the footer (e.g. `", Script v1.4.2"`). | ## Examples ### Basic Usage ```powershell -$results = Get-FolderSizes -DriveLetter C +$results = Get-FolderSizes -DriveLetter C -Top 200 $html = ConvertTo-NinjaOneHtml -ScanResults $results ``` ### Pipeline Usage ```powershell -$html = Get-FolderSizes -AllDrives | ConvertTo-NinjaOneHtml +$html = Get-FolderSizes -AllDrives -Top 200 | ConvertTo-NinjaOneHtml ``` ### Full Scan with Duplicates ```powershell -$html = Get-FolderSizes -AllDrives -FindDuplicates | ConvertTo-NinjaOneHtml +$html = Get-FolderSizes -AllDrives -FindDuplicates -Top 200 | ConvertTo-NinjaOneHtml +``` + +### Compact report (no full results table) + +```powershell +$html = Get-FolderSizes -DriveLetter C -Top 200 | + ConvertTo-NinjaOneHtml -ShowAllResults:$false -FooterSuffix ", Script v1.4.2" ``` ### Save to File ```powershell -Get-FolderSizes -AllDrives -FindDuplicates | +Get-FolderSizes -AllDrives -FindDuplicates -Top 200 | ConvertTo-NinjaOneHtml | Out-File "DiskReport.html" -Encoding UTF8 ``` @@ -46,7 +61,7 @@ Get-FolderSizes -AllDrives -FindDuplicates | ### Set NinjaOne Custom Field ```powershell -$results = Get-FolderSizes -AllDrives -FindDuplicates +$results = Get-FolderSizes -AllDrives -FindDuplicates -Top 200 $html = $results | ConvertTo-NinjaOneHtml $html | Ninja-Property-Set-Piped treesize ``` @@ -68,8 +83,9 @@ Each scanned drive gets its own section with: - **Drive Stats** - Used space, free space, health status (Healthy/Warning/Critical) - **Disk Usage Chart** - Visual bar showing used vs free space -- **Top Folders Chart** - Bar chart of largest folders -- **File Types Table** - Breakdown by extension +- **Top Files** - Ranked table of largest files (path, size, modified) +- **Top Folders** - Ranked table of largest folders +- **Cleanup** and **File Types** - Side-by-side in a two-column row - **Cleanup Suggestions** - Categorized cleanup opportunities ### Duplicates Section @@ -82,6 +98,8 @@ If `-FindDuplicates` was used: ### Results Table +When `-ShowAllResults` is true (default): + - Full sortable table of all items - Color-coded by size severity - Shows path, size, type, and last modified date @@ -89,7 +107,8 @@ If `-FindDuplicates` was used: ### Footer - Scan timestamp -- UltraTree version +- UltraTree module version (from the loaded module manifest) +- Optional suffix from `-FooterSuffix` ## HTML Features @@ -97,21 +116,26 @@ The generated HTML includes: - **Bootstrap 5** - Responsive grid and components - **Font Awesome 6** - Icons for status and categories -- **Charts.css** - Lightweight CSS-based charts -- **Dark/Light support** - Respects system preference +- **Inline badge colors** - Readable in NinjaOne WYSIWYG (wrapper CSS is stripped) +- **Dark/Light support** - Uses `stat-desc` for muted text; explicit dark text on info cards - **Mobile-friendly** - Responsive design ## Customization The HTML output is controlled by the module's configuration. See [Configuration](../configuration.md) for options like: -- `MaxTopFolders` - Number of folders in bar chart +- `MaxTopFiles` - Top files in ranked table (default 50) +- `MaxTopFolders` - Top folders in ranked table (default 25) - `MaxFileTypes` - Number of file types shown - `MaxResults` - Items in results table - `MaxDuplicateGroups` - Duplicate groups displayed +### `-Top` interaction + +`Get-FolderSizes` returns `Items` as one mixed file/folder list truncated by `-Top` (default 40). Folders usually dominate that list. Use a larger `-Top` (for example `-Top 200`) when you need the Top Files table to fill out — especially with `-AllDrives`. + ## Notes -- HTML includes external CDN references for Bootstrap, Font Awesome, and Charts.css +- HTML assumes Bootstrap 5 and Font Awesome 6 are available in the NinjaOne WYSIWYG host page +- For offline viewing, wrap with `New-HtmlWrapper` (loads CDN assets) - Best viewed in modern browsers or NinjaOne WYSIWYG fields -- For offline viewing, external resources need internet connectivity diff --git a/Module/src/Tests/Unit/Private/HtmlGeneration.Tests.ps1 b/Module/src/Tests/Unit/Private/HtmlGeneration.Tests.ps1 index 70ae7ac..4dfd9d7 100644 --- a/Module/src/Tests/Unit/Private/HtmlGeneration.Tests.ps1 +++ b/Module/src/Tests/Unit/Private/HtmlGeneration.Tests.ps1 @@ -69,6 +69,7 @@ Describe 'HTML Generation Functions' -Tag Unit { $html = New-HtmlTag -Text 'Healthy' $html | Should -Match 'class="tag"' $html | Should -Match 'Healthy' + $html | Should -Match 'color: #333' } } @@ -86,7 +87,7 @@ Describe 'HTML Generation Functions' -Tag Unit { $html = New-HtmlTag -Text 'Warning' -Type 'disabled' $html | Should -Match 'class="tag disabled"' $html | Should -Match 'background-color: #f0ad4e' - $html | Should -Match 'color: #fff' + $html | Should -Match 'color: #333' } } } diff --git a/Module/src/Tests/Unit/Public/ConvertTo-NinjaOneHtml.Tests.ps1 b/Module/src/Tests/Unit/Public/ConvertTo-NinjaOneHtml.Tests.ps1 index f44e6ef..ffcd9eb 100644 --- a/Module/src/Tests/Unit/Public/ConvertTo-NinjaOneHtml.Tests.ps1 +++ b/Module/src/Tests/Unit/Public/ConvertTo-NinjaOneHtml.Tests.ps1 @@ -2,6 +2,7 @@ Set-Location -Path $PSScriptRoot $ModuleName = 'UltraTree' $PathToManifest = [System.IO.Path]::Combine('..', '..', '..', $ModuleName, "$ModuleName.psd1") + $script:ModuleVersion = (Import-PowerShellDataFile $PathToManifest).ModuleVersion Get-Module $ModuleName -ErrorAction SilentlyContinue | Remove-Module -Force Import-Module $PathToManifest -Force } @@ -121,7 +122,7 @@ Describe 'ConvertTo-NinjaOneHtml' -Tag Unit { It 'Contains UltraTree version footer' { $html = ConvertTo-NinjaOneHtml -ScanResults $mockScanResults - $html | Should -Match 'UltraTree v1.0.2' + $html | Should -Match "UltraTree v$([regex]::Escape($script:ModuleVersion))" $html | Should -Not -Match 'TreeSize v' } @@ -183,7 +184,7 @@ Describe 'ConvertTo-NinjaOneHtml' -Tag Unit { It 'Appends FooterSuffix to footer' { $html = ConvertTo-NinjaOneHtml -ScanResults $mockScanResults -FooterSuffix ', Script v1.4.2' - $html | Should -Match 'UltraTree v1.0.2, Script v1.4.2' + $html | Should -Match "UltraTree v$([regex]::Escape($script:ModuleVersion)), Script v1.4.2" } It 'Accepts pipeline input' { diff --git a/Module/src/UltraTree/Private/Configuration.ps1 b/Module/src/UltraTree/Private/Configuration.ps1 index 255723c..fb2659a 100644 --- a/Module/src/UltraTree/Private/Configuration.ps1 +++ b/Module/src/UltraTree/Private/Configuration.ps1 @@ -2,8 +2,6 @@ # Centralized configuration with nested theme/thresholds $script:Config = @{ - Version = "1.0.2" - # Size thresholds Thresholds = @{ CleanupMin = 100MB # Minimum size for cleanup suggestions diff --git a/Module/src/UltraTree/Private/New-HtmlBarChart.ps1 b/Module/src/UltraTree/Private/New-HtmlBarChart.ps1 index 97fcbbe..8816210 100644 --- a/Module/src/UltraTree/Private/New-HtmlBarChart.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlBarChart.ps1 @@ -8,18 +8,14 @@ Array of objects with Label and Value properties. .PARAMETER Title The chart title. - .PARAMETER CardStyle - Optional inline CSS for the card element. #> - [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] + [CmdletBinding()] param ( [array]$Items, - [string]$Title = "Top Items", - [string]$CardStyle = "" + [string]$Title = "Top Items" ) if ($null -eq $Items -or $Items.Count -eq 0) { return "" } - if (-not $PSCmdlet.ShouldProcess($Title, 'Generate HTML bar chart')) { return '' } # Find max value (can't use Measure-Object with hashtables) $maxValue = 0 @@ -52,5 +48,5 @@ $($rows -join "`n")
    "@ - New-HtmlCard -Title $Title -Icon $icon -Body $body -BodyStyle "padding: 8px;" -CardStyle $CardStyle + New-HtmlCard -Title $Title -Icon $icon -Body $body -BodyStyle "padding: 8px;" } diff --git a/Module/src/UltraTree/Private/New-HtmlCard.ps1 b/Module/src/UltraTree/Private/New-HtmlCard.ps1 index 05b3592..056d36a 100644 --- a/Module/src/UltraTree/Private/New-HtmlCard.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlCard.ps1 @@ -15,7 +15,7 @@ .PARAMETER CardStyle Optional inline CSS for the card element. #> - [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] + [CmdletBinding()] param ( [string]$Title, [string]$Icon = "", @@ -24,8 +24,6 @@ [string]$CardStyle = "" ) - if (-not $PSCmdlet.ShouldProcess($Title, 'Generate HTML card')) { return '' } - $iconHtml = if ($Icon) { "  " } else { "" } $bodyStyleAttr = if ($BodyStyle) { " style=`"$BodyStyle`"" } else { "" } $cardStyleAttr = if ($CardStyle) { " style=`"$CardStyle`"" } else { "" } diff --git a/Module/src/UltraTree/Private/New-HtmlCleanupSuggestions.ps1 b/Module/src/UltraTree/Private/New-HtmlCleanupSuggestions.ps1 index 32bb466..9c8cc22 100644 --- a/Module/src/UltraTree/Private/New-HtmlCleanupSuggestions.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlCleanupSuggestions.ps1 @@ -9,14 +9,13 @@ .PARAMETER Compact Switch to use compact list format instead of full cards. #> - [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] + [CmdletBinding()] param ( [array]$Suggestions, [switch]$Compact ) if ($null -eq $Suggestions -or $Suggestions.Count -eq 0) { return '' } - if (-not $PSCmdlet.ShouldProcess('cleanup suggestions', 'Generate HTML cleanup suggestions')) { return '' } $broomIcon = Get-ThemeIcon -IconName 'Broom' @@ -36,7 +35,8 @@ "@ New-HtmlCard -Title 'Cleanup' -Icon $broomIcon -Body $body -BodyStyle 'padding: 12px;' - } else { + } + else { $cards = foreach ($sug in $Suggestions) { $sizeText = Format-ByteSize -Bytes $sug.Size $category = $script:CleanupCategories | Where-Object { $_.Name -eq $sug.Category } diff --git a/Module/src/UltraTree/Private/New-HtmlDuplicatesTable.ps1 b/Module/src/UltraTree/Private/New-HtmlDuplicatesTable.ps1 index 3f3dc70..cf807d1 100644 --- a/Module/src/UltraTree/Private/New-HtmlDuplicatesTable.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlDuplicatesTable.ps1 @@ -9,14 +9,13 @@ .PARAMETER TotalWasted Total wasted space across all duplicate groups. #> - [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] + [CmdletBinding()] param ( [array]$DuplicateGroups, [long]$TotalWasted ) if ($null -eq $DuplicateGroups -or $DuplicateGroups.Count -eq 0) { return "" } - if (-not $PSCmdlet.ShouldProcess('duplicate files', 'Generate HTML duplicates table')) { return '' } $totalWastedText = Format-ByteSize -Bytes $TotalWasted $cfg = $script:Config.Display diff --git a/Module/src/UltraTree/Private/New-HtmlFileTypeTable.ps1 b/Module/src/UltraTree/Private/New-HtmlFileTypeTable.ps1 index 817fb6f..d117def 100644 --- a/Module/src/UltraTree/Private/New-HtmlFileTypeTable.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlFileTypeTable.ps1 @@ -7,11 +7,9 @@ .PARAMETER FileTypes Array of file type objects with Extension, FileCount, and TotalSize properties. #> - [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] + [CmdletBinding()] param ([array]$FileTypes) - if (-not $PSCmdlet.ShouldProcess('file types', 'Generate HTML file type table')) { return '' } - $colors = @("#FF6B6B", "#4ECDC4", "#45B7D1", "#96CEB4", "#FFEAA7", "#DDA0DD", "#98D8C8", "#F7DC6F", "#BB8FCE", "#85C1E9", "#F8B500", "#00CED1", "#FF7F50", "#9370DB", "#20B2AA") $rows = for ($i = 0; $i -lt $FileTypes.Count; $i++) { diff --git a/Module/src/UltraTree/Private/New-HtmlInfoCard.ps1 b/Module/src/UltraTree/Private/New-HtmlInfoCard.ps1 index d5f7296..ea11a1b 100644 --- a/Module/src/UltraTree/Private/New-HtmlInfoCard.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlInfoCard.ps1 @@ -11,7 +11,7 @@ .PARAMETER Type The card type: Info, Warning, Danger, or Success. #> - [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] + [CmdletBinding()] param ( [string]$Title, [string]$Description, @@ -19,8 +19,6 @@ [string]$Type = "Info" ) - if (-not $PSCmdlet.ShouldProcess($Title, 'Generate HTML info card')) { return '' } - $style = Get-SeverityStyle -Severity $Type $classExtra = if ($Type -eq "Info") { "" } else { " $($Type.ToLower())" } diff --git a/Module/src/UltraTree/Private/New-HtmlLineChart.ps1 b/Module/src/UltraTree/Private/New-HtmlLineChart.ps1 index 11c3e0a..c401eda 100644 --- a/Module/src/UltraTree/Private/New-HtmlLineChart.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlLineChart.ps1 @@ -9,14 +9,12 @@ .PARAMETER Total The total value for calculating percentages. #> - [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] + [CmdletBinding()] param ( [array]$Segments, [long]$Total ) - if (-not $PSCmdlet.ShouldProcess('disk usage chart', 'Generate HTML line chart')) { return '' } - if ($Total -eq 0) { $Total = 1 } $bars = foreach ($seg in $Segments) { diff --git a/Module/src/UltraTree/Private/New-HtmlRankedStack.ps1 b/Module/src/UltraTree/Private/New-HtmlRankedStack.ps1 index aca7380..2ea4fd7 100644 --- a/Module/src/UltraTree/Private/New-HtmlRankedStack.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlRankedStack.ps1 @@ -8,7 +8,7 @@ .PARAMETER Sections HTML card fragments (e.g. from New-HtmlTable). #> - [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] + [CmdletBinding()] [OutputType([string])] param ( [string[]]$Sections @@ -16,7 +16,6 @@ $parts = @($Sections | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) if ($parts.Count -eq 0) { return '' } - if (-not $PSCmdlet.ShouldProcess('ranked tables', 'Generate HTML ranked stack')) { return '' } $cards = for ($i = 0; $i -lt $parts.Count; $i++) { $card = $parts[$i] -replace '
    - [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] + [CmdletBinding()] param ( [string]$Value, [string]$Description, @@ -21,8 +21,6 @@ [string]$Icon = "" ) - if (-not $PSCmdlet.ShouldProcess($Description, 'Generate HTML stat card')) { return '' } - if (-not $Color) { $Color = Get-ThemeColor -Severity "Primary" } $iconHtml = if ($Icon) { "" } else { "" } diff --git a/Module/src/UltraTree/Private/New-HtmlTable.ps1 b/Module/src/UltraTree/Private/New-HtmlTable.ps1 index 6b8abc4..59493bb 100644 --- a/Module/src/UltraTree/Private/New-HtmlTable.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlTable.ps1 @@ -11,15 +11,13 @@ .PARAMETER Icon Optional FontAwesome icon class for the title. #> - [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] + [CmdletBinding()] param ( [array]$Items, [string]$Title = "Results", [string]$Icon = "" ) - if (-not $PSCmdlet.ShouldProcess($Title, 'Generate HTML table')) { return '' } - if (-not $Icon) { $Icon = Get-ThemeIcon -IconName "Folder" } $folderIcon = Get-ThemeIcon -IconName "Folder" $fileIcon = Get-ThemeIcon -IconName "File" diff --git a/Module/src/UltraTree/Private/New-HtmlTag.ps1 b/Module/src/UltraTree/Private/New-HtmlTag.ps1 index 0a1c4d6..743d7b4 100644 --- a/Module/src/UltraTree/Private/New-HtmlTag.ps1 +++ b/Module/src/UltraTree/Private/New-HtmlTag.ps1 @@ -10,30 +10,31 @@ .PARAMETER Type Optional type: empty string (default), "disabled", or "expired". #> - [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] + [CmdletBinding()] param ( [string]$Text, [ValidateSet("", "disabled", "expired")] [string]$Type = "" ) - if (-not $PSCmdlet.ShouldProcess($Text, 'Generate HTML tag')) { return '' } - $classExtra = if ($Type) { " $Type" } else { "" } $baseStyle = 'display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 0.75rem; font-weight: 600;' switch ($Type) { 'expired' { $bgColor = Get-ThemeColor -Severity 'Danger' + $fgColor = '#fff' } 'disabled' { $bgColor = Get-ThemeColor -Severity 'Warning' + $fgColor = '#333' } default { $bgColor = Get-ThemeColor -Severity 'Success' + $fgColor = '#333' } } - $style = "$baseStyle background-color: $bgColor; color: #fff;" + $style = "$baseStyle background-color: $bgColor; color: $fgColor;" "
    $Text
    " } diff --git a/Module/src/UltraTree/Public/ConvertTo-NinjaOneHtml.ps1 b/Module/src/UltraTree/Public/ConvertTo-NinjaOneHtml.ps1 index 01c27ab..d0151e1 100644 --- a/Module/src/UltraTree/Public/ConvertTo-NinjaOneHtml.ps1 +++ b/Module/src/UltraTree/Public/ConvertTo-NinjaOneHtml.ps1 @@ -12,12 +12,15 @@ CleanupSuggestions, Duplicates, DriveInfo, and statistics. .PARAMETER MaxTopFiles Maximum file rows in per-drive Top Files table. Defaults to Display.MaxTopFiles (50). + Get-FolderSizes returns Items as one mixed file/folder list truncated by -Top (default 40). + Raise -Top on Get-FolderSizes when you need more file rows (for example -Top 200). .PARAMETER MaxTopFolders Maximum folder rows in per-drive Top Folders table. Defaults to Display.MaxTopFolders (25). + Subject to the same Get-FolderSizes -Top limit as MaxTopFiles. .PARAMETER ShowAllResults When true, includes the full "All Results by Size" table. Default true for backward compatibility. .PARAMETER FooterSuffix - Optional text appended after the UltraTree version in the footer (e.g. integrator script version). + Optional text appended after the UltraTree version in the footer (e.g. caller script version). .OUTPUTS String containing HTML markup for the report. .EXAMPLE @@ -32,6 +35,10 @@ .NOTES The output HTML assumes Bootstrap 5, Font Awesome 6, and Charts.css are available. For standalone viewing, wrap with New-HtmlWrapper. + + Top Files and Top Folders tables read from Get-FolderSizes Items, which is one + mixed list capped by -Top. Use a larger -Top when generating reports that need + many file rows (MaxTopFiles defaults to 50). #> [CmdletBinding()] [OutputType([string])] @@ -163,7 +170,8 @@ [void]$html.AppendLine('
    ') if ($driveCleanup.Count -gt 0) { [void]$html.AppendLine((New-HtmlCleanupSuggestions -Suggestions $driveCleanup -Compact)) - } else { + } + else { $checkIcon = Get-ThemeIcon -IconName "CheckCircle" [void]$html.AppendLine("
      No Cleanup Needed

    No significant cleanup opportunities found.

    ") } @@ -192,7 +200,8 @@ # === FOOTER === $scanTime = Get-Date -Format "yyyy-MM-dd HH:mm" - $footerText = "UltraTree v$($cfg.Version)" + $moduleVersion = $MyInvocation.MyCommand.Module.Version + $footerText = "UltraTree v$moduleVersion" if ($FooterSuffix) { $footerText += $FooterSuffix } diff --git a/Module/src/UltraTree/Public/Get-FolderSizes.ps1 b/Module/src/UltraTree/Public/Get-FolderSizes.ps1 index e6022ea..8d73277 100644 --- a/Module/src/UltraTree/Public/Get-FolderSizes.ps1 +++ b/Module/src/UltraTree/Public/Get-FolderSizes.ps1 @@ -11,7 +11,11 @@ function Get-FolderSizes { .PARAMETER MaxDepth Maximum folder depth to include in results (default: 5, range: 1-20). .PARAMETER Top - Maximum number of results to return (default: 40, range: 1-1000). + Maximum number of items returned in ScanResults.Items (default: 40, range: 1-1000). + Items is a single list of files and folders mixed together, sorted by size globally + (across all drives when using -AllDrives). Folders usually dominate this list. + For ranked Top Files tables in ConvertTo-NinjaOneHtml, use a larger -Top value + (for example -Top 200) so enough file rows remain after truncation. .PARAMETER FolderSize Show only folders in results (excludes individual files). .PARAMETER FileSize diff --git a/Module/src/UltraTree/Public/New-HtmlWrapper.ps1 b/Module/src/UltraTree/Public/New-HtmlWrapper.ps1 index cd8f606..b56727e 100644 --- a/Module/src/UltraTree/Public/New-HtmlWrapper.ps1 +++ b/Module/src/UltraTree/Public/New-HtmlWrapper.ps1 @@ -15,7 +15,7 @@ .EXAMPLE Get-FolderSizes -AllDrives | ConvertTo-NinjaOneHtml | New-HtmlWrapper | Out-File "report.html" #> - [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'None')] + [CmdletBinding()] [OutputType([string])] param ( [Parameter(ValueFromPipeline)] @@ -24,7 +24,6 @@ ) process { - if (-not $PSCmdlet.ShouldProcess($Title, 'Wrap HTML content')) { return } @" @@ -124,7 +123,7 @@ } .tag.disabled, .tag.warning { background-color: #f0ad4e; - color: white; + color: #333; } table { width: 100%; diff --git a/docs/images/ninjaone-dark-mode-access-errors.png b/docs/images/ninjaone-dark-mode-access-errors.png deleted file mode 100644 index c8cd2dd87722921ef4533fba377bfd48f5196cf7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6508 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