helpers/docker/DockerHelpers.ps1

# =============================================================================
# Docker Engine API Helpers for WhatsUpGoldPS
# Communicates with Docker Engine REST API (v1.45+).
# Enable remote API: https://docs.docker.com/engine/daemon/remote-access/
# Default ports: 2375 (unencrypted) / 2376 (TLS)
# =============================================================================

function Initialize-SSLBypass {
    if ($PSVersionTable.PSEdition -eq 'Core') {
        $PSDefaultParameterValues["Invoke-RestMethod:SkipCertificateCheck"] = $true
        $PSDefaultParameterValues["Invoke-WebRequest:SkipCertificateCheck"] = $true
    }
    else {
        if (-not ([System.Management.Automation.PSTypeName]'SSLValidator').Type) {
            Add-Type -TypeDefinition @"
using System.Net;
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
public static class SSLValidator {
    private static bool OnValidateCertificate(
        object sender,
        X509Certificate certificate,
        X509Chain chain,
        SslPolicyErrors sslPolicyErrors) {
        return true;
    }
    public static void OverrideValidation() {
        ServicePointManager.ServerCertificateValidationCallback =
            new RemoteCertificateValidationCallback(OnValidateCertificate);
        ServicePointManager.Expect100Continue = false;
        ServicePointManager.DefaultConnectionLimit = 64;
        ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls12;
    }
}
"@

        }
        [SSLValidator]::OverrideValidation()
    }
    Write-Warning "Ignoring SSL certificate validation errors. Use this option with caution."
}

