Private/New-HDTConsoleMonitorRow.ps1

function New-HDTConsoleMonitorRow {
    <#
        .SYNOPSIS
            One deployment's row on the monitoring view.
 
        .DESCRIPTION
            Split out of Get-HDTConsoleMonitor because reading a heartbeat and
            judging a heartbeat are two jobs, and the second one has all the
            decisions in it: what health means, what the row says, and what a
            file that would not parse still gets to show.
 
            HEALTH IS FOUR STATES AND NOT A BOOLEAN. Live and Stalled are the
            two an operator acts on; Finished exists so a completed run cannot
            age into a red row; Unreadable exists so a file caught mid-write
            still puts its machine on the screen.
 
            AN UNREADABLE HEARTBEAT KEEPS ITS ID, because the id is in the file
            NAME - <RunId>.json - and that is legible when nothing inside the
            file is. It sorts to the top by having no age of its own, which is
            where a machine nobody can see the state of belongs.
 
            THE ROW LEADS WITH THE RUN AND THE STEP. Those are what a person
            scanning twenty rows is matching against: which machine, and how far
            has it got.
 
        .PARAMETER RunId
            The run, taken from the file name.
 
        .PARAMETER Path
            The heartbeat file.
 
        .PARAMETER Document
            The parsed heartbeat, or nothing if it would not parse.
 
        .PARAMETER Now
            The instant to measure age against, in UTC.
 
        .PARAMETER StaleMinute
            How long a heartbeat may go unwritten before its run is stalled.
 
        .INPUTS
            None. This command does not accept pipeline input.
 
        .OUTPUTS
            System.Management.Automation.PSCustomObject
 
        .EXAMPLE
            New-HDTConsoleMonitorRow -RunId 'RUN-0001' -Path $path -Document $document -Now $now -StaleMinute 20
    #>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '',
        Justification = 'Builds one row of a display model in memory; it changes no state.')]
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param(
        [Parameter(Mandatory = $true, Position = 0)]
        [ValidateNotNullOrEmpty()]
        [string] $RunId,

        [Parameter(Mandatory = $true, Position = 1)]
        [ValidateNotNullOrEmpty()]
        [string] $Path,

        [Parameter(Position = 2)]
        [AllowNull()]
        [object] $Document,

        [Parameter(Mandatory = $true, Position = 3)]
        [datetime] $Now,

        [Parameter(Mandatory = $true, Position = 4)]
        [int] $StaleMinute
    )

    Set-StrictMode -Version Latest
    $ErrorActionPreference = 'Stop'

    $command = "ConvertFrom-Json -InputObject ((New-HDTFileSystem).ReadAllText('{0}'))" -f $Path

    # -- the file that would not parse -------------------------------------

    if ($null -eq $Document) {
        return [pscustomobject] @{
            RunId       = $RunId
            Phase       = ''
            RunStatus   = ''
            StepIndex   = 0
            StepCount   = 0
            StepText    = ''
            StepName    = ''
            StepType    = ''
            Updated     = $null
            SinceSecond = -1
            SinceText   = ''
            Health      = 'Unreadable'
            Icon        = [string] ([char] 0x26A0)      # warning sign
            Text        = '{0} - the heartbeat could not be read' -f $RunId
            Path        = $Path
            Command     = $command
        }
    }

    $property = @($Document.PSObject.Properties.Name)

    $read = {
        param([string] $Name, [object] $Fallback)

        if ($property -contains $Name) { return $Document.$Name }
        return $Fallback
    }

    $phase = [string] (& $read 'phase' '')
    $runStatus = [string] (& $read 'status' '')
    $stepIndex = [int] (& $read 'stepIndex' 0)
    $stepCount = [int] (& $read 'stepCount' 0)
    $stepName = [string] (& $read 'stepName' '')
    $stepType = [string] (& $read 'stepType' '')

    # -- how long since it said anything -----------------------------------

    $updated = $null
    $since = -1

    $stamp = & $read 'updated' $null

    # ConvertFrom-Json ALREADY TURNED IT INTO A [datetime], and casting that back
    # to a string is where three hours went missing. PowerShell's JSON reader
    # silently converts an ISO-8601 string into a DateTime - a LOCAL one - so
    # [string] renders it in the current culture ("16/08/2026 01:00:00"), and
    # parsing THAT as a round-trip timestamp reads the local wall clock as
    # though it were UTC. Every age on this screen came out one time-zone
    # offset too large, on a host east of UTC, and the failures pointed at the
    # arithmetic rather than at the cast.
    #
    # So the value is taken as it arrives, whichever it is. A share is also
    # entitled to hold a heartbeat written by something that quoted the field
    # differently, and the string path still reads it.
    if ($stamp -is [datetime]) {
        $updated = ([datetime] $stamp).ToUniversalTime()
        $since = [int] ($Now - $updated).TotalSeconds
    } elseif (-not [string]::IsNullOrWhiteSpace([string] $stamp)) {
        $parsed = [datetime]::MinValue

        # RoundtripKind ALONE, and the conversion by hand. Write-HDTStatus
        # formats with 'o' precisely so this can be read back without a culture
        # in the middle of it, and 'o' carries the offset - which is what
        # RoundtripKind honours. It cannot be combined with AdjustToUniversal:
        # .NET rejects the pair outright rather than ignoring one of them.
        if ([datetime]::TryParse([string] $stamp, [System.Globalization.CultureInfo]::InvariantCulture,
                [System.Globalization.DateTimeStyles]::RoundtripKind, [ref] $parsed)) {

            $updated = $parsed.ToUniversalTime()
            $since = [int] ($Now - $updated).TotalSeconds
        }
    }

    # -- what that makes it ------------------------------------------------
    #
    # A run the engine has finished is Finished however long ago that was: a
    # completed heartbeat is not a heartbeat that stopped, and ageing one into a
    # red row teaches a technician to ignore red.
    $health = 'Live'

    if ($runStatus -ne 'Running' -and -not [string]::IsNullOrWhiteSpace($runStatus)) {
        $health = 'Finished'
    } elseif ($since -lt 0) {
        # It parsed, but carried no timestamp anybody can read.
        $health = 'Unreadable'
    } elseif ($since -gt ($StaleMinute * 60)) {
        $health = 'Stalled'
    }

    $icon = [string] ([char] 0x25B6)                    # play - running
    if ($health -eq 'Stalled') { $icon = [string] ([char] 0x26A0) }
    if ($health -eq 'Finished') { $icon = [string] ([char] 0x2714) }
    if ($health -eq 'Unreadable') { $icon = [string] ([char] 0x26A0) }

    $sinceText = ''
    if ($since -ge 0) { $sinceText = Format-HDTConsoleDuration -Second $since }

    $step = Get-HDTConsoleDisplayText -Text $stepName -Fallback '(no step yet)'

    $text = '{0} - {1}' -f $RunId, $step
    if (-not [string]::IsNullOrWhiteSpace($sinceText)) {
        $text = '{0} - {1} ({2} ago)' -f $RunId, $step, $sinceText
    }

    # "STEP 7" IS A NUMBER NOBODY CAN ACT ON; "step 7 of 12" is a progress bar.
    # The count comes from the heartbeat because the console cannot work it out
    # from a share it is only reading - and a heartbeat written by an older
    # engine carries no count at all, so that case says what it knows rather
    # than inventing a total.
    $stepText = [string] $stepIndex
    if ($stepCount -gt 0) { $stepText = '{0} of {1}' -f $stepIndex, $stepCount }

    return [pscustomobject] @{
        RunId       = $RunId
        Phase       = $phase
        RunStatus   = $runStatus
        StepIndex   = $stepIndex
        StepCount   = $stepCount
        StepText    = $stepText
        StepName    = $stepName
        StepType    = $stepType
        Updated     = $updated
        SinceSecond = $since
        SinceText   = $sinceText
        Health      = $health
        Icon        = $icon
        Text        = $text
        Path        = $Path
        Command     = $command
    }
}