Private/Notify.ps1

#Requires -Version 5.1

function Get-SMBeatNotifyStatePath {
    param(
        [Parameter(Mandatory = $true)]
        [string]$DataRoot
    )

    Join-Path $DataRoot 'notify.state.json'
}

function Get-SMBeatLastBootUtc {
    try {
        $os = Get-CimInstance -ClassName Win32_OperatingSystem -Property LastBootUpTime -ErrorAction Stop
        if ($null -eq $os -or $null -eq $os.LastBootUpTime) {
            return [datetime]::MinValue
        }
        $boot = [datetime]$os.LastBootUpTime
        if ($boot.Kind -eq [DateTimeKind]::Utc) {
            return $boot
        }
        if ($boot.Kind -eq [DateTimeKind]::Local) {
            return $boot.ToUniversalTime()
        }
        return [datetime]::SpecifyKind($boot, [DateTimeKind]::Local).ToUniversalTime()
    }
    catch {
        return [datetime]::MinValue
    }
}

function Read-SMBeatPersistedBootUtc {
    param(
        [Parameter(Mandatory = $true)]
        [string]$DataRoot
    )

    $path = Get-SMBeatBootStatePath -DataRoot $DataRoot
    $json = Read-SMBeatTextFile -Path $path
    if ($null -eq $json) {
        return [datetime]::MinValue
    }

    try {
        $obj = ConvertFrom-SMBeatJson -Json $json
    }
    catch {
        return [datetime]::MinValue
    }

    if (-not $obj -or -not $obj.PSObject.Properties['LastBootUtc'] -or -not $obj.LastBootUtc) {
        return [datetime]::MinValue
    }

    ConvertFrom-SMBeatUtcString -Value ([string]$obj.LastBootUtc)
}

function Save-SMBeatPersistedBootUtc {
    param(
        [Parameter(Mandatory = $true)]
        [string]$DataRoot,
        [Parameter(Mandatory = $true)]
        [datetime]$BootUtc
    )

    $obj = [PSCustomObject]@{
        LastBootUtc = (ConvertTo-SMBeatUtcString -DateTime $BootUtc)
    }
    Write-SMBeatTextFile -Path (Get-SMBeatBootStatePath -DataRoot $DataRoot) -Text (ConvertTo-SMBeatJson -InputObject $obj)
}

function Sync-SMBeatBootEvent {
    param(
        [Parameter(Mandatory = $true)]
        [string]$DataRoot,
        [datetime]$BootUtc
    )

    if (-not $PSBoundParameters.ContainsKey('BootUtc') -or $BootUtc -eq [datetime]::MinValue) {
        $BootUtc = Get-SMBeatLastBootUtc
    }
    if ($BootUtc -eq [datetime]::MinValue) {
        return $false
    }

    $previous = Read-SMBeatPersistedBootUtc -DataRoot $DataRoot
    Save-SMBeatPersistedBootUtc -DataRoot $DataRoot -BootUtc $BootUtc
    if ($previous -eq [datetime]::MinValue) {
        return $false
    }

    $deltaSec = [math]::Abs(($BootUtc - $previous).TotalSeconds)
    if ($deltaSec -lt 2) {
        return $false
    }

    $stamp = ConvertTo-SMBeatUtcString -DateTime $BootUtc
    Write-SMBeatLog -DataRoot $DataRoot -Message ('Server reboot LastBootUpTime={0}' -f $stamp) -Level 'WARN'
    Add-SMBeatCollectorEvent -DataRoot $DataRoot -Kind reboot -Message ('LastBootUpTime {0}' -f $stamp) -Utc $BootUtc
    $true
}

function Add-SMBeatCollectorEvent {
    param(
        [Parameter(Mandatory = $true)]
        [string]$DataRoot,
        [Parameter(Mandatory = $true)]
        [string]$Kind,
        [Parameter(Mandatory = $true)]
        [string]$Message,
        [datetime]$Utc
    )

    try {
        $when = Get-SMBeatUtcNow
        if ($PSBoundParameters.ContainsKey('Utc') -and $Utc -ne [datetime]::MinValue) {
            $when = $Utc
        }
        $obj = [PSCustomObject]@{
            ts      = (ConvertTo-SMBeatUtcString -DateTime $when)
            kind    = $Kind
            message = $Message
            host    = $env:COMPUTERNAME
        }
        $line = '{0}{1}' -f (ConvertTo-Json -InputObject $obj -Depth 4 -Compress), [Environment]::NewLine
        $path = Get-SMBeatEventsPath -DataRoot $DataRoot
        Add-SMBeatTextFile -Path $path -Text $line
        if ((Test-Path -LiteralPath $path) -and ((Get-Item -LiteralPath $path).Length -gt 1048576)) {
            $kept = New-Object System.Collections.Generic.List[string]
            $reader = New-SMBeatSharedStreamReader -Path $path
            try {
                while ($null -ne ($old = $reader.ReadLine())) {
                    if (-not [string]::IsNullOrWhiteSpace($old)) {
                        [void]$kept.Add($old)
                    }
                }
            }
            finally {
                $reader.Dispose()
            }
            if ($kept.Count -gt 1500) {
                $start = $kept.Count - 1500
                $text = ($kept.GetRange($start, 1500) -join [Environment]::NewLine) + [Environment]::NewLine
                Write-SMBeatTextFile -Path $path -Text $text
            }
        }
    }
    catch {
    }
}

