JV-ServerInventoryReport.ps1

<#PSScriptInfo
 
.VERSION 1.0.2
.GUID c102bae3-0380-4624-8ff4-030df9fe9f6e
.AUTHOR Justin Verstijnen
.COMPANYNAME JustinVerstijnen
.COPYRIGHT (c) 2025 Justin Verstijnen. All rights reserved.
.TAGS PowerShell, Script, Example
.LICENSEURI https://opensource.org/licenses/MIT
.PROJECTURI https://github.com/JustinVerstijnen/JV-ServerInventoryReport
.ICONURI https://raw.githubusercontent.com/JustinVerstijnen/JV-ServerInventoryReport/icon.png
.RELEASENOTES First publish.
.DESCRIPTION This repository contains a PowerShell script that creates a Server Inventory Report of any Windows Server instance.
 
#>


[CmdletBinding()]
param(
    [string]$OutputPath
)

# Justin Verstijnen Server Install Updates and Restart script
# Github page: https://github.com/JustinVerstijnen/JV-ServerInventoryReport
# Let's start!
Write-Host "Script made by..." -ForegroundColor DarkCyan
Write-Host " _ _ _ __ __ _ _ _
    | |_ _ ___| |_(_)_ __ \ \ / /__ _ __ ___| |_(_)(_)_ __ ___ _ __
 _ | | | | / __| __| | '_ \ \ \ / / _ \ '__/ __| __| || | '_ \ / _ \ '_ \
| |_| | |_| \__ \ |_| | | | | \ V / __/ | \__ \ |_| || | | | | __/ | | |
 \___/ \__,_|___/\__|_|_| |_| \_/ \___|_| |___/\__|_|/ |_| |_|\___|_| |_|
                                                       |__/ "
 -ForegroundColor DarkCyan
function Test-CommandExists {
  [CmdletBinding()] param([Parameter(Mandatory)][string]$Name)
  return [bool](Get-Command -Name $Name -ErrorAction SilentlyContinue)
}

function New-Alert {
  [CmdletBinding()]
  param([Parameter(Mandatory)][string]$Text,[ValidateSet("error","warn","info","ok")]$Type="error")
  $icon = switch ($Type) { "error" { "[ERROR]" } "warn" { "[WARN]" } "info" { "[INFO]" } "ok" { "[OK]" } }
  $enc = [System.Net.WebUtility]::HtmlEncode($Text)
  "<div class='alert $Type'><span class='ico'>$icon</span><span>$enc</span></div>"
}

function ConvertTo-HtmlTable {
  [CmdletBinding()]
  param(
    [Parameter(Mandatory)][object]$InputObject,
    [string[]]$Properties,
    [string]$Title,
    [string]$TableId,
    [string]$Classes = "compact"
  )
  try {
    $pre  = $( if ($Title) { "<h3>$Title</h3>" } else { $null } )
    $frag = @($InputObject) | Select-Object -Property $Properties | ConvertTo-Html -Fragment -PreContent $pre
    $openTag = if ($TableId) { "<table id=""$TableId"" class=""$Classes"">" } else { "<table class=""$Classes"">" }
    ($frag -join "`n") -replace "<table>", $openTag
  } catch {
    New-Alert -Text "Could not render table: $Title. $($_.Exception.Message)" -Type error
  }
}

function ConvertTo-NameValueTable {
  [CmdletBinding()]
  param([Parameter(Mandatory)][object]$Object,[string[]]$Properties,[string]$Title="Overview")
  $rows = @()
  if ($Object -is [System.Collections.IDictionary]) {
    foreach($k in $Object.Keys){ $rows += [pscustomobject]@{ Name="$k"; Value=(($Object[$k] | Out-String).Trim()) } }
  } else {
    $props = if ($Properties) { $Properties } else { $Object.PSObject.Properties.Name }
    foreach($p in $props){ $rows += [pscustomobject]@{ Name="$p"; Value=(($Object.$p | Out-String).Trim()) } }
  }
  ConvertTo-HtmlTable -InputObject $rows -Title $Title -Properties "Name","Value"
}

function Format-Preformatted {
  [CmdletBinding()]
  param([Parameter(Mandatory)][string]$Text,[string]$Title)
  $enc = [System.Net.WebUtility]::HtmlEncode($Text)
  $pre = $( if ($Title) { "<h3>$Title</h3>" } else { "" } )
  "$pre<pre class=""raw"">$enc</pre>"
}

function Add-Section {
  [CmdletBinding()]
  param([Parameter(Mandatory)][string]$Id,[Parameter(Mandatory)][string]$Title,[Parameter(Mandatory)][string]$BodyHtml,[string]$Description)
  $descHtml = if ($Description) { "<div class=""desc"">$([System.Net.WebUtility]::HtmlEncode($Description))</div>" } else { "" }
@"
  <section id="$Id" class="tab-content" aria-labelledby="tab-$Id">
    <div class="section">
      <h2>$Title</h2>
      $descHtml
      $BodyHtml
    </div>
  </section>
"@

}

function Format-Percent { [CmdletBinding()] param([double]$Part,[double]$Whole) if($Whole -le 0){ return "n/a" } [math]::Round(($Part/$Whole)*100,2).ToString("0.##") + "%" }

