Private/TimedEvent.ps1

# The Timed Event primitive.
#
# A Timed Event is one thing a Check read out of a log that happened at a time: a
# connection to a Server that was lost at 08:05 on the third, the machine's network going
# away, a program that crashed, a sign-in. It is what the Report's 30 days are drawn from,
# beside the Findings that count the same events in words. See ADR-0010.
#
# Not a Measurement: a Measurement is one number for one subject and one metric, and has
# no time. Not a row of events.csv either: that is every event the Checks read, as Windows
# wrote it, and says neither which lane it belongs in nor which Finding judged its kind -
# only the Judge knows that (ADR-0002). A Judge returns it among its Findings, as it does
# its Measurements, and the Run tells the three apart by type.
#
# Lane and Mark are closed vocabularies, for the reason a Measurement's Metric is one: a
# misspelt Mark would be a lane of its own.

# Where the timeline draws it, and what it is there. A Server's lane holds what happened
# to the connections to that Server; the machine's lane its own network going away; the
# markers what happened to programs and Users on the same axis; and a log's reach is how
# far back the events of that log can be there at all.
$script:TimedEventMarks = [ordered]@{
    Server  = @('Lost', 'Failed', 'ShareNotFound', 'Context')
    Machine = @('Lost', 'Context')
    Marker  = @('Crash', 'Hang', 'Start', 'SignIn')
    Log     = @('Reaches', 'Unread')
}

function New-TimedEvent {
    <#
    .SYNOPSIS
        One thing that happened at a time, as the Report's timeline draws it. Pure.
    .DESCRIPTION
        Validated here, like a Finding: an unknown Lane, or a Mark that is not of its Lane,
        throws where the Judge is written. Every event has a time, but for a log that could
        not be read: that one reaches back to nothing.
    .PARAMETER Time
        When it happened; for a log's reach, the time of the oldest record the log holds.
    .PARAMETER Lane
        Server, Machine, Marker or Log.
    .PARAMETER Mark
        What it is in its Lane. Server: Lost, Failed, ShareNotFound, Context. Machine: Lost,
        Context. Marker: Crash, Hang, Start, SignIn. Log: Reaches, Unread. Context is an
        event shown and not counted: a resume, a link that came back.
    .PARAMETER Subject
        The Server as the event named it - a name, an FQDN or an address -, the program that
        crashed, the account that signed in. Empty for the machine itself.
    .PARAMETER Address
        The Server's address, where the event or a lookup the Kind already made has it. Not
        looked up for this: the timeline merges a Server by its address first and its name
        second, as the path does.
    .PARAMETER Detail
        What it was, in words: the kind of the event as the Report calls it.
    .PARAMETER Log
        The event log it was read from, which a log's reach is about.
    .PARAMETER Finding
        The Finding that judged this kind of event, whose Severity the timeline colours it
        with: the Finding itself, or its Id as it arrived from the Elevated Part. Nothing
        where no Finding judges it, and the timeline then draws it grey.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$Time,
        [Parameter(Mandatory)][AllowEmptyString()][string]$Lane,
        [Parameter(Mandatory)][AllowEmptyString()][string]$Mark,
        [AllowNull()][AllowEmptyString()][string]$Subject = '',
        [AllowNull()][AllowEmptyString()][string]$Address = '',
        [AllowNull()][AllowEmptyString()][string]$Detail = '',
        [AllowNull()][AllowEmptyString()][string]$Log = '',
        [AllowNull()]$Finding
    )

    $laneValue = Get-MeasurementVocabularyValue -Field 'Lane of a Timed Event' -Value $Lane -Permitted @($script:TimedEventMarks.Keys)
    $marks = $script:TimedEventMarks[$laneValue]
    $markValue = @($marks | Where-Object { $_ -eq $Mark } | Select-Object -First 1)
    if (-not $markValue.Count) {
        throw ("'{0}' is no Mark of the Lane {1}. Permitted: {2}." -f $Mark, $laneValue, ($marks -join ', '))
    }
    $markValue = $markValue[0]

    # A time or nothing, and nothing only where a log reaches back to nothing. Text that is
    # no time is refused rather than placed at a guess.
    $at = $null
    if ($null -ne $Time -and "$Time".Trim()) {
        $at = ConvertTo-DataTime $Time
        if ($null -eq $at) { throw ("'{0}' is not a time. A Timed Event is placed by its time." -f $Time) }
    }
    if ($null -eq $at -and -not ($laneValue -eq 'Log' -and $markValue -eq 'Unread')) {
        throw ("A Timed Event {0} {1} needs a time: the timeline cannot place it without one." -f $laneValue, $markValue)
    }

    if ($laneValue -eq 'Server' -and -not "$Subject".Trim() -and -not "$Address".Trim()) {
        throw 'A Timed Event of a Server names the Server, by its name or its address: it has no lane to be drawn in otherwise.'
    }
    if ($laneValue -eq 'Log' -and -not "$Log".Trim()) {
        throw "A log's reach names the log it is about."
    }

    [pscustomobject]@{
        PSTypeName = 'Gutcheck.TimedEvent'
        Time       = $at
        Lane       = $laneValue
        Mark       = $markValue
        Subject    = "$Subject".Trim()
        Address    = "$Address".Trim()
        Detail     = "$Detail".Trim()
        Log        = "$Log".Trim()
        Finding    = ConvertTo-FindingId -Finding $Finding -Of 'a Timed Event'
        # Stamped on by the Run, as on a Finding. See Set-CheckOrigin.
        CheckName  = ''
        App        = ''
    }
}

function ConvertTo-LogCoverageTimedEvent {
    <#
    .SYNOPSIS
        How far back each log reaches, as the timeline marks it: one Log Reaches per log
        that was read and said, one Log Unread per log this Run's rights were refused. Pure.
    .DESCRIPTION
        A log overwrites its oldest records, so one that reaches back a week says nothing of
        the three weeks before; and a log that was not read says nothing at all. An empty
        stretch on the timeline is otherwise read as a quiet one.
 
        A log that would not say how far it reaches - an empty one - is left out: nothing
        says whether it has been cleared or nothing ever happened.
    .PARAMETER Coverage
        { Log, Oldest } per log, as the NetworkDisruption and Logon Gatherers return it.
    .PARAMETER Unreadable
        The logs this Run's rights were refused, by name.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([AllowNull()][AllowEmptyCollection()]$Coverage, [AllowNull()][AllowEmptyCollection()]$Unreadable)

    foreach ($entry in @($Coverage | Where-Object { $_ })) {
        $log    = "$(Get-DataProperty $entry 'Log')".Trim()
        $oldest = ConvertTo-DataTime (Get-DataProperty $entry 'Oldest')
        if (-not $log -or $null -eq $oldest) { continue }
        New-TimedEvent -Time $oldest -Lane Log -Mark Reaches -Log $log
    }
    foreach ($log in @($Unreadable | ForEach-Object { "$_".Trim() } | Where-Object { $_ })) {
        New-TimedEvent -Lane Log -Mark Unread -Log $log
    }
}