function Read-SMBeatCollectorEvents {
    param(
        [Parameter(Mandatory = $true)]
        [string]$DataRoot,
        [datetime]$StartUtc,
        [datetime]$EndUtc
    )

    $path = Get-SMBeatEventsPath -DataRoot $DataRoot
    if (-not (Test-Path -LiteralPath $path)) {
        return @()
    }

    $list = New-Object System.Collections.Generic.List[object]
    $reader = New-SMBeatSharedStreamReader -Path $path
    try {
        while ($null -ne ($line = $reader.ReadLine())) {
            if ([string]::IsNullOrWhiteSpace($line)) {
                continue
            }
            try {
                $obj = ConvertFrom-SMBeatJson -Json $line
            }
            catch {
                continue
            }
            $ts = [datetime]::MinValue
            if ($obj.PSObject.Properties['ts'] -and $obj.ts) {
                $ts = ConvertFrom-SMBeatUtcString -Value ([string]$obj.ts)
            }
            if ($ts -eq [datetime]::MinValue) {
                continue
            }
            if ($ts -lt $StartUtc -or $ts -ge $EndUtc) {
                continue
            }
            $kind = ''
            $message = ''
            $hostName = ''
            if ($obj.PSObject.Properties['kind'] -and $obj.kind) {
                $kind = [string]$obj.kind
            }
            if ($obj.PSObject.Properties['message'] -and $obj.message) {
                $message = [string]$obj.message
            }
            if ($obj.PSObject.Properties['host'] -and $obj.host) {
                $hostName = [string]$obj.host
            }
            $list.Add([PSCustomObject]@{
                Utc     = $ts
                Kind    = $kind
                Message = $message
                Host    = $hostName
            }) | Out-Null
        }
    }
    finally {
        $reader.Dispose()
    }

    $list.ToArray()
}

function Write-SMBeatLog {
    param(
        [Parameter(Mandatory = $true)]
        [string]$DataRoot,
        [Parameter(Mandatory = $true)]
        [string]$Message,
        [ValidateSet('INFO', 'WARN', 'ERROR')]
        [string]$Level = 'INFO'
    )

    $line = '{0} {1} {2}{3}' -f (ConvertTo-SMBeatUtcString -DateTime (Get-SMBeatUtcNow)), $Level, $Message, [Environment]::NewLine
    Add-SMBeatTextFile -Path (Get-SMBeatLogPath -DataRoot $DataRoot) -Text $line
}

function Test-SMBeatNotifyConfigured {
    param($Config)

    if ($null -eq $Config) {
        return $false
    }
    if (-not $Config.PSObject.Properties['MailTo'] -or [string]::IsNullOrWhiteSpace([string]$Config.MailTo)) {
        return $false
    }
    if (-not $Config.PSObject.Properties['SmtpServer'] -or [string]::IsNullOrWhiteSpace([string]$Config.SmtpServer)) {
        return $false
    }
    $true
}

function Get-SMBeatNoticeSubject {
    param(
        [string]$Kind,
        [string]$HostName
    )

    if ([string]::IsNullOrWhiteSpace($HostName)) {
        $HostName = $env:COMPUTERNAME
    }

    switch ($Kind) {
        'registered' { return ('SMBeat {0} collector registered' -f $HostName) }
        'started' { return ('SMBeat {0} collector started' -f $HostName) }
        'stopped' { return ('SMBeat {0} collector stopped' -f $HostName) }
        default { return ('SMBeat {0} collector error' -f $HostName) }
    }
}