function Invoke-DockerAPI {
    <#
    .SYNOPSIS
        Sends a request to the Docker Engine API.
    .DESCRIPTION
        Wrapper around Invoke-RestMethod for Docker Engine API calls.
        Handles connection reuse, retries on PS 5.1 connection-pool
        exhaustion, and optional TLS client certificate auth.
    .PARAMETER BaseUri
        The base URI of the Docker host (e.g. http://192.168.1.100:2375).
    .PARAMETER Endpoint
        The API path (e.g. /containers/json).
    .PARAMETER Method
        HTTP method. Defaults to GET.
    .PARAMETER Body
        Optional hashtable body for POST requests.
    .PARAMETER Certificate
        Optional X509Certificate2 for TLS client-cert auth (port 2376).
    .EXAMPLE
        Invoke-DockerAPI -BaseUri "http://docker01:2375" -Endpoint "/info"
    #>

    param(
        [Parameter(Mandatory)][string]$BaseUri,
        [string]$Endpoint = '/',
        [string]$Method = 'Get',
        [hashtable]$Body,
        [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate
    )
    $uri = "$BaseUri$Endpoint"
    $params = @{ Uri = $uri; Method = $Method; ContentType = 'application/json' }
    if ($Body) { $params.Body = ($Body | ConvertTo-Json -Depth 10 -Compress) }
    if ($Certificate) { $params.Certificate = $Certificate }

    $maxRetries = if ($PSVersionTable.PSEdition -eq 'Core') { 0 } else { 2 }
    for ($attempt = 0; $attempt -le $maxRetries; $attempt++) {
        try {
            return (Invoke-RestMethod @params)
        } catch {
            $isClosed = $_.Exception.Message -match 'underlying connection was closed|unexpected error occurred on a send'
            if ($isClosed -and $attempt -lt $maxRetries) {
                try {
                    $sp = [System.Net.ServicePointManager]::FindServicePoint([System.Uri]$uri)
                    $sp.CloseConnectionGroup('')
                } catch {}
                Start-Sleep -Milliseconds (300 * ($attempt + 1))
            } else {
                throw
            }
        }
    }
}

function Connect-DockerServer {
    <#
    .SYNOPSIS
        Tests connectivity to a Docker Engine API endpoint.
    .DESCRIPTION
        Validates that the Docker Engine API is reachable by calling /version.
        Returns a connection object containing the base URI, API version, and
        Docker version information for use in subsequent API calls.
    .PARAMETER DockerHost
        The hostname or IP address of the Docker host.
    .PARAMETER Port
        TCP port for the Docker API. Defaults to 2375 (unencrypted).
        Use 2376 for TLS.
    .PARAMETER UseTLS
        Use HTTPS instead of HTTP.
    .PARAMETER IgnoreSSLErrors
        Skip SSL certificate validation (for self-signed certs).
    .PARAMETER Certificate
        Optional X509Certificate2 for TLS client-cert authentication.
    .EXAMPLE
        $conn = Connect-DockerServer -DockerHost "docker01" -Port 2375
        Connects to Docker Engine via unencrypted HTTP.
    .EXAMPLE
        $conn = Connect-DockerServer -DockerHost "docker01" -Port 2376 -UseTLS -IgnoreSSLErrors
        Connects via HTTPS with self-signed cert bypass.
    #>

    param(
        [Parameter(Mandatory)][string]$DockerHost,
        [int]$Port = 2375,
        [switch]$UseTLS,
        [switch]$IgnoreSSLErrors,
        [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate
    )

    if ($IgnoreSSLErrors) { Initialize-SSLBypass }

    $scheme = if ($UseTLS -or $Port -eq 2376) { 'https' } else { 'http' }
    $baseUri = "${scheme}://${DockerHost}:${Port}"

    try {
        $version = Invoke-DockerAPI -BaseUri $baseUri -Endpoint '/version' -Certificate $Certificate
    }
    catch {
        throw "Failed to connect to Docker Engine at ${baseUri}: $($_.Exception.Message)"
    }

    $conn = [PSCustomObject]@{
        BaseUri      = $baseUri
        ApiVersion   = $version.ApiVersion
        DockerVersion = $version.Version
        OS           = $version.Os
        Arch         = $version.Arch
        KernelVersion = $version.KernelVersion
        GoVersion    = $version.GoVersion
        Certificate  = $Certificate
    }

    Write-Verbose "Connected to Docker $($conn.DockerVersion) at $baseUri (API $($conn.ApiVersion))"
    return $conn
}

function Get-DockerSystemInfo {
    <#
    .SYNOPSIS
        Returns Docker Engine system-wide information.
    .DESCRIPTION
        Calls GET /info and returns server details including OS, kernel,
        storage driver, container/image counts, and resource limits.
    .PARAMETER Connection
        A connection object from Connect-DockerServer.
    .EXAMPLE
        $info = Get-DockerSystemInfo -Connection $conn
    #>

    param(
        [Parameter(Mandatory)]$Connection
    )
    $info = Invoke-DockerAPI -BaseUri $Connection.BaseUri -Endpoint '/info' -Certificate $Connection.Certificate

    [PSCustomObject]@{
        Hostname        = "$($info.Name)"
        OS              = "$($info.OperatingSystem)"
        OSType          = "$($info.OSType)"
        Architecture    = "$($info.Architecture)"
        KernelVersion   = "$($info.KernelVersion)"
        DockerVersion   = "$($info.ServerVersion)"
        StorageDriver   = "$($info.Driver)"
        LoggingDriver   = "$($info.LoggingDriver)"
        CgroupDriver    = "$($info.CgroupDriver)"
        Containers      = "$($info.Containers)"
        ContainersRunning = "$($info.ContainersRunning)"
        ContainersPaused = "$($info.ContainersPaused)"
        ContainersStopped = "$($info.ContainersStopped)"
        Images          = "$($info.Images)"
        CPUs            = "$($info.NCPU)"
        MemoryTotalGB   = "$([math]::Round($info.MemTotal / 1GB, 2))"
        DockerRootDir   = "$($info.DockerRootDir)"
        Swarm           = if ($info.Swarm.LocalNodeState -and $info.Swarm.LocalNodeState -ne 'inactive') { "$($info.Swarm.LocalNodeState)" } else { "inactive" }
    }
}

function Get-DockerContainers {
    <#
    .SYNOPSIS
        Returns all containers (running and stopped).
    .DESCRIPTION
        Calls GET /containers/json?all=true and returns container list.
    .PARAMETER Connection
        A connection object from Connect-DockerServer.
    .PARAMETER RunningOnly
        If set, returns only running containers.
    .EXAMPLE
        $containers = Get-DockerContainers -Connection $conn
    .EXAMPLE
        $running = Get-DockerContainers -Connection $conn -RunningOnly
    #>

    param(
        [Parameter(Mandatory)]$Connection,
        [switch]$RunningOnly
    )
    $all = if ($RunningOnly) { 'false' } else { 'true' }
    Invoke-DockerAPI -BaseUri $Connection.BaseUri -Endpoint "/containers/json?all=$all" -Certificate $Connection.Certificate
}

function Get-DockerContainerDetail {
    <#
    .SYNOPSIS
        Returns detailed inspection data for a container.
    .DESCRIPTION
        Calls GET /containers/{id}/json to return full container configuration,
        state, network settings, mount points, and resource limits.
    .PARAMETER Connection
        A connection object from Connect-DockerServer.
    .PARAMETER ContainerId
        The container ID or name.
    .EXAMPLE
        Get-DockerContainerDetail -Connection $conn -ContainerId "abc123"
    #>

    param(
        [Parameter(Mandatory)]$Connection,
        [Parameter(Mandatory)][string]$ContainerId
    )
    Invoke-DockerAPI -BaseUri $Connection.BaseUri -Endpoint "/containers/$ContainerId/json" -Certificate $Connection.Certificate
}

function Get-DockerContainerStats {
    <#
    .SYNOPSIS
        Returns real-time CPU, memory, network, and disk I/O stats for a container.
    .DESCRIPTION
        Calls GET /containers/{id}/stats?stream=false to get a single snapshot
        of container resource usage. Calculates CPU percentage, memory usage,
        and network I/O from the raw counters.
    .PARAMETER Connection
        A connection object from Connect-DockerServer.
    .PARAMETER ContainerId
        The container ID or name.
    .EXAMPLE
        $stats = Get-DockerContainerStats -Connection $conn -ContainerId "abc123"
    .EXAMPLE
        Get-DockerContainers -Connection $conn -RunningOnly | ForEach-Object {
            Get-DockerContainerStats -Connection $conn -ContainerId $_.Id
        }
    #>

    param(
        [Parameter(Mandatory)]$Connection,
        [Parameter(Mandatory)][string]$ContainerId
    )
    $raw = Invoke-DockerAPI -BaseUri $Connection.BaseUri -Endpoint "/containers/$ContainerId/stats?stream=false" -Certificate $Connection.Certificate

    # CPU % calculation: delta usage / delta system * num_cpus * 100
    $cpuDelta = $raw.cpu_stats.cpu_usage.total_usage - $raw.precpu_stats.cpu_usage.total_usage
    $sysDelta = $raw.cpu_stats.system_cpu_usage - $raw.precpu_stats.system_cpu_usage
    $numCPUs  = $raw.cpu_stats.online_cpus
    if (-not $numCPUs -or $numCPUs -eq 0) { $numCPUs = @($raw.cpu_stats.cpu_usage.percpu_usage).Count }
    $cpuPct = if ($sysDelta -gt 0 -and $numCPUs -gt 0) {
        [math]::Round(($cpuDelta / $sysDelta) * $numCPUs * 100, 2)
    } else { 0 }

    # Memory
    $memUsage = $raw.memory_stats.usage - ($raw.memory_stats.stats.cache -as [long])
    if ($memUsage -lt 0) { $memUsage = $raw.memory_stats.usage }
    $memLimit = $raw.memory_stats.limit
    $memPct = if ($memLimit -gt 0) { [math]::Round($memUsage / $memLimit * 100, 2) } else { 0 }

    # Network I/O (aggregate all interfaces)
    $netRx = 0; $netTx = 0
    if ($raw.networks) {
        foreach ($iface in $raw.networks.PSObject.Properties) {
            $netRx += $iface.Value.rx_bytes
            $netTx += $iface.Value.tx_bytes
        }
    }

    # Block I/O
    $blkRead = 0; $blkWrite = 0
    if ($raw.blkio_stats.io_service_bytes_recursive) {
        foreach ($entry in $raw.blkio_stats.io_service_bytes_recursive) {
            if ($entry.op -eq 'read' -or $entry.op -eq 'Read') { $blkRead += $entry.value }
            if ($entry.op -eq 'write' -or $entry.op -eq 'Write') { $blkWrite += $entry.value }
        }
    }

    [PSCustomObject]@{
        ContainerId  = $ContainerId
        CpuPercent   = $cpuPct
        MemoryUsageMB = [math]::Round($memUsage / 1MB, 2)
        MemoryLimitMB = [math]::Round($memLimit / 1MB, 2)
        MemoryPercent = $memPct
        NetRxMB       = [math]::Round($netRx / 1MB, 2)
        NetTxMB       = [math]::Round($netTx / 1MB, 2)
        BlockReadMB   = [math]::Round($blkRead / 1MB, 2)
        BlockWriteMB  = [math]::Round($blkWrite / 1MB, 2)
        PIDs          = $raw.pids_stats.current
    }
}

function Get-DockerNetworks {
    <#
    .SYNOPSIS
        Returns all Docker networks.
    .DESCRIPTION
        Calls GET /networks and returns network configuration details.
    .PARAMETER Connection
        A connection object from Connect-DockerServer.
    .EXAMPLE
        $networks = Get-DockerNetworks -Connection $conn
    #>

    param(
        [Parameter(Mandatory)]$Connection
    )
    $nets = Invoke-DockerAPI -BaseUri $Connection.BaseUri -Endpoint '/networks' -Certificate $Connection.Certificate
    foreach ($n in $nets) {
        [PSCustomObject]@{
            Name       = "$($n.Name)"
            Id         = "$($n.Id.Substring(0,12))"
            Driver     = "$($n.Driver)"
            Scope      = "$($n.Scope)"
            Internal   = "$($n.Internal)"
            IPv6       = "$($n.EnableIPv6)"
            Subnet     = "$(($n.IPAM.Config | ForEach-Object { $_.Subnet }) -join ', ')"
            Gateway    = "$(($n.IPAM.Config | ForEach-Object { $_.Gateway }) -join ', ')"
            Containers = "$(@($n.Containers.PSObject.Properties).Count)"
        }
    }
}

function Get-DockerVolumes {
    <#
    .SYNOPSIS
        Returns all Docker volumes.
    .DESCRIPTION
        Calls GET /volumes and returns volume details.
    .PARAMETER Connection
        A connection object from Connect-DockerServer.
    .EXAMPLE
        $volumes = Get-DockerVolumes -Connection $conn
    #>

    param(
        [Parameter(Mandatory)]$Connection
    )
    $resp = Invoke-DockerAPI -BaseUri $Connection.BaseUri -Endpoint '/volumes' -Certificate $Connection.Certificate
    foreach ($v in $resp.Volumes) {
        [PSCustomObject]@{
            Name       = "$($v.Name)"
            Driver     = "$($v.Driver)"
            Scope      = "$($v.Scope)"
            Mountpoint = "$($v.Mountpoint)"
            CreatedAt  = "$($v.CreatedAt)"
        }
    }
}

function Get-DockerImages {
    <#
    .SYNOPSIS
        Returns all Docker images.
    .DESCRIPTION
        Calls GET /images/json and returns image details including size and tags.
    .PARAMETER Connection
        A connection object from Connect-DockerServer.
    .EXAMPLE
        $images = Get-DockerImages -Connection $conn
    #>

    param(
        [Parameter(Mandatory)]$Connection
    )
    $imgs = Invoke-DockerAPI -BaseUri $Connection.BaseUri -Endpoint '/images/json' -Certificate $Connection.Certificate
    foreach ($img in $imgs) {
        [PSCustomObject]@{
            Id         = "$($img.Id.Split(':')[1].Substring(0,12))"
            Tags       = "$(($img.RepoTags | Where-Object { $_ -ne '<none>:<none>' }) -join ', ')"
            SizeMB     = "$([math]::Round($img.Size / 1MB, 2))"
            Created    = "$([DateTimeOffset]::FromUnixTimeSeconds($img.Created).LocalDateTime.ToString('yyyy-MM-dd HH:mm:ss'))"
            Containers = "$($img.Containers)"
        }
    }
}

function Get-DockerDashboard {
    <#
    .SYNOPSIS
        Builds a flat dashboard view of Docker host and container status.
    .DESCRIPTION
        Gathers system info, container list, and live stats from a Docker host,
        then returns a unified collection with Docker host as Type="Host" and
        each container as Type="Container". Follows the Proxmox/VMware dashboard
        pattern for WhatsUpGoldPS helper dashboards.
    .PARAMETER Connection
        A connection object from Connect-DockerServer.
    .EXAMPLE
        $conn = Connect-DockerServer -DockerHost "docker01"
        $data = Get-DockerDashboard -Connection $conn
    .EXAMPLE
        $data = Get-DockerDashboard -Connection $conn
        Export-DockerDashboardHtml -DashboardData $data -OutputPath "C:\Reports\docker.html"
    #>

    param(
        [Parameter(Mandatory)]$Connection
    )

    $results = @()
    $sysInfo = Get-DockerSystemInfo -Connection $Connection

    # -- Host row --
    $results += [PSCustomObject]@{
        Type           = "Host"
        Name           = $sysInfo.Hostname
        Status         = "running"
        IPAddress      = ($Connection.BaseUri -replace '^https?://' -replace ':\d+$')
        Image          = "N/A"
        CPU            = "$($sysInfo.CPUs) CPUs"
        Memory         = "$($sysInfo.MemoryTotalGB) GB Total"
        CpuPercent     = "N/A"
        MemPercent     = "N/A"
        NetRxMB        = "N/A"
        NetTxMB        = "N/A"
        BlockReadMB    = "N/A"
        BlockWriteMB   = "N/A"
        PIDs           = "N/A"
        OS             = $sysInfo.OS
        DockerVersion  = $sysInfo.DockerVersion
        StorageDriver  = $sysInfo.StorageDriver
        Containers     = "$($sysInfo.ContainersRunning) running / $($sysInfo.Containers) total"
        ImageCount     = $sysInfo.Images
        Ports          = "N/A"
        Uptime         = "N/A"
    }

    # -- Container rows --
    $containers = Get-DockerContainers -Connection $Connection
    foreach ($c in $containers) {
        $name = ($c.Names | Select-Object -First 1) -replace '^/'
        $state = "$($c.State)"
        $image = "$($c.Image)"

        # Parse ports
        $portList = @()
        if ($c.Ports) {
            foreach ($p in $c.Ports) {
                if ($p.PublicPort) {
                    $portList += "$($p.PublicPort)->$($p.PrivatePort)/$($p.Type)"
                } else {
                    $portList += "$($p.PrivatePort)/$($p.Type)"
                }
            }
        }
        $portsStr = if ($portList.Count -gt 0) { $portList -join ', ' } else { 'N/A' }

        # Uptime from Created timestamp
        $created = [DateTimeOffset]::FromUnixTimeSeconds($c.Created).LocalDateTime
        $uptime = if ($state -eq 'running') { '{0:d\.hh\:mm\:ss}' -f ((Get-Date) - $created) } else { 'N/A' }

        # Get live stats for running containers
        $cpuPct = 'N/A'; $memPct = 'N/A'; $memStr = 'N/A'; $cpuStr = 'N/A'
        $netRx = 'N/A'; $netTx = 'N/A'; $blkR = 'N/A'; $blkW = 'N/A'; $pids = 'N/A'
        if ($state -eq 'running') {
            try {
                $stats = Get-DockerContainerStats -Connection $Connection -ContainerId $c.Id
                $cpuPct = "$($stats.CpuPercent)"
                $memPct = "$($stats.MemoryPercent)"
                $cpuStr = "$($stats.CpuPercent)%"
                $memStr = "$($stats.MemoryPercent)% ($($stats.MemoryUsageMB) / $($stats.MemoryLimitMB) MB)"
                $netRx = "$($stats.NetRxMB)"
                $netTx = "$($stats.NetTxMB)"
                $blkR = "$($stats.BlockReadMB)"
                $blkW = "$($stats.BlockWriteMB)"
                $pids = "$($stats.PIDs)"
            } catch {
                Write-Verbose "Stats unavailable for ${name}: $($_.Exception.Message)"
            }
        }

        # Get IP from first network
        $ipAddr = 'N/A'
        if ($c.NetworkSettings -and $c.NetworkSettings.Networks) {
            $firstNet = $c.NetworkSettings.Networks.PSObject.Properties | Select-Object -First 1
            if ($firstNet -and $firstNet.Value.IPAddress) {
                $ipAddr = "$($firstNet.Value.IPAddress)"
            }
        }

        $results += [PSCustomObject]@{
            Type           = "Container"
            Name           = $name
            Status         = $state
            IPAddress      = $ipAddr
            Image          = $image
            CPU            = $cpuStr
            Memory         = $memStr
            CpuPercent     = $cpuPct
            MemPercent     = $memPct
            NetRxMB        = $netRx
            NetTxMB        = $netTx
            BlockReadMB    = $blkR
            BlockWriteMB   = $blkW
            PIDs           = $pids
            OS             = 'N/A'
            DockerVersion  = 'N/A'
            StorageDriver  = 'N/A'
            Containers     = 'N/A'
            ImageCount     = 'N/A'
            Ports          = $portsStr
            Uptime         = $uptime
        }
    }

    return $results
}

function Export-DockerDashboardHtml {
    <#
    .SYNOPSIS
        Renders Docker dashboard data into a self-contained HTML file.
    .DESCRIPTION
        Takes the output of Get-DockerDashboard and generates a Bootstrap-based
        HTML report with sortable, searchable, and exportable tables.
    .PARAMETER DashboardData
        Array of PSCustomObject from Get-DockerDashboard.
    .PARAMETER OutputPath
        File path for the output HTML file.
    .PARAMETER ReportTitle
        Title shown in the report header. Defaults to "Docker Dashboard".
    .PARAMETER TemplatePath
        Optional path to a custom HTML template. If omitted, uses the
        Docker-Dashboard-Template.html in the same directory as this script.
    .EXAMPLE
        $data = Get-DockerDashboard -Connection $conn
        Export-DockerDashboardHtml -DashboardData $data -OutputPath "C:\Reports\docker.html"
    .EXAMPLE
        Export-DockerDashboardHtml -DashboardData $data -OutputPath "$env:TEMP\docker.html" -ReportTitle "Production Docker"
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]$DashboardData,
        [Parameter(Mandatory)][string]$OutputPath,
        [string]$ReportTitle = "Docker Dashboard",
        [string]$TemplatePath
    )

    if (-not $TemplatePath) {
        $TemplatePath = Join-Path $PSScriptRoot "Docker-Dashboard-Template.html"
    }

    if (-not (Test-Path $TemplatePath)) {
        throw "HTML template not found at $TemplatePath"
    }

    $titleMap = @{
        'Type'          = 'Type'
        'Name'          = 'Name'
        'Status'        = 'Status'
        'IPAddress'     = 'IP Address'
        'Image'         = 'Image'
        'CPU'           = 'CPU'
        'Memory'        = 'Memory'
        'CpuPercent'    = 'CPU %'
        'MemPercent'    = 'Mem %'
        'NetRxMB'       = 'Net Rx MB'
        'NetTxMB'       = 'Net Tx MB'
        'BlockReadMB'   = 'Blk Read MB'
        'BlockWriteMB'  = 'Blk Write MB'
        'PIDs'          = 'PIDs'
        'OS'            = 'OS'
        'DockerVersion' = 'Docker Version'
        'StorageDriver' = 'Storage Driver'
        'Containers'    = 'Containers'
        'ImageCount'    = 'Images'
        'Ports'         = 'Ports'
        'Uptime'        = 'Uptime'
    }

    $firstObj = $DashboardData | Select-Object -First 1
    $columns = @()
    foreach ($prop in $firstObj.PSObject.Properties) {
        $title = if ($titleMap.ContainsKey($prop.Name)) { $titleMap[$prop.Name] } else { ($prop.Name -creplace '([A-Z])', ' $1').Trim() }
        $col = @{
            field      = $prop.Name
            title      = $title
            sortable   = $true
            searchable = $true
        }
        if ($prop.Name -eq 'Status') { $col.formatter = 'formatStatus' }
        if ($prop.Name -eq 'Type')   { $col.formatter = 'formatType' }
        $columns += $col
    }

    $columnsJson = $columns | ConvertTo-Json -Depth 5 -Compress
    $dataJson    = ConvertTo-Json -InputObject @($DashboardData) -Depth 5 -Compress

    $tableConfig = @"
        columns: $columnsJson,
        data: $dataJson
"@


    $html = Get-Content -Path $TemplatePath -Raw
    $html = $html -replace 'replaceThisHere', $tableConfig
    $html = $html -replace 'ReplaceYourReportNameHere', $ReportTitle
    $html = $html -replace 'ReplaceUpdateTimeHere', (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")

    Set-Content -Path $OutputPath -Value $html -Encoding UTF8
    Write-Verbose "Docker Dashboard HTML written to $OutputPath"
}

# SIG # Begin signature block
# MIIVlwYJKoZIhvcNAQcCoIIViDCCFYQCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCBW8LhqCROCOLmG
# vSmTmwrSHTB04ZouHa39ZYo/1zF+o6CCEdMwggVvMIIEV6ADAgECAhBI/JO0YFWU
# jTanyYqJ1pQWMA0GCSqGSIb3DQEBDAUAMHsxCzAJBgNVBAYTAkdCMRswGQYDVQQI
# DBJHcmVhdGVyIE1hbmNoZXN0ZXIxEDAOBgNVBAcMB1NhbGZvcmQxGjAYBgNVBAoM
# EUNvbW9kbyBDQSBMaW1pdGVkMSEwHwYDVQQDDBhBQUEgQ2VydGlmaWNhdGUgU2Vy
# dmljZXMwHhcNMjEwNTI1MDAwMDAwWhcNMjgxMjMxMjM1OTU5WjBWMQswCQYDVQQG
# EwJHQjEYMBYGA1UEChMPU2VjdGlnbyBMaW1pdGVkMS0wKwYDVQQDEyRTZWN0aWdv
# IFB1YmxpYyBDb2RlIFNpZ25pbmcgUm9vdCBSNDYwggIiMA0GCSqGSIb3DQEBAQUA
# A4ICDwAwggIKAoICAQCN55QSIgQkdC7/FiMCkoq2rjaFrEfUI5ErPtx94jGgUW+s
# hJHjUoq14pbe0IdjJImK/+8Skzt9u7aKvb0Ffyeba2XTpQxpsbxJOZrxbW6q5KCD
# J9qaDStQ6Utbs7hkNqR+Sj2pcaths3OzPAsM79szV+W+NDfjlxtd/R8SPYIDdub7
# P2bSlDFp+m2zNKzBenjcklDyZMeqLQSrw2rq4C+np9xu1+j/2iGrQL+57g2extme
# me/G3h+pDHazJyCh1rr9gOcB0u/rgimVcI3/uxXP/tEPNqIuTzKQdEZrRzUTdwUz
# T2MuuC3hv2WnBGsY2HH6zAjybYmZELGt2z4s5KoYsMYHAXVn3m3pY2MeNn9pib6q
# RT5uWl+PoVvLnTCGMOgDs0DGDQ84zWeoU4j6uDBl+m/H5x2xg3RpPqzEaDux5mcz
# mrYI4IAFSEDu9oJkRqj1c7AGlfJsZZ+/VVscnFcax3hGfHCqlBuCF6yH6bbJDoEc
# QNYWFyn8XJwYK+pF9e+91WdPKF4F7pBMeufG9ND8+s0+MkYTIDaKBOq3qgdGnA2T
# OglmmVhcKaO5DKYwODzQRjY1fJy67sPV+Qp2+n4FG0DKkjXp1XrRtX8ArqmQqsV/
# AZwQsRb8zG4Y3G9i/qZQp7h7uJ0VP/4gDHXIIloTlRmQAOka1cKG8eOO7F/05QID
# AQABo4IBEjCCAQ4wHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQwHQYD
# VR0OBBYEFDLrkpr/NZZILyhAQnAgNpFcF4XmMA4GA1UdDwEB/wQEAwIBhjAPBgNV
# HRMBAf8EBTADAQH/MBMGA1UdJQQMMAoGCCsGAQUFBwMDMBsGA1UdIAQUMBIwBgYE
# VR0gADAIBgZngQwBBAEwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21v
# ZG9jYS5jb20vQUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEE
# KDAmMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZI
# hvcNAQEMBQADggEBABK/oe+LdJqYRLhpRrWrJAoMpIpnuDqBv0WKfVIHqI0fTiGF
# OaNrXi0ghr8QuK55O1PNtPvYRL4G2VxjZ9RAFodEhnIq1jIV9RKDwvnhXRFAZ/ZC
# J3LFI+ICOBpMIOLbAffNRk8monxmwFE2tokCVMf8WPtsAO7+mKYulaEMUykfb9gZ
# pk+e96wJ6l2CxouvgKe9gUhShDHaMuwV5KZMPWw5c9QLhTkg4IUaaOGnSDip0TYl
# d8GNGRbFiExmfS9jzpjoad+sPKhdnckcW67Y8y90z7h+9teDnRGWYpquRRPaf9xH
# +9/DUp/mBlXpnYzyOmJRvOwkDynUWICE5EV7WtgwggYaMIIEAqADAgECAhBiHW0M
# UgGeO5B5FSCJIRwKMA0GCSqGSIb3DQEBDAUAMFYxCzAJBgNVBAYTAkdCMRgwFgYD
# VQQKEw9TZWN0aWdvIExpbWl0ZWQxLTArBgNVBAMTJFNlY3RpZ28gUHVibGljIENv
# ZGUgU2lnbmluZyBSb290IFI0NjAeFw0yMTAzMjIwMDAwMDBaFw0zNjAzMjEyMzU5
# NTlaMFQxCzAJBgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzAp
# BgNVBAMTIlNlY3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYwggGiMA0G
# CSqGSIb3DQEBAQUAA4IBjwAwggGKAoIBgQCbK51T+jU/jmAGQ2rAz/V/9shTUxjI
# ztNsfvxYB5UXeWUzCxEeAEZGbEN4QMgCsJLZUKhWThj/yPqy0iSZhXkZ6Pg2A2NV
# DgFigOMYzB2OKhdqfWGVoYW3haT29PSTahYkwmMv0b/83nbeECbiMXhSOtbam+/3
# 6F09fy1tsB8je/RV0mIk8XL/tfCK6cPuYHE215wzrK0h1SWHTxPbPuYkRdkP05Zw
# mRmTnAO5/arnY83jeNzhP06ShdnRqtZlV59+8yv+KIhE5ILMqgOZYAENHNX9SJDm
# +qxp4VqpB3MV/h53yl41aHU5pledi9lCBbH9JeIkNFICiVHNkRmq4TpxtwfvjsUe
# dyz8rNyfQJy/aOs5b4s+ac7IH60B+Ja7TVM+EKv1WuTGwcLmoU3FpOFMbmPj8pz4
# 4MPZ1f9+YEQIQty/NQd/2yGgW+ufflcZ/ZE9o1M7a5Jnqf2i2/uMSWymR8r2oQBM
# dlyh2n5HirY4jKnFH/9gRvd+QOfdRrJZb1sCAwEAAaOCAWQwggFgMB8GA1UdIwQY
# MBaAFDLrkpr/NZZILyhAQnAgNpFcF4XmMB0GA1UdDgQWBBQPKssghyi47G9IritU
# pimqF6TNDDAOBgNVHQ8BAf8EBAMCAYYwEgYDVR0TAQH/BAgwBgEB/wIBADATBgNV
# HSUEDDAKBggrBgEFBQcDAzAbBgNVHSAEFDASMAYGBFUdIAAwCAYGZ4EMAQQBMEsG
# A1UdHwREMEIwQKA+oDyGOmh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2VjdGlnb1B1
# YmxpY0NvZGVTaWduaW5nUm9vdFI0Ni5jcmwwewYIKwYBBQUHAQEEbzBtMEYGCCsG
# AQUFBzAChjpodHRwOi8vY3J0LnNlY3RpZ28uY29tL1NlY3RpZ29QdWJsaWNDb2Rl
# U2lnbmluZ1Jvb3RSNDYucDdjMCMGCCsGAQUFBzABhhdodHRwOi8vb2NzcC5zZWN0
# aWdvLmNvbTANBgkqhkiG9w0BAQwFAAOCAgEABv+C4XdjNm57oRUgmxP/BP6YdURh
# w1aVcdGRP4Wh60BAscjW4HL9hcpkOTz5jUug2oeunbYAowbFC2AKK+cMcXIBD0Zd
# OaWTsyNyBBsMLHqafvIhrCymlaS98+QpoBCyKppP0OcxYEdU0hpsaqBBIZOtBajj
# cw5+w/KeFvPYfLF/ldYpmlG+vd0xqlqd099iChnyIMvY5HexjO2AmtsbpVn0OhNc
# WbWDRF/3sBp6fWXhz7DcML4iTAWS+MVXeNLj1lJziVKEoroGs9Mlizg0bUMbOalO
# hOfCipnx8CaLZeVme5yELg09Jlo8BMe80jO37PU8ejfkP9/uPak7VLwELKxAMcJs
# zkyeiaerlphwoKx1uHRzNyE6bxuSKcutisqmKL5OTunAvtONEoteSiabkPVSZ2z7
# 6mKnzAfZxCl/3dq3dUNw4rg3sTCggkHSRqTqlLMS7gjrhTqBmzu1L90Y1KWN/Y5J
# KdGvspbOrTfOXyXvmPL6E52z1NZJ6ctuMFBQZH3pwWvqURR8AgQdULUvrxjUYbHH
# j95Ejza63zdrEcxWLDX6xWls/GDnVNueKjWUH3fTv1Y8Wdho698YADR7TNx8X8z2
# Bev6SivBBOHY+uqiirZtg0y9ShQoPzmCcn63Syatatvx157YK9hlcPmVoa1oDE5/
# L9Uo2bC5a4CH2RwwggY+MIIEpqADAgECAhAHnODk0RR/hc05c892LTfrMA0GCSqG
# SIb3DQEBDAUAMFQxCzAJBgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0
# ZWQxKzApBgNVBAMTIlNlY3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYw
# HhcNMjYwMjA5MDAwMDAwWhcNMjkwNDIxMjM1OTU5WjBVMQswCQYDVQQGEwJVUzEU
# MBIGA1UECAwLQ29ubmVjdGljdXQxFzAVBgNVBAoMDkphc29uIEFsYmVyaW5vMRcw
# FQYDVQQDDA5KYXNvbiBBbGJlcmlubzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCC
# AgoCggIBAPN6aN4B1yYWkI5b5TBj3I0VV/peETrHb6EY4BHGxt8Ap+eT+WpEpJyE
# tRYPxEmNJL3A38Bkg7mwzPE3/1NK570ZBCuBjSAn4mSDIgIuXZnvyBO9W1OQs5d6
# 7MlJLUAEufl18tOr3ST1DeO9gSjQSAE5Nql0QDxPnm93OZBon+Fz3CmE+z3MwAe2
# h4KdtRAnCqwM+/V7iBdbw+JOxolpx+7RVjGyProTENIG3pe/hKvPb501lf8uBAAD
# LdjZr5ip8vIWbf857Yw1Bu10nVI7HW3eE8Cl5//d1ribHlzTzQLfttW+k+DaFsKZ
# BBL56l4YAlIVRsrOiE1kdHYYx6IGrEA809R7+TZA9DzGqyFiv9qmJAbL4fDwetDe
# yIq+Oztz1LvEdy8Rcd0JBY+J4S0eDEFIA3X0N8VcLeAwabKb9AjulKXwUeqCJLvN
# 79CJ90UTZb2+I+tamj0dn+IKMEsJ4v4Ggx72sxFr9+6XziodtTg5Luf2xd6+Phha
# mOxF2px9LObhBLLEMyRsCHZIzVZOFKu9BpHQH7ufGB+Sa80Tli0/6LEyn9+bMYWi
# 2ttn6lLOPThXMiQaooRUq6q2u3+F4SaPlxVFLI7OJVMhar6nW6joBvELTJPmANSM
# jDSRFDfHRCdGbZsL/keELJNy+jZctF6VvxQEjFM8/bazu6qYhrA7AgMBAAGjggGJ
# MIIBhTAfBgNVHSMEGDAWgBQPKssghyi47G9IritUpimqF6TNDDAdBgNVHQ4EFgQU
# 6YF0o0D5AVhKHbVocr8GaSIBibAwDgYDVR0PAQH/BAQDAgeAMAwGA1UdEwEB/wQC
# MAAwEwYDVR0lBAwwCgYIKwYBBQUHAwMwSgYDVR0gBEMwQTA1BgwrBgEEAbIxAQIB
# AwIwJTAjBggrBgEFBQcCARYXaHR0cHM6Ly9zZWN0aWdvLmNvbS9DUFMwCAYGZ4EM
# AQQBMEkGA1UdHwRCMEAwPqA8oDqGOGh0dHA6Ly9jcmwuc2VjdGlnby5jb20vU2Vj
# dGlnb1B1YmxpY0NvZGVTaWduaW5nQ0FSMzYuY3JsMHkGCCsGAQUFBwEBBG0wazBE
# BggrBgEFBQcwAoY4aHR0cDovL2NydC5zZWN0aWdvLmNvbS9TZWN0aWdvUHVibGlj
# Q29kZVNpZ25pbmdDQVIzNi5jcnQwIwYIKwYBBQUHMAGGF2h0dHA6Ly9vY3NwLnNl
# Y3RpZ28uY29tMA0GCSqGSIb3DQEBDAUAA4IBgQAEIsm4xnOd/tZMVrKwi3doAXvC
# wOA/RYQnFJD7R/bSQRu3wXEK4o9SIefye18B/q4fhBkhNAJuEvTQAGfqbbpxow03
# J5PrDTp1WPCWbXKX8Oz9vGWJFyJxRGftkdzZ57JE00synEMS8XCwLO9P32MyR9Z9
# URrpiLPJ9rQjfHMb1BUdvaNayomm7aWLAnD+X7jm6o8sNT5An1cwEAob7obWDM6s
# X93wphwJNBJAstH9Ozs6LwISOX6sKS7CKm9N3Kp8hOUue0ZHAtZdFl6o5u12wy+z
# zieGEI50fKnN77FfNKFOWKlS6OJwlArcbFegB5K89LcE5iNSmaM3VMB2ADV1FEcj
# GSHw4lTg1Wx+WMAMdl/7nbvfFxJ9uu5tNiT54B0s+lZO/HztwXYQUczdsFon3pjs
# Nrsk9ZlalBi5SHkIu+F6g7tWiEv3rtVApmJRnLkUr2Xq2a4nbslUCt4jKs5UX4V1
# nSX8OM++AXoyVGO+iTj7z+pl6XE9Gw/Td6WKKKsxggMaMIIDFgIBATBoMFQxCzAJ
# BgNVBAYTAkdCMRgwFgYDVQQKEw9TZWN0aWdvIExpbWl0ZWQxKzApBgNVBAMTIlNl
# Y3RpZ28gUHVibGljIENvZGUgU2lnbmluZyBDQSBSMzYCEAec4OTRFH+FzTlzz3Yt
# N+swDQYJYIZIAWUDBAIBBQCggYQwGAYKKwYBBAGCNwIBDDEKMAigAoAAoQKAADAZ
# BgkqhkiG9w0BCQMxDAYKKwYBBAGCNwIBBDAcBgorBgEEAYI3AgELMQ4wDAYKKwYB
# BAGCNwIBFTAvBgkqhkiG9w0BCQQxIgQgF0rjX+nMGZPcUNPxDlA2COSZYW5VstuL
# Pb6o/HTZ9nYwDQYJKoZIhvcNAQEBBQAEggIAeZx2Yat3Z3PsBh4g8W2CS0AqH4sb
# BFGgYvLLvCJNL9nK8Kmv5gtal8QMlqvDpFMn9GZaXPZzx5srRid0nzMHJYcuXF9K
# OMHmLHokDUa+BQtnlBTVMckisQzEuDCrKKFRIMFO3kVfiGF5lvSDMP1mjYZ6pD/h
# 3T06M3j6utwjjRm7RwSFgS1XHNBP5FgWQMWD84zwW/WpRhJLa6TNVafNUXafGxWa
# cqnktQmiCZG9nwnadh3sX3NbSSZMl8jYCNQhV6c0SmaLB0evcAWc8vkK0ba3BqVG
# A5JKiiO2CJR8FV9garBx/w91KkEDsUmdqZgWZnfCKS+aCy5gTL+2XjPPiZoElJKE
# IBVFq3KSVp1872uuj/H/LyWQGFXvwwHbeZYAmWI+uHy2TCekeZ17uvPLVfjiuLK1
# /XcJrcJgi92lykpTKEcHif39Ohpa02u8H+R+hBUMKHKEKDBi2payBrtouDm8bLe0
# whxf5+eU3eQ2OJ4dfJWaJDWydmxl8rVuWOhmSWMAWEMfwzNyDUuPgR2oIMR2CLwB
# eIFFTZmse6+Af0KLmq3lcWuq+QOJRj80lKwQ7cjSEQ1XJJIA3/rcD7Sdg24v8D+F
# Tss6gQOGlxyC6MGrMvm94r3iIO5EP7NzjNs8NOmINRDz7ROzy9mAV0LkfXyhQ3dq
# GQOI6QdsCMlldoQ=
# SIG # End signature block