function ConvertTo-DateTimeSafe {
  [CmdletBinding()] param([Parameter(Mandatory)][object]$Value)
  if ($null -eq $Value) { return $null }
  if ($Value -is [datetime]) { return $Value }
  $s = [string]$Value
  if ($s -match "^\d{14}\.\d{6}(\+|-)\d{3}$") { try { return [Management.ManagementDateTimeConverter]::ToDateTime($s) } catch {} }
  try { return [DateTime]::Parse($s, [System.Globalization.CultureInfo]::InvariantCulture) } catch { try { return [DateTime]::Parse($s) } catch { return $null } }
}

if (-not $OutputPath) {
  $stamp = Get-Date -Format "yyyyMMdd_HHmmss"
  $desktop = [Environment]::GetFolderPath("Desktop")
  $OutputPath = Join-Path $desktop "Server-Inventory_$env:COMPUTERNAME_$stamp.html"
}
$reportSections = New-Object System.Collections.Generic.List[string]

$sectionDescriptions = @{
  system   = "This page shows a summary of the complete system and Windows information."
  network  = "This page shows the complete network configuration including the raw data."
  firewall = "This page shows the Windows Firewall configuration and status."
  storage  = "This page shows the storage information like volumes, total size, free space and raw data."
  apps     = "This page shows all installed applications."
  roles    = "This page shows the Windows Server roles, SQL and IIS information (if applicable)."
  services = "This page shows all Windows services with state, start mode, account and path."
  shares   = "This page shows all created SMB shares, share permissions and NTFS ACLs."
  printers = "This page shows all installed printers, ports, drivers and IP addresses."
}

