Noveris.VMwareReport.psm1


################
# Global settings
$ErrorActionPreference = "Stop"
$InformationPreference = "Continue"
Set-StrictMode -Version 2

enum VMwareReportStatus
{
    None = 0
    Ok
    Warning
    Error
}

Class VMwareReportConfig
{
    [string[]]$Reports
    [string]$Target
    [string]$Username
    [string]$Password
    [string]$SmtpServer
    [string]$SmtpSender
    [string[]]$Recipients
    [string[]]$IssueRecipients
    [string]$Site
    [PSCustomObject]$Settings

    VMwareReportConfig()
    {
        $this.Reports = [string[]]@()
        $this.Target = ""
        $this.Username = ""
        $this.Password = ""
        $this.SmtpServer = ""
        $this.SmtpSender = ""
        $this.Recipients = [string[]]@()
        $this.IssueRecipients = [string[]]@()
        $this.Site = ""
        $this.Settings = [PSCustomObject]@{}
    }
}

Class VMwareReportSummaryNotice
{
    [VMwareReportStatus]$Status
    [string]$Report
    [string]$Description

    VMwareReportSummaryNotice()
    {
        $this.Status = [VMwareReportStatus]::None
        $this.Report = ""
        $this.Description = ""
    }
}

<#
#>

Function Set-VMwareReportConfig
{
    [CmdletBinding()]
    param(
        [Parameter(mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]$ConfigPath,

        [Parameter(mandatory=$false)]
        [ValidateNotNullOrEmpty()]
        [string[]]$Reports,

        [Parameter(mandatory=$false)]
        [ValidateNotNullOrEmpty()]
        [string]$Target,

        [Parameter(mandatory=$false)]
        [ValidateNotNull()]
        [PSCredential]$Credential,

        [Parameter(mandatory=$false)]
        [ValidateNotNullOrEmpty()]
        [string]$SmtpServer,

        [Parameter(mandatory=$false)]
        [ValidateNotNullOrEmpty()]
        [string]$SmtpSender,

        [Parameter(mandatory=$false)]
        [ValidateNotNullOrEmpty()]
        [string[]]$Recipients,

        [Parameter(mandatory=$false)]
        [ValidateNotNullOrEmpty()]
        [string[]]$IssueRecipients,

        [Parameter(mandatory=$false)]
        [AllowEmptyString()]
        [ValidateNotNull()]
        [string]$Site = ""
    )

    process
    {
        [VMwareReportConfig]$config = New-Object VMwareReportConfig
        if (Test-Path -PathType Leaf $ConfigPath)
        {
            $config = [VMwareReportConfig](Get-Content $ConfigPath -Encoding UTF8 | ConvertFrom-Json)
        }

        if ($PSBoundParameters.Keys -contains "Reports")
        {
            $config.Reports = $Reports
        }

        if ($PSBoundParameters.Keys -contains "Target")
        {
            $config.Target = $Target
        }

        if ($PSBoundParameters.Keys -contains "Credential")
        {
            $config.Username = $Credential.Username
            $config.Password = $Credential.Password | ConvertFrom-SecureString
        }

        if ($PSBoundParameters.Keys -contains "SmtpServer")
        {
            $config.SmtpServer = $SmtpServer
        }

        if ($PSBoundParameters.Keys -contains "SmtpSender")
        {
            $config.SmtpSender = $SmtpSender
        }

        if ($PSBoundParameters.Keys -contains "Recipients")
        {
            $config.Recipients = $Recipients
        }

        if ($PSBoundParameters.Keys -contains "IssueRecipients")
        {
            $config.IssueRecipients = $IssueRecipients
        }

        if ($PSBoundParameters.Keys -contains "Site")
        {
            $config.Site = $Site
        }

        $config | ConvertTo-Json | Out-File -Encoding UTF8 $ConfigPath
    }
}

Function New-VMwareReportSummaryNotice
{
    [CmdletBinding()]
    param(
        [Parameter(mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]$Report,

        [Parameter(mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]$Description,

        [Parameter(mandatory=$false)]
        [ValidateNotNull()]
        [VMwareReportStatus]$Status = [VMwareReportStatus]::Ok
    )

    process
    {
        $notice = New-Object VMwareReportSummaryNotice
        $notice.Status = $Status
        $notice.Report = $Report
        $notice.Description = $Description

        $notice
    }
}

Function New-VMwareReportCell
{
    [CmdletBinding()]
    param(
        [Parameter(mandatory=$false)]
        [AllowNull()]
        [AllowEmptyString()]
        [string]$Content = "",

        [Parameter(mandatory=$false)]
        [ValidateNotNull()]
        [VMwareReportStatus]$Status,

        [Parameter(mandatory=$false)]
        [ValidateNotNull()]
        [int]$ColumnSpan
    )

    process
    {
        $header = "<td "
        if ($PSBoundParameters.Keys -contains "Status")
        {
            if ($Status -eq [VMwareReportStatus]::Error)
            {
                $header += " bgcolor=`"#FF0000`" "
            } elseif ($Status -eq [VMwareReportStatus]::Warning)
            {
                $header += " bgcolor=`"#FFCC00`" "
            } elseif ($Status -eq [VMwareReportStatus]::Ok)
            {
                $header += " bgcolor=`"#009900`" "
            }
        }

        if ($PSBoundParameters.Keys -contains "ColumnSpan")
        {
            $header += " colspan=`"$ColumnSpan`" "
        }

        $header + ">" + $Content + "</td>"
    }
}

<#
#>

Function New-VMwareReportSnapshotSummary
{
    [CmdletBinding()]
    param(
        [Parameter(mandatory=$true)]
        [ValidateNotNull()]
        [VMwareReportConfig]$Config
    )

    process
    {
        "<table><tr><th>VM</th><th>Consolidation Required</th><th>Snapshots</th></tr>"
        
        # Check for machines with snapshots or requiring consolidation
        $vmCount = 0
        foreach ($vm in Get-VM -Server $config.Target) {
            Write-Information ("Getting snapshot information for: " + $vm.Name)

            $snapshots = $vm | Get-Snapshot
            $consolidate = $vm.ExtensionData.Runtime.consolidationNeeded

            if ($consolidate -eq $false -and ($snapshots | Measure-Object).Count -lt 1)
            {
                continue
            }
            
            $vmCount++

            "<tr>"

            # Display VM name
            New-VMwareReportCell -Content $vm.Name

            # Consolidate status
            $cellStatus = [VMwareReportStatus]::None
            if ($consolidate -eq $true)
            {
                $cellStatus = [VMwareReportStatus]::Warning
            }

            New-VMwareReportCell -Status $cellStatus -Content $consolidate

            # Snapshot status
            $cellStatus = [VMwareReportStatus]::None
            $content = "None"
            if (($snapshots | Measure-Object).Count -gt 0)
            {
                $cellStatus = [VMwareReportStatus]::Warning
                $content = $snapshots | ForEach-Object {
                    $snap = $_
                    ("<b>Snapshot: </b>" + $snap.Description + "<br>")
                    ("<b>Size (MB): </b>" + ([int] $snap.SizeMB) + "<br>")
                    ("<b>Created: </b>" + $snap.Created + "<br>")
                    "<br>"
                } | Out-String
            }

            New-VMwareReportCell -Status $cellStatus -Content $content

            "</tr>"
        }

        if ($vmCount -eq 0)
        {
            "<tr>"
            New-VMwareReportCell -ColumnSpan 3 -Content "None"
            "</tr>"
        } else {
            New-VMwareReportSummaryNotice -Status Warning -Report "Snapshot Summary" -Description "Some VMs require consolidation or have snapshots"
        }

        "</table>"
    }
}

<#
#>

Function New-VMwareReportvCenterHealth
{
    [CmdletBinding()]
    param(
        [Parameter(mandatory=$true)]
        [ValidateNotNull()]
        [VMwareReportConfig]$Config
    )

    process
    {

    }
}

<#
#>

Function New-VMwareReportHostHealth
{
    [CmdletBinding()]
    param(
        [Parameter(mandatory=$true)]
        [ValidateNotNull()]
        [VMwareReportConfig]$Config
    )

    process
    {
        # Get a list of hosts to work with
        $vmhosts = Get-VMHost -Server $Config.Target

        # Build the table header
        "<table><tr><th>Condition</th>"
        $vmhosts | ForEach-Object {
            $name = $_.Name
            if (($config.Settings | Get-Member).Name -contains "StripHostSuffix" -and $config.Settings.StripHostSuffix -ne $null)
            {
                [string]$strip = $config.Settings.StripHostSuffix.ToString()
                $name = $name -replace $strip, ""
            }

            $name
        } | ForEach-Object { ("<th>{0}</th>" -f $_) }
        "</tr>"

        # Section: Connection State
        $disconnected = 0
        $maintenance = 0
        "<tr>"
        New-VMwareReportCell -Content "<b>Connection State</b>"

        foreach ($vmhost in $vmhosts)
        {
            $status = [VMwareReportStatus]::Error
            $content = "Unknown"
            switch ($vmhost.ConnectionState)
            {
                "Connected" {
                    $status = [VMwareReportStatus]::Ok
                    $content = "Connected"
                    break
                }

                "Maintenance" {
                    $maintenance++
                    $status = [VMwareReportStatus]::Warning
                    $content = "Maintenance"
                    break
                }

                default {
                    $disconnected++
                    $status = [VMwareReportStatus]::Error
                    $content = ("Not connected: " + $vmhost.ConnectionState.ToString())
                    break
                }
            }

            New-VMwareReportCell -Status $status -Content $content
        }

        "</tr>"

        if ($maintenance -gt 0)
        {
            New-VMwareReportSummaryNotice -Status Warning -Report "Host Health" -Description "Some hosts are in maintenance mode"
        }

        if ($disconnected -gt 0)
        {
            New-VMwareReportSummaryNotice -Status Error -Report "Host Health" -Description "Some hosts are not connected"
        }

        # Section: VM Counts
        "<tr>"
        $allVMs = Get-VM -Server $Config.Target
        $vmCount = ($allVMs | Measure-Object).Count
        $vmhostCount = ($vmhosts | Measure-Object).Count

        $vmPerHost = 0
        $avgPerHost = 0
        if ($vmhostCount -gt 0 -and $vmCount -gt 0)
        {
            $avgPerHost = 1 / $vmhostCount * 100
            $vmPerHost = $vmCount / $vmhostCount
        }

        $content = "<b>Virtual Machines</b>"
        $content += "<br>VMs Per Host Count (Balanced): " + $vmPerHost.ToString("0.00")
        $content += "<br>VM Per Host % (Balanced): " + $avgPerHost.ToString("0.00")
        $content += "<br>Total VMs: " + $vmCount
        New-VMwareReportCell -Content $content

        foreach ($vmhost in $vmhosts)
        {
            $hostVMCount = ($vmhost | Get-VM | Measure-Object).Count
            $hostAvg = $hostVMCount / $vmCount * 100
            New-VMwareReportCell -Content ("VMs: {0}<br>Avg: {1}" -f $hostVMCount, $hostAvg.ToString("0.00"))
        }

        "</tr>"

        # Section: CPU Utilisation
        $cpuTotalMhz = ($vmhosts | Measure-Object -sum -property CpuTotalMhz).Sum
        $cpuTotalGhz = $cpuTotalMhz / 1024
        $cpuUsageAvgMhz = ($vmhosts | Measure-Object -average -property CpuUsageMhz).Average
        $cpuUsageAvgGhz = $cpuUsageAvgMhz / 1024
        $cpuUsageMhz = ($vmhosts | Measure-Object -sum -property CpuUsageMhz).Sum
        $cpuUsageGhz = $cpuUsageMhz / 1024

        "<tr>"
        $content = ("<b>CPU</b><br>CPU Usage (Ghz): [{0}/{1}]" -f $cpuUsageGhz.ToString("0.00"), $cpuTotalGhz.ToString("0.00"))
        $usagePct = 0
        if ($cpuTotalGhz -gt 0)
        {
            $usagePct = (($cpuUsageGhz / $cpuTotalGhz) * 100)
        }

        $content += ("<br>CPU Usage %: {0}" -f $usagePct.ToString("0.00"))
        New-VMwareReportCell -Content $content

        foreach ($vmhost in $vmhosts)
        {
            $hostUsageMhz = $vmhost.CpuUsageMhz
            $hostTotalMhz = $vmhost.CpuTotalMhz
            $hostUsageGhz = $hostUsageMhz / 1024
            $hostTotalGhz = $hostTotalMhz / 1024

            # Calculate utilisation pct
            $utl = $hostUsageMhz / $hostTotalMhz * 100
            $status = [VMwareReportStatus]::Ok
            if ($utl -gt 90) {
                $status = [VMwareReportStatus]::Error
            } elseif ($utl -gt 80) {
                $status = [VMwareReportStatus]::Warn
            }

            $content = ("Host Utilisation (Ghz): [{0}/{1}] = {2}%" -f $hostUsageGhz.ToString("0.00"), $hostTotalGhz.ToString("0.00"), $utl.ToString("0.00"))

            $loadContribution = 0
            if ($cpuUsageGhz -gt 0)
            {
                $loadContribution = (($hostUsageGhz / $cpuUsageGhz) * 100)
            }

            $content += ("<br>Load Contribution: [{0}/{1}] = {2}%" -f $hostUsageGhz.ToString("0.00"), $cpuUsageGhz.ToString("0.00"), $loadContribution.ToString("0.00"))

            New-VMwareReportCell -Status $status -Content $content
        }

        "</tr>"

        # Section: Time synchronisation
        & {
            "<tr>"

            New-VMwareReportCell -Content ("<b>Time Synchronisation<br></b>Reference System: {0}" -f [System.Net.DNS]::GetHostname())

            $timeErrorHosts = 0
            $timeWarnHosts = 0
            foreach ($vmhost in $vmhosts)
            {
                $esxcli = $vmhost | Get-EsxCli -V2
                $hostTime = [DateTime]::Parse($esxcli.system.time.get.Invoke()).ToUniversalTime()
                $sysTime = [DateTime]::UtcNow

                $diffMins = ($hostTime - $sysTime).TotalMinutes
                $status = [VMwareReportStatus]::Ok
                if ([Math]::Abs($diffMins) -gt 4)
                {
                    $status = [VMwareReportStatus]::Error
                    $timeErrorHosts++
                } elseif ([Math]::Abs($diffMins) -gt 1) {
                    $status = [VMwareReportStatus]::Warning
                    $timeWarnHosts++
                }

                $mod = "+"
                if ($diffMins -lt 0)
                {
                    $mod = ""
                }

                New-VMwareReportCell -Status $status -Content ("{0}{1} min" -f $mod, $diffMins.ToString("0.00"))
            }

            if ($timeErrorHosts -gt 0)
            {
                New-VMwareReportSummaryNotice -Status Error -Report "Host Health" -Description "Some hosts have significant time drift"
            }

            if ($timeWarnHosts -gt 0)
            {
                New-VMwareReportSummaryNotice -Status Warning -Report "Host Health" -Description "Some hosts have minor time drift"
            }

            "</tr>"
        }

        # End table
        "</table>"
    }
}

<#
#>

Function New-VMwareReportDatastoreHealth
{
    [CmdletBinding()]
    param(
        [Parameter(mandatory=$true)]
        [ValidateNotNull()]
        [VMwareReportConfig]$Config
    )

    process
    {

    }
}

<#
#>

Function New-VMwareReportNetworkHealth
{
    [CmdletBinding()]
    param(
        [Parameter(mandatory=$true)]
        [ValidateNotNull()]
        [VMwareReportConfig]$Config
    )

    process
    {

    }
}

<#
#>

Function New-VMwareReportvCenterSecurity
{
    [CmdletBinding()]
    param(
        [Parameter(mandatory=$true)]
        [ValidateNotNull()]
        [VMwareReportConfig]$Config
    )

    process
    {

    }
}

<#
#>

Function New-VMwareReportHostSecurity
{
    [CmdletBinding()]
    param(
        [Parameter(mandatory=$true)]
        [ValidateNotNull()]
        [VMwareReportConfig]$Config
    )

    process
    {

    }
}

<#
#>

Function New-VMwareReportVMSecurity
{
    [CmdletBinding()]
    param(
        [Parameter(mandatory=$true)]
        [ValidateNotNull()]
        [VMwareReportConfig]$Config
    )

    process
    {

    }
}

<#
#>

Function New-VMwareReport
{
    [CmdletBinding()]
    param(
        [Parameter(mandatory=$true)]
        [ValidateNotNullOrEmpty()]
        [string]$ConfigPath,

        [Parameter(mandatory=$false)]
        [switch]$DisplayContent = $false
    )

    process
    {
        # Test for existance of config file
        if (!(Test-Path -PathType Leaf $ConfigPath))
        {
            Write-Error "ConfigPath ($ConfigPath) does not exist"
        }

        # Read config as json and convert to VMwareReportConfig object
        Write-Information "Reading configuration"
        [VMwareReportConfig]$config = $null
        try {
            $config = [VMwareReportConfig](Get-Content $ConfigPath -Encoding UTF8 | ConvertFrom-Json)
        } catch {
            Write-Information "Failed to read the VMwareReport configuration file. See error below."
            Write-Information ("Error: " + $_.ToString())
            throw $_
        }

        # decrypt credential string
        Write-Information "Decrypting password"
        $secureString = $config.Password | ConvertTo-SecureString
        $byteStr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secureString)
        $password = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($byteStr)

        # Display active configuration
        Write-Information "Current configuration:"
        $config | ConvertTo-Json

        # Import VMware.VimAutomation.Core module
        try {
            Write-Information "Importing VMware.VimAutomation.Core"
            #Install-Module VMware.PowerCli -Scope CurrentUser -Force -Confirm:$false -EA Ignore
            Import-Module VMware.VimAutomation.Core -EA Stop -WA Ignore | Out-Null
        } catch {
            Write-Information "Failed to import VMware.VimAutomation.Core module. This may not be installed. See error below."
            Write-Information ("Error: " + $_.ToString())
            throw $_
        }

        # Set PowerCli session configuration
        try {
            Write-Information "Setting PowerCli session configuration"
            Set-PowerCliConfiguration -Scope Session -DefaultVIServerMode Single -ParticipateInCeip $false -DisplayDeprecationWarnings $false -InvalidCertificateAction Ignore -Confirm:$false | Out-Null
        } catch {
            Write-Information "Error setting session configuration for PowerCli. See error below."
            Write-Information ("Error: " + $_.ToString())
            throw $_
        }

        # Connect to target server
        try {
            Write-Information "Connecting to target server"
            Connect-VIserver -Server $config.Target -User $config.Username -Password $password
        } catch {
            Write-Information "Error connecting to target server. See error below."
            Write-Information ("Error: " + $_.ToString())
            throw $_
        }

        # Create a new report frame
        $warnings = 0
        $errors = 0

        Write-Information "Generating new report frame"
        $content = New-VMwareReportFrame -Script {

            # List of all possible reports and scriptblock to generate that report
            $allReports = [ordered]@{
                SnapshotSummary = { New-VMwareReportSnapshotSummary -Config $_ }
                vCenterHealth = { New-VMwareReportvCenterHealth -Config $_ }
                HostHealth = { New-VMwareReportHostHealth -Config $_ }
                DatastoreHealth = { New-VMwareReportDatastoreHealth -Config $_ }
                NetworkHealth = { New-VMwareReportNetworkHealth -Config $_ }
                vCenterSecurity = { New-VMwareReportvCenterSecurity -Config $_ }
                HostSecurity = { New-VMwareReportHostSecurity -Config $_ }
                VMSecurity = { New-VMwareReportVMSecurity -Config $_ }
            }

            # Determine ordering of reports in output
            $effectiveReports = @()
            if (($config.Reports | Measure-Object).Count -eq 0)
            {
                $effectiveReports = $allReports.Keys | ForEach-Object { $_ }
            } else {
                foreach ($report in $config.Reports) {
                    if ($allReports.Keys -contains $report -and $effectiveReports -notcontains $report)
                    {
                        $effectiveReports += $report
                    }

                    if ($report -eq "*")
                    {
                        $allReports.Keys | ForEach-Object {
                            if ($effectiveReports -notcontains $_) {
                                $effectiveReports += $_
                            }
                        }
                    }
                }
            }

            Write-Information ("Effective Reports: " + $effectiveReports)

            # Run each of the reports specified
            $effectiveReports | ForEach-Object {
                try {
                    Write-Information ("Running report: " + $_)
                    ForEach-Object -InputObject $config -Process $allReports[$_]
                } catch {
                    Write-Information ("Error generating report. Exception: " + $_.ToString())
                    "Error generating report."
                }

                "<br><p>"
            }

        } | ForEach-Object {
            # Check if it is a string or status object
            if ([VMwareReportSummaryNotice].IsAssignableFrom($_.GetType()))
            {
                [VMwareReportSummaryNotice]$notice = $_
                if ($notice.Status -eq [VMwareReportStatus]::Warning)
                {
                    $warnings++
                } elseif ($notice.Status -eq [VMwareReportStatus]::Error)
                {
                    $errors++
                }
            } else {
                $_
            }
        } | Out-String

        if ($DisplayContent)
        {
            Write-Information "Displaying Content:"
            $content
        }

        # Disconnect from target server
        try {
            Write-Information "Disconnecting from target server"
            Disconnect-VIServer -Server $config.Target -Confirm:$false -Force
        } catch {
            Write-Information "Error disconnecting from target server. See error below. Will stil continue/non-terminating"
            Write-Information ("Error: " + $_.ToString())
        }
        
        # Build a list of recipients to email results to
        Write-Information "Building email recipient list"
        $recipients = New-Object 'System.Collections.Generic.Hashset[string]'
        $config.Recipients | ForEach-Object {
            $recipients.Add($_) | Out-Null
        }

        # Add issue recipients, if there are any errors or warnings
        if ($warnings -gt 0 -or $errors -gt 0)
        {
            Write-Information "Warnings or errors within report. Adding issue recipients."
            $config.IssueRecipients | ForEach-Object {
                $recipients.Add($_) | Out-Null
            }
        }

        # Send notification to recipients
        $dateStr = [DateTime]::Now.ToString("yyyyMMdd HHmm")
        $recipients | ForEach-Object {
            Write-Information "Sending notification to $_"
            $subject = "${dateStr}: "
            if (![string]::IsNullOrEmpty($config.Site))
            {
                $subject += ("{0} - " -f $config.Site)                
            }

            $subject += "VMware Status Report"

            $attempts = 3
            while ($attempts -gt 0)
            {
                try
                {
                    Send-MailMessage -To $_ -Subject $subject -Body $content -SmtpServer $config.SmtpServer -From $config.SmtpSender -BodyAsHtml
                    break
                } catch {
                    Write-Information ("Failure to send message. Exception: " + $_.ToString())
                    Write-Information "Retrying in 5 seconds."
                    Start-Sleep 5
                }

                $attempts--
            }

            if ($attempts -lt 1)
            {
                Write-Information "Failed to send message to $_"
            }
        }
    }
}

Function New-VMwareReportFrame
{
    [CmdletBinding()]
    param(
        [Parameter(mandatory=$true)]
        [ValidateNotNull()]
        [ScriptBlock]$Script
    )

    process
    {
        # Write header to output
        "<head>
        <style>
        table {
            font-family: arial, sans-serif;
            border-collapse: collapse;
            width: 100%;
        }
 
        td, th {
            border: 1px solid #dddddd;
            text-align: left;
            padding: 8px;
        }
 
        tr:nth-child(even) {
            background-color: #dddddd;
        }
        </style>
        </head>
        <body>"


        try {
            $summaries = New-Object 'System.Collections.Generic.Hashset[VMwareReportSummaryNotice]'

            $content = & $Script | ForEach-Object {
                # Check if there is a summary status and save that separately to be written shortly
                if ([VMwareReportSummaryNotice].IsAssignableFrom($_.GetType()))
                {
                    $summaries.Add($_) | Out-Null
                }

                $_
            }

            # Write summary table
            "<table><tr><th>Report</th><th>Description</th></tr>"
            if ($summaries.Count -gt 0)
            {
                $summaries | ForEach-Object {
                    "<tr>"
                    New-VMwareReportCell -Content $_.Report
                    New-VMwareReportCell -Status $_.Status -Content $_.Description
                    "</tr>"
                }
            } else {
                New-VMwareReportCell -ColumnSpan 2 -Content "No Summaries"
            }
            "</table><br><p>"

            # Write report content
            $content

        } catch {
            "<b>Error during report processing</b>"
            ("Error: " + $_.ToString())
            ($_.Exception | Format-List | Out-String -Stream | ForEach-Object { "{0}<br>" -f $_})
        }

        "</body></html>"
    }
}