Public/Get-RegistrationHeartbeat.ps1

function Get-RegistrationHeartbeat {
    <#
    .SYNOPSIS
        Shows heartbeat status for all registered non-Windows systems.
 
    .DESCRIPTION
        Queries the target OU for all computer objects and checks the registration
        folder for each system's JSON file timestamp. Classifies each system as
        Active, Stale, Offline, or Never based on how recently the agent checked in.
 
        Active: Heartbeat within StaleHours (default: 2 hours)
        Stale: Between StaleHours and OfflineHours
        Offline: Beyond OfflineHours (default: 24 hours)
        Never: No JSON file found (manually registered system)
 
    .PARAMETER RegistrationPath
        Path to the shared folder where agents drop their JSON heartbeat files.
 
    .PARAMETER OrganizationalUnit
        OU to query for registered systems. Defaults to "OU=Non-Windows Servers"
        under the domain root.
 
    .PARAMETER StaleHours
        Number of hours without a heartbeat before a system is flagged as Stale.
        Default: 2 hours.
 
    .PARAMETER OfflineHours
        Number of hours without a heartbeat before a system is flagged as Offline.
        Default: 24 hours.
 
    .PARAMETER OutputPath
        If specified, generates an HTML dashboard report at this file path.
 
    .EXAMPLE
        Get-RegistrationHeartbeat -RegistrationPath '\\fileserver\registrations$'
 
        Shows heartbeat status for all registered systems.
 
    .EXAMPLE
        Get-RegistrationHeartbeat -RegistrationPath '\\fs01\reg$' -StaleHours 1 -OfflineHours 12
 
        Uses tighter thresholds for stale/offline classification.
 
    .EXAMPLE
        Get-RegistrationHeartbeat -RegistrationPath '\\fs01\reg$' | Where-Object Status -eq 'Offline'
 
        Shows only systems that have not checked in for 24+ hours.
 
    .EXAMPLE
        Get-RegistrationHeartbeat -RegistrationPath '\\fs01\reg$' -OutputPath '.\heartbeat-report.html'
 
        Generates an HTML dashboard of heartbeat status.
 
    .NOTES
        Requires: ActiveDirectory module (RSAT).
        The heartbeat status is based on the file modification timestamp of each
        system's JSON file in the registration folder.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Position = 0)]
        [string]$RegistrationPath,

        [string]$OrganizationalUnit,

        [ValidateRange(1, 8760)]
        [int]$StaleHours = 2,

        [ValidateRange(1, 8760)]
        [int]$OfflineHours = 24,

        [string]$OutputPath
    )

    begin {
        Import-Module ActiveDirectory -ErrorAction Stop

        if (-not $OrganizationalUnit) {
            $domainDN = (Get-ADDomain).DistinguishedName
            $OrganizationalUnit = "OU=Non-Windows Servers,$domainDN"
        }
    }

    process {
        # Verify the OU exists
        try {
            $null = Get-ADOrganizationalUnit -Identity $OrganizationalUnit -ErrorAction Stop
        }
        catch {
            Write-Warning "OU not found: $OrganizationalUnit. No systems registered yet."
            return
        }

        # Get all computer objects from the OU
        $properties = @(
            'Name', 'DNSHostName', 'IPv4Address', 'OperatingSystem',
            'OperatingSystemVersion', 'Description', 'Created', 'Modified'
        )

        $computers = Get-ADComputer -SearchBase $OrganizationalUnit -Filter * `
            -Properties $properties -ErrorAction Stop

        if (-not $computers) {
            Write-Verbose "No computer objects found in $OrganizationalUnit"
            return
        }

        $now = Get-Date
        $results = [System.Collections.Generic.List[PSCustomObject]]::new()

        foreach ($comp in $computers) {
            $lastHeartbeat = $null
            $hoursSince    = $null
            $status        = 'Never'

            # Check for JSON file in the registration folder
            if ($RegistrationPath -and (Test-Path $RegistrationPath)) {
                $jsonFile = Join-Path -Path $RegistrationPath -ChildPath "$($comp.Name).json"
                if (Test-Path $jsonFile) {
                    $fileInfo      = Get-Item -Path $jsonFile
                    $lastHeartbeat = $fileInfo.LastWriteTime
                    $hoursSince    = [math]::Round(($now - $lastHeartbeat).TotalHours, 1)

                    if ($hoursSince -le $StaleHours) {
                        $status = 'Active'
                    }
                    elseif ($hoursSince -le $OfflineHours) {
                        $status = 'Stale'
                    }
                    else {
                        $status = 'Offline'
                    }
                }
            }

            $results.Add([PSCustomObject]@{
                ComputerName           = $comp.Name
                OperatingSystem        = $comp.OperatingSystem
                OperatingSystemVersion = $comp.OperatingSystemVersion
                IPAddress              = $comp.IPv4Address
                LastHeartbeat          = $lastHeartbeat
                Status                 = $status
                HoursSinceHeartbeat    = $hoursSince
                Description            = $comp.Description
            })
        }

        Write-Verbose "Heartbeat check complete: $($results.Count) systems evaluated"

        # Generate HTML report if requested
        if ($OutputPath) {
            $reportDir = Split-Path -Path $OutputPath -Parent
            if ($reportDir -and -not (Test-Path $reportDir)) {
                New-Item -Path $reportDir -ItemType Directory -Force | Out-Null
            }

            $timestamp    = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
            $totalCount   = @($results).Count
            $activeCount  = @($results | Where-Object { $_.Status -eq 'Active' }).Count
            $staleCount   = @($results | Where-Object { $_.Status -eq 'Stale' }).Count
            $offlineCount = @($results | Where-Object { $_.Status -eq 'Offline' }).Count
            $neverCount   = @($results | Where-Object { $_.Status -eq 'Never' }).Count

            $tableRows = ($results | ForEach-Object {
                $statusClass = switch ($_.Status) {
                    'Active'  { 'finding-ok' }
                    'Stale'   { 'finding-warn' }
                    'Offline' { 'finding-bad' }
                    'Never'   { '' }
                    default   { '' }
                }
                $lastHb = if ($_.LastHeartbeat) { $_.LastHeartbeat.ToString('yyyy-MM-dd HH:mm') } else { '&mdash;' }
                $hoursSince = if ($null -ne $_.HoursSinceHeartbeat) { "$($_.HoursSinceHeartbeat)h" } else { '&mdash;' }
                $os = if ($_.OperatingSystem) { $_.OperatingSystem } else { '&mdash;' }
                $osVer = if ($_.OperatingSystemVersion) { $_.OperatingSystemVersion } else { '&mdash;' }
                $ip = if ($_.IPAddress) { $_.IPAddress } else { '&mdash;' }

                "<tr><td>$($_.ComputerName)</td><td>$os</td><td>$osVer</td><td>$ip</td><td>$lastHb</td><td class=`"$statusClass`">$($_.Status)</td><td>$hoursSince</td></tr>"
            }) -join "`n "

            $html = @"
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Registration Heartbeat Status</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body { font-family: 'Segoe UI', Tahoma, sans-serif; background: #0d1117; color: #c9d1d9; padding: 2rem; }
        .header { background: linear-gradient(135deg, #1a1f2e 0%, #2a1a0a 100%); padding: 2rem; border-radius: 8px; margin-bottom: 2rem; }
        .header h1 { color: #d29922; font-size: 1.8rem; margin-bottom: 0.5rem; }
        .header .meta { color: #8b949e; font-size: 0.9rem; }
        .summary-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 1rem; margin-bottom: 2rem; }
        .card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1.5rem; text-align: center; }
        .card .number { font-size: 2.5rem; font-weight: 700; }
        .card .label { color: #8b949e; font-size: 0.85rem; margin-top: 0.5rem; }
        .card.active .number { color: #3fb950; }
        .card.stale .number { color: #d29922; }
        .card.offline .number { color: #f85149; }
        .card.never .number { color: #8b949e; }
        .card.total .number { color: #d29922; }
        .section { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 1.5rem; }
        .section h2 { color: #d29922; font-size: 1.3rem; margin-bottom: 1rem; }
        .table-wrapper { overflow-x: auto; }
        table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
        th { background: #21262d; color: #d29922; padding: 0.75rem; text-align: left; border-bottom: 2px solid #30363d; white-space: nowrap; }
        td { padding: 0.6rem 0.75rem; border-bottom: 1px solid #21262d; }
        tr:hover { background: #1c2128; }
        .finding-bad { color: #f85149; font-weight: 600; }
        .finding-warn { color: #d29922; font-weight: 600; }
        .finding-ok { color: #3fb950; }
        .footer { text-align: center; color: #484f58; margin-top: 2rem; font-size: 0.8rem; }
    </style>
</head>
<body>
    <div class="header">
        <h1>Registration Heartbeat Status</h1>
        <div class="meta">Generated $timestamp | OU: $OrganizationalUnit | Stale: >${StaleHours}h | Offline: >${OfflineHours}h</div>
    </div>
    <div class="summary-cards">
        <div class="card total"><div class="number">$totalCount</div><div class="label">Total</div></div>
        <div class="card active"><div class="number">$activeCount</div><div class="label">Active</div></div>
        <div class="card stale"><div class="number">$staleCount</div><div class="label">Stale</div></div>
        <div class="card offline"><div class="number">$offlineCount</div><div class="label">Offline</div></div>
        <div class="card never"><div class="number">$neverCount</div><div class="label">Never</div></div>
    </div>
    <div class="section">
        <h2>System Heartbeats</h2>
        <div class="table-wrapper">
            <table>
                <thead><tr><th>Computer</th><th>Operating System</th><th>OS Version</th><th>IP Address</th><th>Last Heartbeat</th><th>Status</th><th>Since</th></tr></thead>
                <tbody>
                    $tableRows
                </tbody>
            </table>
        </div>
    </div>
    <div class="footer">Generated by AD-LinuxInventory | github.com/larro1991/AD-LinuxInventory</div>
</body>
</html>
"@


            $html | Out-File -FilePath $OutputPath -Encoding UTF8 -Force
            Write-Verbose "HTML report saved: $OutputPath"
        }

        $results
    }
}