# ===================== System Info =====================
try {
  $cs   = Get-CimInstance Win32_ComputerSystem -ErrorAction Stop
  $os   = Get-CimInstance Win32_OperatingSystem -ErrorAction Stop
  $bios = Get-CimInstance Win32_BIOS -ErrorAction SilentlyContinue
  $proc = Get-CimInstance Win32_Processor -ErrorAction SilentlyContinue | Select-Object -First 1

  $installDt = ConvertTo-DateTimeSafe $os.InstallDate
  $bootDt    = ConvertTo-DateTimeSafe $os.LastBootUpTime
  $uptimeDays = if($bootDt){ [Math]::Round((New-TimeSpan -Start $bootDt -End (Get-Date)).TotalDays,1) } else { "(unknown)" }

  $winProdId = try { (Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion" -Name "ProductId" -ErrorAction Stop).ProductId } catch { $null }

  $sysSummary = [ordered]@{
    "Computer name"      = $env:COMPUTERNAME
    "Domain"             = $cs.Domain
    "Manufacturer"       = $cs.Manufacturer
    "Model"              = $cs.Model
    "Serial number"      = ($bios.SerialNumber | Out-String).Trim()
    "Windows product ID" = if($winProdId){ $winProdId } else { "(unknown)" }
    "OS"                 = $os.Caption
    "OS version"         = $os.Version
    "Installed on"       = if($installDt){ $installDt.ToString("yyyy-MM-dd HH:mm") } else { "(unknown)" }
    "Last boot"          = if($bootDt){ $bootDt.ToString("yyyy-MM-dd HH:mm") } else { "(unknown)" }
    "Uptime (days)"      = $uptimeDays
    "CPU model"          = $proc.Name
    "Physical cores"     = $proc.NumberOfCores
    "Logical processors" = $proc.NumberOfLogicalProcessors
    "Memory total (GB)"  = [Math]::Round($cs.TotalPhysicalMemory/1GB,2)
    "Memory free (GB)"   = [Math]::Round($os.FreePhysicalMemory*1KB/1GB,2)
  }

  $systeminfoRaw = try { (cmd /c systeminfo) -join "`r`n" } catch { "" }

  $topCards = @"
  <div class='grid'>
    <div class='card'><h4>Computer</h4><p>$env:COMPUTERNAME</p></div>
    <div class='card'><h4>OS</h4><p>$($os.Caption)</p></div>
    <div class='card'><h4>Version</h4><p>$($os.Version)</p></div>
    <div class='card'><h4>Uptime (days)</h4><p>$($uptimeDays)</p></div>
    <div class='card'><h4>CPU</h4><p>$($proc.Name)</p></div>
    <div class='card'><h4>RAM (GB)</h4><p>$([Math]::Round($cs.TotalPhysicalMemory/1GB,2)) total / $([Math]::Round($os.FreePhysicalMemory*1KB/1GB,2)) free</p></div>
  </div>
"@


  $sysHtml  = $topCards
  $sysHtml += ConvertTo-NameValueTable -Object $sysSummary -Title "Overview"
  $rawBlock = ""
  if ($systeminfoRaw) { $rawBlock += Format-Preformatted -Text $systeminfoRaw -Title "Raw data (systeminfo)" }
  $sysHtml += $rawBlock

  $reportSections.Add((Add-Section -Id "system" -Title "System Info" -BodyHtml $sysHtml -Description $sectionDescriptions.system))
} catch {
  $reportSections.Add((Add-Section -Id "system" -Title "System Info" -BodyHtml (New-Alert -Text "Failed to collect system info: $($_.Exception.Message)") -Description $sectionDescriptions.system))
}

# ===================== Network =====================
try {
  $ipconfigRaw = try { (ipconfig /all) -join "`r`n" } catch { "" }
  $adapterRows = @()
  if (Test-CommandExists Get-NetAdapter) {
    $ipcfg = Get-NetIPConfiguration -All -ErrorAction SilentlyContinue
    $binds = Get-NetAdapterBinding -ComponentID ms_tcpip6 -ErrorAction SilentlyContinue | Select-Object Name, Enabled
    foreach ($c in $ipcfg) {
      $ipv4 = ($c.IPv4Address | ForEach-Object { $_.IPAddress }) -join ", "
      $ipv6 = ($c.IPv6Address | ForEach-Object { $_.IPAddress }) -join ", "
      $dns  = ($c.DnsServer.ServerAddresses) -join ", "
      $gw   = ($c.IPv4DefaultGateway.NextHop, $c.IPv6DefaultGateway.NextHop | Where-Object { $_ }) -join ", "
      $bind = $binds | Where-Object Name -eq $c.InterfaceAlias
      $dhcp = $null; try { $iface = Get-NetIPInterface -InterfaceIndex $c.InterfaceIndex -AddressFamily IPv4 -ErrorAction Stop; $dhcp=$iface.Dhcp } catch {}
      $adapterRows += [PSCustomObject]@{
        Interface   = $c.InterfaceAlias
        Index       = $c.InterfaceIndex
        Description = $c.NetAdapter.Description
        Status      = $c.NetAdapter.Status
        MAC         = $c.NetAdapter.MacAddress
        IPv4        = $ipv4
        IPv6        = $ipv6
        Gateway     = $gw
        DNS         = $dns
        DHCP        = $dhcp
        IPv6Enabled = if ($bind) { $bind.Enabled } else { $null }
      }
    }
  }
  $netHtml = ""
  if ($adapterRows) { $netHtml += ConvertTo-HtmlTable -InputObject $adapterRows -Title "Network Adapters" -Properties "Interface","Index","Description","Status","MAC","IPv4","IPv6","Gateway","DNS","DHCP","IPv6Enabled" }
  $rawBlock = ""
  if ($ipconfigRaw) { $rawBlock += Format-Preformatted -Text $ipconfigRaw -Title "Raw data (ipconfig /all)" }
  if ($rawBlock){ $netHtml += $rawBlock }
  $reportSections.Add((Add-Section -Id "network" -Title "Network Configuration" -BodyHtml $netHtml -Description $sectionDescriptions.network))
} catch {
  $reportSections.Add((Add-Section -Id "network" -Title "Network Configuration" -BodyHtml (New-Alert -Text "Failed to collect network info: $($_.Exception.Message)") -Description $sectionDescriptions.network))
}

# ===================== Firewall and Ports =====================
try {
  $fwHtml = ""
  $profiles = $null
  if (Test-CommandExists Get-NetFirewallProfile) {
    $profiles = Get-NetFirewallProfile -ErrorAction SilentlyContinue | Select-Object Name, Enabled, DefaultInboundAction, DefaultOutboundAction, NotifyOnListen, AllowInboundRules
    if ($profiles) { $fwHtml += ConvertTo-HtmlTable -InputObject $profiles -Title "Firewall Profiles" -Properties * -TableId "fw-profiles" }
  }
  if (Test-CommandExists Get-NetFirewallRule) {
    $customRules = Get-NetFirewallRule -PolicyStore ActiveStore -ErrorAction SilentlyContinue | Where-Object { -not $_.Group -and $_.PolicyStoreSourceType -eq "PersistentStore" }
    if ($customRules) {
      $portFilters = $customRules | Get-NetFirewallPortFilter -ErrorAction SilentlyContinue | Select-Object Name, Protocol, LocalPort, RemotePort, DynamicTarget, Program
      if ($portFilters) { $fwHtml += ConvertTo-HtmlTable -InputObject $portFilters -Title "Custom Firewall Rules (PersistentStore, no Group)" -Properties * }
    }
  }
  $netstatRaw = try { (netstat -a -n -o) -join "`r`n" } catch { "" }
  $tcpListen = @()
  if (Test-CommandExists Get-NetTCPConnection) { $tcpListen = Get-NetTCPConnection -State Listen -ErrorAction SilentlyContinue | Select-Object LocalAddress, LocalPort, OwningProcess }
  if ($tcpListen) { $fwHtml += ConvertTo-HtmlTable -InputObject $tcpListen -Title "Listening TCP Ports (Get-NetTCPConnection)" -Properties * }

  $rawBlock = ""
  if ($profiles) { $rawBlock += Format-Preformatted -Text (($profiles | ConvertTo-Json -Depth 4) | Out-String) -Title "Raw data (Firewall profiles JSON)" }
  if ($netstatRaw) { $rawBlock += Format-Preformatted -Text $netstatRaw -Title "Raw data (netstat -a -n -o)" }
  if ($rawBlock){ $fwHtml += $rawBlock }

  $reportSections.Add((Add-Section -Id "firewall" -Title "Firewall and Ports" -BodyHtml $fwHtml -Description $sectionDescriptions.firewall))
} catch {
  $reportSections.Add((Add-Section -Id "firewall" -Title "Firewall and Ports" -BodyHtml (New-Alert -Text "Failed to collect firewall/port info: $($_.Exception.Message)") -Description $sectionDescriptions.firewall))
}

# ===================== Storage =====================
try {
  if (Test-CommandExists Get-Volume) {
    $vols = Get-Volume -ErrorAction SilentlyContinue | Where-Object { $_.DriveType -eq "Fixed" -and $_.FileSystem } |
      Select-Object DriveLetter, Path, FileSystem, HealthStatus,
        @{n="SizeGB";e={[math]::Round($_.Size/1GB,2)}},
        @{n="FreeGB";e={[math]::Round($_.SizeRemaining/1GB,2)}},
        @{n="Free%";e={Format-Percent $_.SizeRemaining $_.Size}}
  } else {
    $vols = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3" -ErrorAction SilentlyContinue |
      Select-Object @{n="DriveLetter";e={$_.DeviceID}}, @{n="Path";e={$_.ProviderName}}, FileSystem, @{n="HealthStatus";e={"n/a"}},
        @{n="SizeGB";e={[math]::Round($_.Size/1GB,2)}},
        @{n="FreeGB";e={[math]::Round($_.FreeSpace/1GB,2)}},
        @{n="Free%";e={Format-Percent $_.FreeSpace $_.Size}}
  }
  $stHtml = ConvertTo-HtmlTable -InputObject $vols -Title "Volumes" -Properties *

  # Root listings for C:\, D:\, E:\ (only if the drive exists)
  $rootLs = ""
  foreach ($drv in "C:","D:","E:") {
    try {
      $path = "$drv\"
      if (Test-Path $path) {
        $ls = Get-ChildItem -Force -LiteralPath $path -ErrorAction SilentlyContinue |
             Select-Object Mode, LastWriteTime, Length, Name |
             Format-Table -AutoSize | Out-String
        $rootLs += (Format-Preformatted -Text $ls -Title "Root listing $path")
      }
    } catch {}
  }
  if ($rootLs) { $stHtml += $rootLs }

  $rawBlock = Format-Preformatted -Text (($vols | ConvertTo-Json -Depth 4) | Out-String) -Title "Raw data (Volumes JSON)"
  $stHtml += $rawBlock

  $reportSections.Add((Add-Section -Id "storage" -Title "Storage" -BodyHtml $stHtml -Description $sectionDescriptions.storage))
} catch {
  $reportSections.Add((Add-Section -Id "storage" -Title "Storage" -BodyHtml (New-Alert -Text "Failed to collect storage info: $($_.Exception.Message)") -Description $sectionDescriptions.storage))
}

# ===================== Applications =====================
try {
  $uninstallKeys = @(
    "HKLM:\Software\Microsoft\Windows\CurrentVersion\Uninstall",
    "HKLM:\Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall",
    "HKCU:\Software\Microsoft\Windows\CurrentVersion\Uninstall"
  )
  $apps = @(
    foreach ($k in $uninstallKeys) {
      if (Test-Path $k) {
        Get-ChildItem $k -ErrorAction SilentlyContinue | ForEach-Object {
          try {
            $p = Get-ItemProperty $_.PsPath -ErrorAction Stop
            if ($p.DisplayName) {
              [PSCustomObject]@{
                Name        = $p.DisplayName
                Version     = $p.DisplayVersion
                Publisher   = $p.Publisher
                InstallDate = $p.InstallDate
                Uninstall   = $p.UninstallString
                Wow6432     = ($k -like "*WOW6432Node*")
              }
            }
          } catch {}
        }
      }
    }
  )
  $apps = @($apps) | Sort-Object Name, Version
  $appHtml = ConvertTo-HtmlTable -InputObject $apps -Title "Installed Software" -Properties "Name","Version","Publisher","InstallDate","Wow6432","Uninstall"
  $appHtml += Format-Preformatted -Text (($apps | ConvertTo-Json -Depth 4) | Out-String) -Title "Raw data (Installed software JSON)"
  $reportSections.Add((Add-Section -Id "apps" -Title "Applications" -BodyHtml $appHtml -Description $sectionDescriptions.apps))
} catch {
  $reportSections.Add((Add-Section -Id "apps" -Title "Applications" -BodyHtml (New-Alert -Text "Failed to collect application list: $($_.Exception.Message)") -Description $sectionDescriptions.apps))
}

# ===================== Roles / SQL / IIS =====================
$rolesHtml = ""
try {
  if (Test-CommandExists Get-WindowsFeature) {
    $roles = Get-WindowsFeature -ErrorAction SilentlyContinue | Where-Object Installed | Select-Object Name, DisplayName, Installed
    if ($roles) { $rolesHtml += ConvertTo-HtmlTable -InputObject $roles -Title "Installed Roles and Features" -Properties "Name","DisplayName","Installed" }
  } else {
    $rolesHtml += New-Alert -Text "Get-WindowsFeature is not available. Perhaps this is no Windows Server installation." -Type warn
  }
} catch { $rolesHtml += New-Alert -Text "Failed to collect roles: $($_.Exception.Message)" }

function Get-SqlInstanceNames {
  $instances = @()
  try {
    $regPath = "HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server\Instance Names\SQL"
    if (Test-Path $regPath) {
      $props = Get-ItemProperty $regPath
      foreach ($name in ($props.PSObject.Properties | Where-Object { $_.MemberType -eq "NoteProperty" }).Name) {
        $instances += if ($name -eq "MSSQLSERVER") { $env:COMPUTERNAME } else { "$env:COMPUTERNAME\$name" }
      }
    }
  } catch {}
  if (-not $instances) {
    Get-Service -Name "MSSQL*" -ErrorAction SilentlyContinue | ForEach-Object {
      if ($_.Name -eq "MSSQLSERVER") { $instances += $env:COMPUTERNAME } else { $instances += "$env:COMPUTERNAME$([System.IO.Path]::DirectorySeparatorChar)$($_.Name -replace ""^MSSQL\\$"","""")" }
    }
  }
  $instances | Select-Object -Unique
}

function Get-SqlDbMdfInfo {
  $out = @()
  $instances = Get-SqlInstanceNames
  if (-not $instances) { return $out }
  $smoLoaded = $false
  foreach ($asm in "Microsoft.SqlServer.Smo","Microsoft.SqlServer.ConnectionInfo","Microsoft.SqlServer.SmoExtended","Microsoft.SqlServer.Management.Sdk.Sfc") {
    try { Add-Type -AssemblyName $asm -ErrorAction Stop; $smoLoaded = $true } catch {}
  }
  foreach ($inst in $instances) {
    if ($smoLoaded) {
      try {
        $srv = New-Object Microsoft.SqlServer.Management.Smo.Server $inst
        foreach ($db in $srv.Databases) {
          try {
            $files = $db.EnumFiles() | Where-Object { $_.FileName -match "\.mdf$" }
            foreach ($f in $files) { $out += [PSCustomObject]@{ Instance=$inst; Database=$db.Name; MdfPath=$f.FileName } }
          } catch { $out += [PSCustomObject]@{ Instance=$inst; Database=$db.Name; MdfPath="(could not read MDF path)" } }
        }
        continue
      } catch {}
    }
    $sqlcmd = Get-Command sqlcmd.exe -ErrorAction SilentlyContinue
    if ($sqlcmd) {
      $query = "set nocount on; select DB_NAME(database_id) as DBName, physical_name from sys.master_files where type_desc='ROWS' and physical_name like '%.mdf' order by 1,2;"
      try {
        $raw = & $sqlcmd.Source -S $inst -E -Q $query -h -1 -W -s '|' 2>$null
        foreach ($line in $raw) {
          if ($line -match "\|") {
            $parts = $line -split "\|"
            $dbn=$parts[0].Trim(); $path=$parts[1].Trim()
            if ($dbn -and $path -and $path -match "\.mdf$") { $out += [PSCustomObject]@{ Instance=$inst; Database=$dbn; MdfPath=$path } }
          }
        }
      } catch {}
    }
  }
  return $out
}

try {
  $sqlData = Get-SqlDbMdfInfo
  if ($sqlData -and $sqlData.Count -gt 0) {
    $rolesHtml += ConvertTo-HtmlTable -InputObject $sqlData -Title "SQL Server Databases (.MDF)" -Properties "Instance","Database","MdfPath"
    $rolesHtml += Format-Preformatted -Text (($sqlData | ConvertTo-Json -Depth 4) | Out-String) -Title "Raw data (SQL .MDF JSON)"
  } else {
    $rolesHtml += New-Alert -Text "SQL Server is not installed on this server." -Type error
  }
} catch { $rolesHtml += New-Alert -Text "SQL detection error: $($_.Exception.Message)" }

try {
  $iisInstalled = $false
  if (Test-CommandExists Get-WindowsFeature) { $feat = Get-WindowsFeature -Name Web-Server -ErrorAction SilentlyContinue; $iisInstalled = [bool]($feat -and $feat.Installed) }
  if ($iisInstalled) {
    Import-Module WebAdministration -ErrorAction SilentlyContinue | Out-Null
    $sites = Get-Website -ErrorAction SilentlyContinue

    $siteBindRows = @()
    foreach ($s in $sites) {
      $bindings = Get-WebBinding -Name $s.Name -ErrorAction SilentlyContinue
      foreach ($b in $bindings) {
        $proto = $b.protocol
        $info  = $b.bindingInformation
        $ip,$port,$hostHeader = $info -split ":"
        $siteBindRows += [PSCustomObject]@{
          Site         = $s.Name
          State        = $s.State
          AppPool      = $s.applicationPool
          Protocol     = $proto
          IP           = $ip
          Port         = $port
          HostHeader   = $hostHeader
          PhysicalRoot = $s.physicalPath
        }
      }
    }
    if ($siteBindRows) {
      $rolesHtml += ConvertTo-HtmlTable -InputObject $siteBindRows -Title "IIS Sites and Bindings" -Properties "Site","State","AppPool","Protocol","IP","Port","HostHeader","PhysicalRoot"
      $rolesHtml += Format-Preformatted -Text (($siteBindRows | ConvertTo-Json -Depth 4) | Out-String) -Title "Raw data (IIS Sites/Bindings JSON)"
    }

    $appRows = @()
    foreach ($s in $sites) {
      $apps = Get-WebApplication -Site $s.Name -ErrorAction SilentlyContinue
      foreach ($a in $apps) {
        $appRows += [PSCustomObject]@{
          Site         = $s.Name
          Application  = ($a.Path.TrimStart("/"))
          AppPool      = $a.ApplicationPool
          PhysicalPath = $a.PhysicalPath
        }
      }
    }
    if ($appRows) {
      $rolesHtml += ConvertTo-HtmlTable -InputObject $appRows -Title "IIS Applications" -Properties "Site","Application","AppPool","PhysicalPath"
      $rolesHtml += Format-Preformatted -Text (($appRows | ConvertTo-Json -Depth 4) | Out-String) -Title "Raw data (IIS Applications JSON)"
    }
  } else {
    $rolesHtml += New-Alert -Text "IIS (Web-Server) is not installed on this server." -Type error
  }
} catch { $rolesHtml += New-Alert -Text "IIS information error: $($_.Exception.Message)" }

$reportSections.Add((Add-Section -Id "roles" -Title "Server Roles / SQL / IIS" -BodyHtml $rolesHtml -Description $sectionDescriptions.roles))

# ===================== Services =====================
try {
  $svcs = Get-CimInstance Win32_Service -ErrorAction SilentlyContinue | Select-Object Name, DisplayName, State, StartMode, StartName, PathName
  $svcHtml = ConvertTo-HtmlTable -InputObject $svcs -Title "All Services" -Properties "Name","DisplayName","State","StartMode","StartName","PathName"
  $svcHtml += Format-Preformatted -Text (($svcs | ConvertTo-Json -Depth 4) | Out-String) -Title "Raw data (Services JSON)"
  $reportSections.Add((Add-Section -Id "services" -Title "Services" -BodyHtml $svcHtml -Description $sectionDescriptions.services))
} catch {
  $reportSections.Add((Add-Section -Id "services" -Title "Services" -BodyHtml (New-Alert -Text "Failed to collect services: $($_.Exception.Message)") -Description $sectionDescriptions.services))
}

# ===================== Shares =====================
try {
  $sharesHtml = ""
  $shares = $sp = $ntfs = $null
  if (Test-CommandExists Get-SmbShare) {
    $shares = Get-SmbShare -ErrorAction SilentlyContinue | Where-Object { -not $_.Special }
    if ($shares) {
      $sharesHtml += ConvertTo-HtmlTable -InputObject $shares -Title "Shares (non-administrative)" -Properties "Name","Path","Description","CachingMode","EncryptData"
      $sp = foreach ($s in $shares) { Get-SmbShareAccess -Name $s.Name -ErrorAction SilentlyContinue | Select-Object @{n="Share";e={$s.Name}}, AccountName, AccessControlType, AccessRight }
      if ($sp) { $sharesHtml += ConvertTo-HtmlTable -InputObject $sp -Title "Share Permissions" -Properties "Share","AccountName","AccessControlType","AccessRight" }
      $ntfs = foreach ($s in $shares) {
        try { $acl = Get-Acl -Path $s.Path -ErrorAction Stop } catch { $acl = $null }
        if ($acl) {
          foreach ($ace in $acl.Access) { [PSCustomObject]@{ Path=$s.Path; Identity=$ace.IdentityReference; Rights=$ace.FileSystemRights; Inherited=$ace.IsInherited; Type=$ace.AccessControlType } }
        } else {
          [PSCustomObject]@{ Path=$s.Path; Identity="(no access)"; Rights="n/a"; Inherited="n/a"; Type="n/a" }
        }
      }
      if ($ntfs) { $sharesHtml += ConvertTo-HtmlTable -InputObject $ntfs -Title "NTFS Permissions" -Properties "Path","Identity","Rights","Inherited","Type" }
    } else {
      $sharesHtml += New-Alert -Text "No non-administrative shares found." -Type info
    }
  } else {
    $sharesHtml += New-Alert -Text "Get-SmbShare is not available on this system." -Type warn
  }
  $rawText = ""
  if ($shares) { $rawText += (($shares | ConvertTo-Json -Depth 4) | Out-String) + "`r`n" }
  if ($sp)     { $rawText += (($sp     | ConvertTo-Json -Depth 4) | Out-String) + "`r`n" }
  if ($ntfs)   { $rawText += (($ntfs   | ConvertTo-Json -Depth 4) | Out-String) }
  if ($rawText){ $sharesHtml += Format-Preformatted -Text $rawText -Title "Raw data (Shares JSON)" }

  $reportSections.Add((Add-Section -Id "shares" -Title "Shares" -BodyHtml $sharesHtml -Description $sectionDescriptions.shares))
} catch {
  $reportSections.Add((Add-Section -Id "shares" -Title "Shares" -BodyHtml (New-Alert -Text "Failed to collect share information: $($_.Exception.Message)") -Description $sectionDescriptions.shares))
}

# ===================== Printers =====================
try {
  $prtHtml = ""
  $rows = $null
  if (Test-CommandExists Get-Printer) {
    $printers = Get-Printer -ErrorAction SilentlyContinue
    $ports    = Get-PrinterPort -ErrorAction SilentlyContinue | Select-Object Name, PrinterHostAddress
    $drivers  = Get-PrinterDriver -ErrorAction SilentlyContinue | Select-Object Name, Manufacturer
    $rows = foreach ($p in $printers) {
      $port = $ports | Where-Object Name -eq $p.PortName | Select-Object -First 1
      $ip = $null
      if ($port -and $port.PrinterHostAddress) { $ip = $port.PrinterHostAddress }
      elseif ($p.PortName -match "^IP_(\d+\.\d+\.\d+\.\d+)$") { $ip = $Matches[1] }
      $drv = $drivers | Where-Object Name -eq $p.DriverName | Select-Object -First 1
      [PSCustomObject]@{ Name=$p.Name; Driver=$p.DriverName; DriverVendor=$drv.Manufacturer; Port=$p.PortName; IPAddress=$ip; Shared=$p.Shared; ShareName=$p.ShareName }
    }
    if ($rows) { $prtHtml += ConvertTo-HtmlTable -InputObject $rows -Title "Printers" -Properties "Name","Driver","DriverVendor","Port","IPAddress","Shared","ShareName" }
  } else {
    $prtHtml += New-Alert -Text "Printer cmdlets not available (PrintManagement module missing?)." -Type warn
  }
  if ($rows){ $prtHtml += Format-Preformatted -Text (($rows | ConvertTo-Json -Depth 4) | Out-String) -Title "Raw data (Printers JSON)" }
  $reportSections.Add((Add-Section -Id "printers" -Title "Printers" -BodyHtml $prtHtml -Description $sectionDescriptions.printers))
} catch {
  $reportSections.Add((Add-Section -Id "printers" -Title "Printers" -BodyHtml (New-Alert -Text "Failed to collect printer info: $($_.Exception.Message)") -Description $sectionDescriptions.printers))
}

# ===================== CSS =====================
$css = @"
:root{--hdrH:64px}
 
*{box-sizing:border-box}
html{font-family:Segoe UI,Arial;line-height:1.35}
body{margin:0;background:#F2F2F2;color:#111827}
 
/* Header: gradient from #8EAFDA to white, centered content */
header{
  position:sticky; top:0; z-index:30;
  background:linear-gradient(180deg,#8EAFDA 0%, #FFFFFF 100%);
  padding:16px 24px;
  color:#0b1220;
  display:flex; flex-direction:column; align-items:center; text-align:center; gap:6px;
}
header h1{margin:0;font-size:20px;color:#0b1220}
header .meta{opacity:.9;font-size:12px;color:#0b1220}
 
/* Logo */
header .brand{display:inline-block; line-height:0}
header .brand img{width:35px;height:35px;border-radius:6px}
header .brand:focus{outline:2px solid rgba(0,0,0,.2); outline-offset:3px}
 
/* Tabs under header; centered */
nav.tabs{
  position:sticky; top:var(--hdrH); z-index:25;
  display:flex; flex-wrap:wrap; gap:6px; align-content:flex-start; justify-content:center;
  padding:10px 12px; background:#ffffff; border-bottom:1px solid #e5e7eb;
  overflow:visible!important; max-height:none
}
nav.tabs a{padding:8px 12px;border-radius:10px;background:#f3f4f6;color:#111827;text-decoration:none;font-size:13px;transition:.15s}
nav.tabs a:hover{background:#e5e7eb}
nav.tabs a.active{background:#8EAFDA;color:#0b1220;box-shadow:0 0 0 1px rgba(0,0,0,.05) inset}
 
/* Content */
main{padding:18px}
 
/* Section cards */
.section{background:#ffffff;border:1px solid #e5e7eb;border-radius:14px;padding:16px;margin-bottom:16px;box-shadow:0 1px 0 rgba(0,0,0,.03) inset}
.section h2{margin-top:0;font-size:18px;color:#8EAFDA}
.desc{margin:8px 0 14px;font-size:13px;color:#334155;background:#f8fafc;border:1px solid #e5e7eb;border-radius:10px;padding:10px 12px}
 
/* Alerts */
.alert{display:flex;gap:8px;align-items:flex-start;border-radius:10px;padding:10px 12px;margin:8px 0}
.alert .ico{font-size:12px}
.alert.error{background:#fee2e2;color:#7f1d1d}
.alert.warn{background:#fef3c7;color:#78350f}
.alert.info{background:#e0f2fe;color:#1e3a8a}
.alert.ok{background:#dcfce7;color:#065f46}
 
/* Preformatted (raw blocks have a light blue tint) */
pre{background:#f8fafc;border:1px solid #e5e7eb;border-radius:10px;padding:12px;overflow:auto;max-height:15em;white-space:pre;color:#111827}
pre.raw{background:#EAF2FF;border-color:#CFE0FF}
 
/* Tables */
.tablewrap{overflow:auto}
table{border-collapse:collapse;width:100%;margin:8px 0;background:#ffffff}
th,td{border-bottom:1px solid #e5e7eb;padding:8px 10px;text-align:left;color:#111827}
th{position:sticky;top:0;background:#f1f5f9}
tr:hover{background:#f9fafb}
.compact th,.compact td{font-size:12px}
 
/* Cards */
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px}
.card{background:#ffffff;border:1px solid #e5e7eb;border-radius:12px;padding:12px}
.card h4{margin:0 0 6px 0;font-size:12px;color:#475569;text-transform:uppercase;letter-spacing:.06em}
.card p{margin:0;font-size:14px;color:#111827}
 
/* Tab visibility */
.tab-content{display:none}
.tab-content.active{display:block}
 
/* Disabled firewall profile rows */
tr.danger{background:#fee2e2 !important;color:#991b1b !important}
 
footer{opacity:.7;font-size:12px;padding:12px 18px;color:#334155}
"@


# ===================== JS =====================
$js = @"
(function(){
  function setHeaderHeightVar(){
    var hdr = document.querySelector('header');
    if(!hdr) return;
    var h = Math.round(hdr.getBoundingClientRect().height);
    document.documentElement.style.setProperty('--hdrH', h + 'px');
  }
 
  var tabs = document.querySelectorAll('nav.tabs a');
  var secs = document.querySelectorAll('.tab-content');
 
  function highlightFirewallRows(){
    var tbl = document.getElementById('fw-profiles');
    if(!tbl) return;
    var rows = tbl.querySelectorAll('tr');
    if(rows.length < 2) return;
 
    var header = rows[0].querySelectorAll('th');
    var enabledIdx = -1;
    for (var i=0;i<header.length;i++){
      if(header[i].textContent.trim().toLowerCase() === 'enabled'){ enabledIdx = i; break; }
    }
    if(enabledIdx === -1) return;
 
    for (var r=1;r<rows.length;r++){
      var cells = rows[r].children;
      var val = (cells[enabledIdx]?.textContent || '').trim().toLowerCase();
      var disabled = (val === 'false' || val === '0' || val === 'no');
      rows[r].classList.toggle('danger', disabled);
    }
  }
 
  function activate(id){
    for (var i=0;i<secs.length;i++){ secs[i].classList.toggle('active', secs[i].id===id); }
    for (var j=0;j<tabs.length;j++){ tabs[j].classList.toggle('active', (tabs[j].getAttribute('href')==='#'+id)); }
    try{ history.replaceState(null,'','#'+id); }catch(e){}
    highlightFirewallRows();
  }
 
  for (var k=0;k<tabs.length;k++){
    tabs[k].addEventListener('click', function(e){ e.preventDefault(); activate(this.getAttribute('href').substring(1)); });
  }
 
  window.addEventListener('load', setHeaderHeightVar);
  window.addEventListener('resize', setHeaderHeightVar);
  setHeaderHeightVar();
 
  var wanted = (location.hash ? location.hash.substring(1) : 'system');
  if (!document.getElementById(wanted)) { wanted = 'system'; }
  activate(wanted);
  highlightFirewallRows();
})();
"@


# ===================== NAV + HTML =====================
$idsAndTitles = @(
  @{Id="system";Title="System Info"},
  @{Id="network";Title="Network"},
  @{Id="firewall";Title="Firewall and Ports"},
  @{Id="storage";Title="Storage"},
  @{Id="apps";Title="Applications"},
  @{Id="roles";Title="Server Roles / SQL / IIS"},
  @{Id="services";Title="Services"},
  @{Id="shares";Title="Shares"},
  @{Id="printers";Title="Printers"}
)
$nav = @()
foreach($it in $idsAndTitles){ $nav += ('<a id="tab-{0}" class="tab-link" href="#{0}">{1}</a>' -f $it.Id, $it.Title) }

$html = @"
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <title>Server Inventory | $env:COMPUTERNAME</title>
  <style>$css</style>
</head>
<body>
  <header>
    <a class="brand" href="https://justinverstijnen.nl/" target="_blank" rel="noopener">
      <img src="https://justinverstijnen.nl/wp-content/uploads/2025/04/cropped-Logo-2.0-Transparant.png" alt="Logo" width="35" height="35" />
    </a>
    <h1>Server Inventory $env:COMPUTERNAME</h1>
    <div class="meta">Generated: $(Get-Date -Format "yyyy-MM-dd HH:mm:ss") User: $env:USERNAME Domain: $env:USERDOMAIN</div>
  </header>
  <nav class="tabs">
    $($nav -join "`n")
  </nav>
  <main>
    $($reportSections -join "`n")
  </main>
  <footer><a href="https://github.com/JustinVerstijnen/JV-ServerInventoryReport/tree/main" target="_blank" rel="noopener">Report generated by JV-ServerInventoryReport.ps1</a></footer>
  <script>$js</script>
</body>
</html>
"@


try {
  $null = New-Item -Path (Split-Path $OutputPath) -ItemType Directory -Force -ErrorAction SilentlyContinue
  $html | Out-File -FilePath $OutputPath -Encoding UTF8
  Write-Host "Report written to: $OutputPath" -ForegroundColor Cyan
} catch {
  Write-Warning "Could not write report: $($_.Exception.Message)"
}