function Test-SMBeatNotifyRateLimit {
    param(
        [Parameter(Mandatory = $true)]
        [string]$DataRoot,
        [Parameter(Mandatory = $true)]
        [string]$Key,
        [int]$QuietSec = 900
    )

    if ($QuietSec -le 0) {
        return $false
    }

    $path = Get-SMBeatNotifyStatePath -DataRoot $DataRoot
    $json = Read-SMBeatTextFile -Path $path
    if ($null -eq $json) {
        return $false
    }

    $state = $null
    try {
        $state = ConvertFrom-SMBeatJson -Json $json
    }
    catch {
        return $false
    }

    if (-not $state -or -not $state.PSObject.Properties['Key'] -or [string]$state.Key -ne $Key) {
        return $false
    }
    if (-not $state.PSObject.Properties['Utc'] -or -not $state.Utc) {
        return $false
    }

    $last = ConvertFrom-SMBeatUtcString -Value ([string]$state.Utc)
    $age = (Get-SMBeatUtcNow) - $last
    $age.TotalSeconds -lt $QuietSec
}

function Save-SMBeatNotifyState {
    param(
        [Parameter(Mandatory = $true)]
        [string]$DataRoot,
        [Parameter(Mandatory = $true)]
        [string]$Key
    )

    $obj = [PSCustomObject]@{
        Key = $Key
        Utc = (ConvertTo-SMBeatUtcString -DateTime (Get-SMBeatUtcNow))
    }
    Write-SMBeatTextFile -Path (Get-SMBeatNotifyStatePath -DataRoot $DataRoot) -Text (ConvertTo-SMBeatJson -InputObject $obj)
}

function Send-SMBeatMailMessage {
    param(
        [Parameter(Mandatory = $true)]
        [string]$To,
        [Parameter(Mandatory = $true)]
        [string]$SmtpServer,
        [string]$From,
        [Parameter(Mandatory = $true)]
        [string]$Subject,
        [Parameter(Mandatory = $true)]
        [string]$Text
    )

    if ([string]::IsNullOrWhiteSpace($From)) {
        $From = $To
    }

    Send-MailMessage -To $To -From $From -Subject $Subject -Body $Text -SmtpServer $SmtpServer -ErrorAction Stop
}

function Send-SMBeatNotice {
    param(
        [Parameter(Mandatory = $true)]
        [string]$DataRoot,
        [ValidateSet('registered', 'started', 'stopped', 'error')]
        [string]$Kind = 'error',
        [Parameter(Mandatory = $true)]
        [string]$Message,
        $Config
    )

    $level = 'INFO'
    if ($Kind -eq 'error') {
        $level = 'ERROR'
    }
    elseif ($Kind -eq 'stopped') {
        $level = 'WARN'
    }
    $key = $Kind
    $quiet = 900
    if ($Kind -eq 'registered' -or $Kind -eq 'started' -or $Kind -eq 'stopped') {
        $quiet = 60
    }
    $limited = Test-SMBeatNotifyRateLimit -DataRoot $DataRoot -Key $key -QuietSec $quiet
    if ($Kind -eq 'error' -and $limited) {
        return
    }

    Write-SMBeatLog -DataRoot $DataRoot -Message $Message -Level $level
    Add-SMBeatCollectorEvent -DataRoot $DataRoot -Kind $Kind -Message $Message

    if ($null -eq $Config) {
        $Config = Read-SMBeatConfig -DataRoot $DataRoot
    }
    if (-not (Test-SMBeatNotifyConfigured -Config $Config)) {
        if ($Kind -eq 'error') {
            Save-SMBeatNotifyState -DataRoot $DataRoot -Key $key
        }
        return
    }
    if ($limited) {
        Write-SMBeatLog -DataRoot $DataRoot -Message ('Mail skipped (quiet): {0}' -f $Kind) -Level 'INFO'
        return
    }

    $subject = Get-SMBeatNoticeSubject -Kind $Kind -HostName $env:COMPUTERNAME
    $text = @(
        $Message
        ''
        ('Host: {0}' -f $env:COMPUTERNAME)
        ('Kind: {0}' -f $Kind)
        ('Utc: {0}' -f (ConvertTo-SMBeatUtcString -DateTime (Get-SMBeatUtcNow)))
        ('Log: {0}' -f (Get-SMBeatLogPath -DataRoot $DataRoot))
    ) -join [Environment]::NewLine

    $to = [string]$Config.MailTo
    $smtp = [string]$Config.SmtpServer
    $from = ''
    if ($Config.PSObject.Properties['MailFrom'] -and $Config.MailFrom) {
        $from = [string]$Config.MailFrom
    }

    try {
        Send-SMBeatMailMessage -To $to -SmtpServer $smtp -From $from -Subject $subject -Text $text
        Write-SMBeatLog -DataRoot $DataRoot -Message ('Mail sent: {0}' -f $Kind) -Level 'INFO'
    }
    catch {
        Write-SMBeatLog -DataRoot $DataRoot -Message ('Mail failed: {0}' -f $_.Exception.Message) -Level 'WARN'
    }
    Save-SMBeatNotifyState -DataRoot $DataRoot -Key $key
}