Build the Ops Reporting Foundation Helper
Create the local PowerShell helper file that every Ops Stack reporting-compatible script can share. This guide walks through the folder structure, the helper contract, the commented PowerShell implementation, a sample validation run, and the artifacts the helper creates so a new reader can build it from scratch and prove it works.
Good For
- Creating the Ops Reporting Foundation helper from an empty folder
- Understanding the shared result-row schema used by reporting-compatible scripts
- Learning how raw collector output becomes normalized report rows
- Exporting consistent HTML, CSV, JSON, and log artifacts
- Preparing future health, patching, certificate, AD, Azure Arc, and evidence scripts to plug into one reporting layer
- Building the first step toward a larger local operations reporting app
How to Use It
- Start with the purpose: this helper is not a health check by itself. It is the local reporting layer that later collector scripts dot-source when they need consistent HTML, CSV, JSON, and log output.
- Create the folder `./templates/reporting`. The helper file must live at `./templates/reporting/OpsReporting.Foundation.ps1` unless a later collector script is changed to use a different path.
- Create the helper file by running the Command Starter. The setup script writes the complete helper implementation into `OpsReporting.Foundation.ps1` and adds comments explaining each major section.
- Understand the five public functions the helper exposes: `New-OpsReportRunContext`, `Complete-OpsReportRunContext`, `New-OpsReportResultRow`, `New-OpsReportSummary`, and `Export-OpsReportArtifacts`.
- Use the generic target model from day one. A target can be a host, AD user, file share, certificate endpoint, DHCP scope, Azure resource, or any other object a collector evaluates.
- Use one normalized result row per evaluated check against one target. For example, one server can produce separate rows for disk, memory, pending reboot, services, and event logs.
- Run the built-in sample validation at the bottom of the Command Starter. It creates fake sample rows, exports artifacts, and prints the generated file paths.
- Review the generated HTML file first, then compare it with the CSV and JSON files. The HTML is for quick reading, the CSV is for spreadsheet review, and the JSON preserves the full run package.
- After the helper works, use it from reporting-compatible collector scripts. Those scripts may still write their own raw CSV, but they can also map findings into `New-OpsReportResultRow` and hand those rows to this helper.
- When this helper becomes important to multiple report packs, promote it from a dot-sourced `.ps1` helper into a versioned PowerShell module with tests and comment-based help. The page intentionally starts with a `.ps1` file because it is easier for beginners to create and inspect.
Execution Modes
- local-authoring
- local-validation
- local-rendering-for-remote-results
Inputs and Outputs
Inputs
- Local folder path for the helper file
- Report/check name
- Environment label
- Execution mode label
- Normalized result rows from a collector script
- Output folder path for generated artifacts
- Optional target count, script version, operator, expected values, actual values, and evidence text
Outputs
- helper-script
- html-report
- csv-report
- json-report-package
- log-file
- artifact-path-object
- operator-notes
Command Starter
Changes system state: review before running
# ---------------------------------------------------------------------
# Build the Ops Reporting Foundation helper
# ---------------------------------------------------------------------
# This setup script creates the helper file used by the reporting
# foundation examples. Run it from the root of your content/tool folder,
# or change $FoundationRoot to another location you prefer.
#
# What this creates:
# .\templates\reporting\OpsReporting.Foundation.ps1
#
# What the helper does:
# - creates a run context for each report execution
# - creates normalized result rows from any collector script
# - builds row-level and target-level summaries
# - exports HTML, CSV, JSON, and log artifacts
#
# This setup script writes files locally only. It does not contact or
# modify any remote system.
# ---------------------------------------------------------------------
# Folder where the reusable helper will live.
$FoundationRoot = '.\templates\reporting'
# Full path to the helper file that later reporting pages will dot-source.
$FoundationPath = Join-Path $FoundationRoot 'OpsReporting.Foundation.ps1'
# Create the folder if it does not already exist.
New-Item -ItemType Directory -Path $FoundationRoot -Force | Out-Null
# Write the helper implementation into OpsReporting.Foundation.ps1.
# The single-quoted here-string keeps PowerShell variables inside the
# helper from expanding while the file is being created.
@'
# ---------------------------------------------------------------------
# OpsReporting.Foundation.ps1
# ---------------------------------------------------------------------
# Purpose:
# Reusable local reporting helper for Ops Stack collector scripts.
#
# How to use:
# 1. Dot-source this file from a collector script.
# 2. Create a run context.
# 3. Add normalized result rows.
# 4. Create a summary.
# 5. Export HTML, CSV, JSON, and log artifacts.
#
# Design rule:
# One result row should represent one evaluated check against one target.
# Examples:
# Host/server01 + Disk/FreeSpace
# ADUser/jsmith + LastLogon/StaleThreshold
# FileShare/\\fs01\dept + Permission/BroadAccess
# ---------------------------------------------------------------------
Set-StrictMode -Version 2.0
function New-OpsReportRunContext {
<#
.SYNOPSIS
Creates metadata for one report run.
.DESCRIPTION
The run context is shared by every result row and every exported
artifact. It helps later readers understand when the report ran,
what collector produced it, what scope was used, and how long it ran.
#>
[CmdletBinding()]
param(
# Friendly report/check name, such as WindowsServerHealthSnapshot.
[Parameter(Mandatory)]
[string]$CheckName,
# Environment label used in report headings and future filtering.
[string]$Environment = 'unknown',
# Describes how the upstream collector gathered data.
[ValidateSet('local','local-rendering','remote-single-target','remote-host-list','ad-filtered','azure-resource-scope','imported-results')]
[string]$ExecutionMode = 'local-rendering',
# Input source, such as servers.txt, AD filter, ARG query, or manual.
[string]$InputSource = 'manual',
# Optional version of the collector script that produced the rows.
[string]$ScriptVersion = '0.1.0',
# Number of targets requested before collection started.
[int]$RequestedTargetCount = 0,
# Operator or service account running the collector.
[string]$Operator = $env:USERNAME
)
# Use UTC timestamps so reports from multiple systems compare cleanly.
$StartedUtc = (Get-Date).ToUniversalTime()
[pscustomobject]@{
PSTypeName = 'OpsReport.RunContext'
RunId = Get-Date -Format 'yyyyMMdd-HHmmss'
CheckName = $CheckName
Environment = $Environment
ExecutionMode = $ExecutionMode
InputSource = $InputSource
ScriptVersion = $ScriptVersion
Operator = $Operator
RequestedTargetCount = $RequestedTargetCount
StartedUtc = $StartedUtc
CompletedUtc = $null
DurationMs = $null
FrameworkStatus = 'Running'
}
}
function Complete-OpsReportRunContext {
<#
.SYNOPSIS
Closes the run context and records duration.
#>
[CmdletBinding()]
param(
# Run context returned by New-OpsReportRunContext.
[Parameter(Mandatory)]
[psobject]$RunContext,
# Final framework status. This describes the reporting run itself,
# not whether individual checks passed or failed.
[ValidateSet('Completed','CompletedWithErrors','Failed')]
[string]$FrameworkStatus = 'Completed'
)
$CompletedUtc = (Get-Date).ToUniversalTime()
# These properties are created by New-OpsReportRunContext, so assigning
# them here keeps the same object flowing through the report pipeline.
$RunContext.CompletedUtc = $CompletedUtc
$RunContext.DurationMs = [math]::Round(($CompletedUtc - $RunContext.StartedUtc).TotalMilliseconds, 0)
$RunContext.FrameworkStatus = $FrameworkStatus
return $RunContext
}
function New-OpsReportResultRow {
<#
.SYNOPSIS
Creates one normalized result row.
.DESCRIPTION
Use this function inside collector scripts to translate raw evidence
into a common reporting shape. The helper does not know how to check
disk, certificates, AD users, or Azure resources. It only standardizes
the result row after the collector has made that decision.
#>
[CmdletBinding()]
param(
# RunId from the run context. This ties the row to one report run.
[Parameter(Mandatory)]
[string]$RunId,
# Generic target category: Host, ADUser, FileShare, CertificateEndpoint, AzureResource, etc.
[Parameter(Mandatory)]
[string]$TargetType,
# Human-readable target name shown in reports.
[Parameter(Mandatory)]
[string]$TargetName,
# Optional stable identifier such as DN, resource ID, SID, or URL.
[string]$TargetId = '',
# Optional convenience field for host-based reports.
[string]$Host = '',
# High-level group such as Disk, Reboot, Certificate, Permission, or Connectivity.
[Parameter(Mandatory)]
[string]$CheckGroup,
# Specific check name inside the group.
[Parameter(Mandatory)]
[string]$CheckName,
# Standard status vocabulary used across all Ops Stack reports.
[ValidateSet('Pass','Warning','Fail','Error','Skipped','NotAssessed','Unreachable')]
[Parameter(Mandatory)]
[string]$Status,
# Optional severity used for sorting or future dashboards.
[ValidateSet('Info','Low','Medium','High','Critical')]
[string]$Severity = 'Info',
# Plain-language explanation of what was found.
[string]$Finding = '',
# Expected value or threshold, such as >= 15 percent free.
[string]$ExpectedValue = '',
# Actual observed value, such as 8 percent free.
[string]$ActualValue = '',
# Collection or runtime error text. Keep blank when the check ran successfully.
[string]$ErrorText = '',
# Optional error category such as WinRM, DNS, PermissionDenied, or QueryFailed.
[string]$ErrorCategory = '',
# Optional supporting detail. Keep this short enough for CSV and HTML output.
[string]$Evidence = '',
# Milliseconds spent collecting or evaluating this row.
[int]$DurationMs = 0
)
[pscustomobject]@{
PSTypeName = 'OpsReport.ResultRow'
RunId = $RunId
TargetType = $TargetType
TargetName = $TargetName
TargetId = $TargetId
Host = $Host
CheckGroup = $CheckGroup
CheckName = $CheckName
Status = $Status
Severity = $Severity
Finding = $Finding
ExpectedValue = $ExpectedValue
ActualValue = $ActualValue
ErrorText = $ErrorText
ErrorCategory = $ErrorCategory
Evidence = $Evidence
DurationMs = $DurationMs
CollectedUtc = (Get-Date).ToUniversalTime()
}
}
function New-OpsReportSummary {
<#
.SYNOPSIS
Builds summary counts from normalized result rows.
.DESCRIPTION
The summary intentionally includes both row-level counts and
target-level counts. This avoids a common reporting mistake where
three warnings on one server are confused with three affected servers.
#>
[CmdletBinding()]
param(
# Completed or running context for this report.
[Parameter(Mandatory)]
[psobject]$RunContext,
# Normalized rows created by New-OpsReportResultRow.
[Parameter(Mandatory)]
[object[]]$Results
)
$Rows = @($Results)
# Group by target so target-level health can be counted separately from row counts.
$TargetGroups = @($Rows | Group-Object -Property TargetType, TargetName)
$TargetsWithWarnings = @($TargetGroups | Where-Object {
$_.Group.Status -contains 'Warning'
}).Count
$TargetsWithFailures = @($TargetGroups | Where-Object {
$_.Group.Status -contains 'Fail' -or $_.Group.Status -contains 'Error'
}).Count
$TargetsUnreachable = @($TargetGroups | Where-Object {
$_.Group.Status -contains 'Unreachable'
}).Count
$TargetsHealthy = @($TargetGroups | Where-Object {
($_.Group.Status | Where-Object { $_ -in @('Warning','Fail','Error','Unreachable') }).Count -eq 0
}).Count
# Overall status is intentionally conservative.
# Fail/Error rows mean the report needs attention.
# Warning/Unreachable rows mean the report completed but needs review.
$OverallStatus = if (@($Rows | Where-Object { $_.Status -in @('Fail','Error') }).Count -gt 0) {
'Fail'
}
elseif (@($Rows | Where-Object { $_.Status -in @('Warning','Unreachable') }).Count -gt 0) {
'Warning'
}
elseif ($Rows.Count -eq 0 -or @($Rows | Where-Object { $_.Status -eq 'NotAssessed' }).Count -eq $Rows.Count) {
'NotAssessed'
}
else {
'Pass'
}
[pscustomobject]@{
PSTypeName = 'OpsReport.Summary'
RunId = $RunContext.RunId
CheckName = $RunContext.CheckName
Environment = $RunContext.Environment
OverallStatus = $OverallStatus
ResultRowsTotal = $Rows.Count
PassRows = @($Rows | Where-Object Status -eq 'Pass').Count
WarningRows = @($Rows | Where-Object Status -eq 'Warning').Count
FailRows = @($Rows | Where-Object Status -eq 'Fail').Count
ErrorRows = @($Rows | Where-Object Status -eq 'Error').Count
SkippedRows = @($Rows | Where-Object Status -eq 'Skipped').Count
NotAssessedRows = @($Rows | Where-Object Status -eq 'NotAssessed').Count
UnreachableRows = @($Rows | Where-Object Status -eq 'Unreachable').Count
TargetsRequested = $RunContext.RequestedTargetCount
TargetsProcessed = $TargetGroups.Count
TargetsHealthy = $TargetsHealthy
TargetsWithWarnings = $TargetsWithWarnings
TargetsWithFailures = $TargetsWithFailures
TargetsUnreachable = $TargetsUnreachable
}
}
function Export-OpsReportArtifacts {
<#
.SYNOPSIS
Exports the report artifacts to disk.
.DESCRIPTION
This function writes local files only. Collector scripts should call
this before email, Teams, ticket, or other delivery actions so that
evidence remains available even if delivery fails.
#>
[CmdletBinding()]
param(
# Folder where report artifacts will be written.
[Parameter(Mandatory)]
[string]$OutputRoot,
# Run context returned by New-OpsReportRunContext / Complete-OpsReportRunContext.
[Parameter(Mandatory)]
[psobject]$RunContext,
# Summary returned by New-OpsReportSummary.
[Parameter(Mandatory)]
[psobject]$Summary,
# Normalized result rows.
[Parameter(Mandatory)]
[object[]]$Results
)
# Create the output folder locally. This is the only state change made by the helper.
New-Item -ItemType Directory -Path $OutputRoot -Force | Out-Null
# Keep file names tied to the RunId so one report run produces a matched artifact set.
$BaseName = Join-Path $OutputRoot $RunContext.RunId
$CsvPath = "$BaseName-results.csv"
$JsonPath = "$BaseName-report.json"
$HtmlPath = "$BaseName-summary.html"
$LogPath = "$BaseName-execution.log"
# CSV is the easiest artifact for spreadsheet review and later ingestion.
$Results | Export-Csv -Path $CsvPath -NoTypeInformation -Encoding UTF8
# JSON preserves the full run package: run metadata, summary, and rows.
[pscustomobject]@{
RunContext = $RunContext
Summary = $Summary
Results = $Results
} | ConvertTo-Json -Depth 8 | Set-Content -Path $JsonPath -Encoding UTF8
# Simple HTML output. Keep the helper plain; product packs can add styling later.
$HtmlSections = @()
$HtmlSections += '<h1>Ops Report Summary</h1>'
$HtmlSections += '<h2>Run Context</h2>'
$HtmlSections += ($RunContext | ConvertTo-Html -Fragment)
$HtmlSections += '<h2>Summary</h2>'
$HtmlSections += ($Summary | ConvertTo-Html -Fragment)
$HtmlSections += '<h2>Result Rows</h2>'
$HtmlSections += ($Results | ConvertTo-Html -Fragment)
ConvertTo-Html `
-Title $RunContext.CheckName `
-Body ($HtmlSections -join [Environment]::NewLine) |
Set-Content -Path $HtmlPath -Encoding UTF8
# Plain execution log for quick proof that artifacts were written.
"[$((Get-Date).ToUniversalTime().ToString('o'))] Exported artifacts for RunId $($RunContext.RunId)." |
Set-Content -Path $LogPath -Encoding UTF8
# Return paths so a calling script can attach, email, or link to the artifacts later.
[pscustomobject]@{
PSTypeName = 'OpsReport.Artifacts'
OutputFolder = (Resolve-Path $OutputRoot).Path
HtmlPath = (Resolve-Path $HtmlPath).Path
CsvPath = (Resolve-Path $CsvPath).Path
JsonPath = (Resolve-Path $JsonPath).Path
LogPath = (Resolve-Path $LogPath).Path
ArtifactCount = 4
}
}
'@ | Set-Content -Path $FoundationPath -Encoding UTF8
# ---------------------------------------------------------------------
# Validate the helper immediately after creating it
# ---------------------------------------------------------------------
# Dot-source the helper so its functions are available in this PowerShell session.
. $FoundationPath
# Confirm the expected public functions exist. If a function is missing,
# Get-Command throws and the setup needs to be corrected before moving on.
$ExpectedFunctions = @(
'New-OpsReportRunContext',
'Complete-OpsReportRunContext',
'New-OpsReportResultRow',
'New-OpsReportSummary',
'Export-OpsReportArtifacts'
)
$ExpectedFunctions | ForEach-Object {
Get-Command $_ -ErrorAction Stop | Select-Object Name, CommandType
}
# ---------------------------------------------------------------------
# Create a small sample report to prove the helper works end-to-end
# ---------------------------------------------------------------------
# This sample uses fake health-check rows. It does not query or modify
# any real server. The point is to prove the helper can create artifacts.
$Run = New-OpsReportRunContext `
-CheckName 'SampleOpsHealthReport' `
-Environment 'lab' `
-ExecutionMode 'local-validation' `
-InputSource 'built-in sample rows' `
-RequestedTargetCount 2 `
-ScriptVersion '0.1.0'
$Results = @(
New-OpsReportResultRow `
-RunId $Run.RunId `
-TargetType 'Host' `
-TargetName 'server01' `
-Host 'server01' `
-CheckGroup 'Disk' `
-CheckName 'FreeSpacePercent' `
-Status 'Pass' `
-Severity 'Info' `
-ExpectedValue '>= 15 percent free' `
-ActualValue '42 percent free' `
-Finding 'Disk free space is above the warning threshold.'
New-OpsReportResultRow `
-RunId $Run.RunId `
-TargetType 'Host' `
-TargetName 'server02' `
-Host 'server02' `
-CheckGroup 'Reboot' `
-CheckName 'PendingReboot' `
-Status 'Warning' `
-Severity 'Medium' `
-ExpectedValue 'No pending reboot indicators' `
-ActualValue 'Pending reboot detected' `
-Finding 'The sample target is flagged as needing a reboot.'
)
# Close the run context after result rows are created.
$CompletedRun = Complete-OpsReportRunContext -RunContext $Run
# Build summary counts from the normalized rows.
$Summary = New-OpsReportSummary -RunContext $CompletedRun -Results $Results
# Export artifacts to a local sample-output folder.
$Artifacts = Export-OpsReportArtifacts `
-OutputRoot '.\output\ops-reporting-foundation-sample' `
-RunContext $CompletedRun `
-Summary $Summary `
-Results $Results
# Show the generated artifact paths so the operator knows where to look.
$Artifacts | Format-ListValidation
- The folder `./templates/reporting` exists after the setup script runs.
- The file `./templates/reporting/OpsReporting.Foundation.ps1` exists and contains the helper functions, not just placeholder calls.
- The helper can be dot-sourced with `. ./templates/reporting/OpsReporting.Foundation.ps1` without parse errors.
- `Get-Command` confirms all five public functions exist in the current PowerShell session.
- The sample validation run creates an output folder under `./output/ops-reporting-foundation-sample`.
- The sample output folder contains one HTML summary, one CSV result file, one JSON report package, and one plain log file.
- The sample summary reports two result rows and one warning target, proving that row-level and target-level summary counts are both working.
- No remote systems are contacted during the helper setup or sample validation run.
Reporting
- Defines the common reporting contract used by reporting-compatible Toolchest scripts.
- Separates collector logic from presentation logic: collectors gather evidence, while this helper creates the shared report artifacts.
- Supports both row-level counts and target-level counts so one noisy host does not look like multiple affected hosts unless the report intentionally says so.
- Allows future scripts to be composed into a larger operations reporting app by emitting the same normalized result-row schema.
- Keeps local artifacts available before any future email, Teams, Jira, or ticket-delivery workflow is attempted.
Safety Notes
- This setup writes a local helper file and local sample report artifacts only. It does not modify remote systems.
- Review `$FoundationRoot` and the sample output path before running the setup script so files are created where you expect them.
- Treat the first run as a preview or validation pass. If you are testing only, delete or archive the generated helper file and sample report folder as the explicit undo path.
- The helper is for report artifact creation. Collector scripts that gather data from remote servers, AD, Azure, or network devices still need their own safety review.
- Do not place passwords, tokens, connection strings, secret-bearing command lines, or sensitive personal data into report rows unless the report storage location is approved for that data.
- For test cleanup, remove the generated `./templates/reporting/OpsReporting.Foundation.ps1` file and the `./output/ops-reporting-foundation-sample` folder.