PowerShell 技能连载 - 合规审计自动化

适用于 PowerShell 5.1 及以上版本

在企业 IT 管理中,合规审计是一项持续性工作。无论是为了满足 CIS Benchmarks、ISO 27001 还是行业监管要求,安全团队都需要定期检查系统配置是否符合既定的安全基线。手动逐项检查不仅耗时巨大,而且容易出现遗漏和标准不一致的问题,尤其是在服务器数量较多的环境中。

PowerShell 凭借其对 Windows 系统的深度访问能力,可以从密码策略、账户策略、审核策略到注册表配置、防火墙规则、文件权限等维度全面采集安全配置信息。将审计规则标准化为可执行脚本后,每次审计都能以完全一致的检查项和判定逻辑运行,确保结果的可重复性和可比性。

本文将构建一套完整的合规审计自动化方案,涵盖安全基线检查、系统配置审计和合规报告生成三个核心模块,帮助企业将审计周期从数天缩短到数分钟,并输出结构化的 HTML 报告供管理层审阅。

安全基线检查

安全基线检查是合规审计的基础。我们将密码策略、账户策略、审核策略和用户权限分配四项关键检查整合到一个函数中,每项检查都映射到 CIS Benchmark 的具体控制项,并给出合规/不合规的判定结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
function Invoke-SecurityBaselineCheck {
[CmdletBinding()]
param(
[string]$ComputerName = $env:COMPUTERNAME
)

$results = @()

# 密码策略检查
$passwordPolicy = net accounts |
Select-String -Pattern 'Minimum password length|Maximum password age|Minimum password age|Password history length'

$passwordLengthLine = $passwordPolicy | Where-Object { $_ -match 'Minimum password length' }
$minLength = if ($passwordLengthLine -match '(\d+)') { [int]$Matches[1] } else { 0 }

$results += [PSCustomObject]@{
Category = '密码策略'
CheckItem = '最小密码长度'
Expected = '>= 14 字符 (CIS 1.1.1)'
Actual = "$minLength 字符"
Status = if ($minLength -ge 14) { 'Compliant' } else { 'Non-Compliant' }
Severity = 'High'
}

$maxAgeLine = $passwordPolicy | Where-Object { $_ -match 'Maximum password age' }
$maxAge = if ($maxAgeLine -match '(\d+)') { [int]$Matches[1] } else { 0 }

$results += [PSCustomObject]@{
Category = '密码策略'
CheckItem = '密码最大使用期限'
Expected = '<= 60 天 (CIS 1.1.3)'
Actual = "$maxAge 天"
Status = if ($maxAge -gt 0 -and $maxAge -le 60) { 'Compliant' } else { 'Non-Compliant' }
Severity = 'Medium'
}

# 账户策略检查
$lockoutPolicy = net accounts |
Select-String -Pattern 'Lockout threshold|Lockout duration|Lockout observation'

$lockoutLine = $lockoutPolicy | Where-Object { $_ -match 'Lockout threshold' }
$lockoutThreshold = if ($lockoutLine -match '(\d+)') { [int]$Matches[1] } else { 0 }

$results += [PSCustomObject]@{
Category = '账户策略'
CheckItem = '账户锁定阈值'
Expected = '<= 5 次失败尝试 (CIS 1.2.1)'
Actual = if ($lockoutThreshold -eq 0) { 'Never' } else { "$lockoutThreshold 次" }
Status = if ($lockoutThreshold -gt 0 -and $lockoutThreshold -le 5) { 'Compliant' } else { 'Non-Compliant' }
Severity = 'High'
}

# 审核策略检查
$auditPolicies = auditpol /get /category:* /r 2>$null |
ConvertFrom-Csv |
Where-Object { $_.'Inclusion Setting' -eq 'No Auditing' -and $_.'Subcategory' -ne '' }

$criticalAuditCategories = @(
'Logon',
'Logoff',
'Special Logon',
'User Account Management',
'Security State Change'
)

foreach ($critical in $criticalAuditCategories) {
$found = $auditPolicies | Where-Object { $_.'Subcategory' -eq $critical }
$results += [PSCustomObject]@{
Category = '审核策略'
CheckItem = "审核: $critical"
Expected = 'Success and Failure (CIS 17.x)'
Actual = if ($found) { 'No Auditing' } else { '已配置' }
Status = if ($found) { 'Non-Compliant' } else { 'Compliant' }
Severity = 'High'
}
}

# 用户权限检查 - 检查敏感权限的分配情况
$sensitivePrivileges = @{
'SeDebugPrivilege' = '调试程序'
'SeTakeOwnershipPrivilege' = '取得文件所有权'
'SeLoadDriverPrivilege' = '加载和卸载设备驱动程序'
}

foreach ($privilege in $sensitivePrivileges.GetEnumerator()) {
$privOutput = secedit /export /cfg "$env:TEMP\secedit.cfg" 2>$null
$privLine = Get-Content "$env:TEMP\secedit.cfg" |
Select-String -Pattern $privilege.Key
$assignedTo = if ($privLine) {
($privLine.Line -split '=')[1].Trim()
} else {
'未找到'
}

$isAdminOnly = $assignedTo -match 'Administrators' -and
$assignedTo -notmatch 'Everyone|Users|Authenticated'

$results += [PSCustomObject]@{
Category = '用户权限'
CheckItem = $privilege.Value
Expected = '仅 Administrators (CIS 2.x)'
Actual = $assignedTo
Status = if ($isAdminOnly) { 'Compliant' } else { 'Non-Compliant' }
Severity = 'Critical'
}
}

Remove-Item "$env:TEMP\secedit.cfg" -ErrorAction SilentlyContinue

$results | Sort-Object Severity, Category, CheckItem
}

运行基线检查后,可以看到各项安全策略的合规状态:

1
2
3
4
5
6
7
8
9
Category  CheckItem              Expected                    Actual         Status          Severity
------- ---------- -------- ------ ------ --------
密码策略 最小密码长度 >= 14 字符 (CIS 1.1.1) 8 字符 Non-Compliant High
密码策略 密码最大使用期限 <= 60 天 (CIS 1.1.3) 90 天 Non-Compliant Medium
账户策略 账户锁定阈值 <= 5 次失败尝试 (CIS 1.2.1) 5 次 Compliant High
审核策略 审核: Logon Success and Failure (CIS) 已配置 Compliant High
审核策略 审核: User Account Management Success and Failure (CIS) No Auditing Non-Compliant High
用户权限 调试程序 仅 Administrators (CIS 2.x) Administrators Compliant Critical
用户权限 取得文件所有权 仅 Administrators (CIS 2.x) Administrators Compliant Critical

系统配置审计

安全基线只覆盖了策略层面的检查,实际运行环境中的服务、防火墙、注册表和文件系统同样需要审计。下面这个函数对系统配置进行全面扫描,检查每项配置是否满足 CIS 和 ISO 27001 的要求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
function Invoke-SystemConfigAudit {
[CmdletBinding()]
param(
[string[]]$CriticalRegistryKeys = @(
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System',
'HKLM:\SYSTEM\CurrentControlSet\Services\LDAP',
'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer'
)
)

$auditResults = @()

# 服务配置检查
$insecureServices = @{
'SysMain' = 'SuperFetch/SysMain 可能导致信息泄露'
'RemoteRegistry' = '远程注册表访问应被禁用'
'XblAuthManager' = 'Xbox 服务不应存在于服务器'
'WSearch' = 'Windows Search 在服务器上应禁用'
}

foreach ($svc in $insecureServices.GetEnumerator()) {
$service = Get-Service -Name $svc.Key -ErrorAction SilentlyContinue
$startType = try {
(Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Services\$($svc.Key)" -ErrorAction Stop).Start
} catch { 4 }

$isDisabled = $service.Status -eq 'Stopped' -and $startType -eq 4

$auditResults += [PSCustomObject]@{
Category = '服务配置'
CheckItem = "服务: $($svc.Key)"
Expected = '已禁用 (CIS 9.x)'
Actual = "状态: $($service.Status), 启动类型: $(if($startType -eq 4){'Disabled'}elseif($startType -eq 3){'Manual'}else{'Auto'})"
Status = if ($isDisabled) { 'Compliant' } else { 'Non-Compliant' }
Severity = 'Medium'
Remediation = "Set-Service -Name $($svc.Key) -StartupType Disabled; Stop-Service -Name $($svc.Key) -Force"
}
}

# 防火墙规则审计
$firewallProfiles = Get-NetFirewallProfile |
Select-Object Name, Enabled

foreach ($profile in $firewallProfiles) {
$auditResults += [PSCustomObject]@{
Category = '防火墙'
CheckItem = "防火墙配置文件: $($profile.Name)"
Expected = '已启用 (CIS 9.1.x)'
Actual = "Enabled: $($profile.Enabled)"
Status = if ($profile.Enabled) { 'Compliant' } else { 'Non-Compliant' }
Severity = 'Critical'
Remediation = "Set-NetFirewallProfile -Name $($profile.Name) -Enabled True"
}
}

# 危险入站规则检查
$dangerousRules = Get-NetFirewallRule |
Where-Object {
$_.Direction -eq 'Inbound' -and
$_.Action -eq 'Allow' -and
$_.Enabled -eq 'True'
} | Get-NetFirewallPortFilter |
Where-Object { $_.LocalPort -in @('3389', '22', '445', '139') }

$rdpExposed = $dangerousRules |
Where-Object { $_.LocalPort -eq '3389' }

if ($rdpExposed) {
$auditResults += [PSCustomObject]@{
Category = '防火墙'
CheckItem = 'RDP (3389) 入站规则'
Expected = '不应公开暴露 (CIS 9.2.x)'
Actual = '存在允许的入站规则'
Status = 'Non-Compliant'
Severity = 'Critical'
Remediation = '限制 RDP 访问源 IP 范围,或通过 VPN 隧道访问'
}
}

# 注册表安全审计
$regChecks = @(
@{
Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
Name = 'EnableLUA'
Expected = 1
Desc = 'UAC 必须启用 (CIS 2.3.17.3)'
Severity = 'Critical'
},
@{
Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System'
Name = 'ConsentPromptBehaviorAdmin'
Expected = 2
Desc = 'UAC 提示级别应为"始终通知" (CIS 2.3.17.5)'
Severity = 'Medium'
},
@{
Path = 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurePipeServers\Winreg'
Name = 'RemoteAllowed'
Expected = 1
Desc = '远程注册表访问应受限制 (CIS 2.3.10.3)'
Severity = 'High'
}
)

foreach ($check in $regChecks) {
$actualValue = try {
(Get-ItemProperty -Path $check.Path -Name $check.Name -ErrorAction Stop).($check.Name)
} catch { $null }

$auditResults += [PSCustomObject]@{
Category = '注册表安全'
CheckItem = $check.Desc
Expected = "值 = $($check.Expected)"
Actual = if ($null -ne $actualValue) { "值 = $actualValue" } else { '键值不存在' }
Status = if ($actualValue -eq $check.Expected) { 'Compliant' } else { 'Non-Compliant' }
Severity = $check.Severity
Remediation = "Set-ItemProperty -Path '$($check.Path)' -Name $($check.Name) -Value $($check.Expected)"
}
}

# 关键目录权限审计
$sensitivePaths = @(
@{ Path = 'C:\Windows\System32\config'; ExpectedOwner = 'BUILTIN\Administrators'; Desc = 'SAM/SYSTEM 文件目录' },
@{ Path = 'C:\ProgramData'; ExpectedOwner = 'BUILTIN\Administrators'; Desc = '公共程序数据目录' }
)

foreach ($sp in $sensitivePaths) {
if (Test-Path $sp.Path) {
$acl = Get-Acl -Path $sp.Path
$owner = $acl.Owner

$auditResults += [PSCustomObject]@{
Category = '文件权限'
CheckItem = "目录所有者: $($sp.Desc)"
Expected = "所有者 = $($sp.ExpectedOwner) (CIS 13.x)"
Actual = "所有者 = $owner"
Status = if ($owner -match 'Administrators') { 'Compliant' } else { 'Non-Compliant' }
Severity = 'High'
Remediation = "手动检查并修复 $sp.Path 的 NTFS 权限"
}
}
}

$auditResults | Sort-Object Severity, Category, CheckItem
}

系统配置审计的输出结果示例如下:

1
2
3
4
5
6
7
8
9
Category  CheckItem                              Expected                              Actual           Status          Severity
-------- ---------- -------- ------ ------ --------
注册表安全 UAC 必须启用 (CIS 2.3.17.3) 值 = 1 值 = 1 Compliant Critical
防火墙 防火墙配置文件: Domain 已启用 (CIS 9.1.x) Enabled: True Compliant Critical
防火墙 RDP (3389) 入站规则 不应公开暴露 (CIS 9.2.x) 存在允许的入站规则 Non-Compliant Critical
注册表安全 远程注册表访问应受限制 (CIS 2.3.10.3) 值 = 1 值 = 1 Compliant High
文件权限 目录所有者: SAM/SYSTEM 文件目录 所有者 = BUILTIN\Administrators (CIS) 所有者 = ... Compliant High
服务配置 服务: RemoteRegistry 已禁用 (CIS 9.x) 状态: Running Non-Compliant Medium
注册表安全 UAC 提示级别应为"始终通知" (CIS 2.3.17.5) 值 = 2 值 = 5 Non-Compliant Medium

合规报告生成

审计数据的最终价值体现在报告上。下面的函数将前面两个模块的检查结果汇总,生成一份结构化的 HTML 报告,包含合规率统计、风险等级分布、各检查项的详细状态,以及每个不合规项的具体修复建议。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
function New-ComplianceReport {
[CmdletBinding()]
param(
[string]$OutputPath = "$env:USERPROFILE\Desktop\ComplianceReport_$(Get-Date -Format 'yyyyMMdd_HHmmss').html",
[string]$Organization = 'IT 安全团队'
)

Write-Host "正在执行安全基线检查..." -ForegroundColor Cyan
$baselineResults = Invoke-SecurityBaselineCheck

Write-Host "正在执行系统配置审计..." -ForegroundColor Cyan
$configResults = Invoke-SystemConfigAudit

$allResults = @($baselineResults) + @($configResults)
$totalCount = $allResults.Count
$compliantCount = ($allResults | Where-Object { $_.Status -eq 'Compliant' }).Count
$complianceRate = [math]::Round(($compliantCount / $totalCount) * 100, 1)

# 按严重等级统计
$severityStats = $allResults |
Group-Object Severity |
Select-Object Name, Count

# 按类别统计合规率
$categoryStats = $allResults |
Group-Object Category |
ForEach-Object {
$compliant = ($_.Group | Where-Object { $_.Status -eq 'Compliant' }).Count
[PSCustomObject]@{
Category = $_.Name
Total = $_.Count
Compliant = $compliant
NonCompliant = $_.Count - $compliant
ComplianceRate = [math]::Round(($compliant / $_.Count) * 100, 1)
}
}

# 修复建议汇总
$remediations = $allResults |
Where-Object { $_.Status -eq 'Non-Compliant' -and $_.Remediation } |
Select-Object CheckItem, Severity, Remediation |
Sort-Object Severity

Write-Host "正在生成 HTML 报告..." -ForegroundColor Cyan

$severityColorMap = @{
'Critical' = '#dc3545'
'High' = '#fd7e14'
'Medium' = '#ffc107'
'Low' = '#28a745'
}

# 构建 HTML 报告
$htmlSections = @()
$htmlSections += '<!DOCTYPE html><html lang="zh-CN"><head>'
$htmlSections += '<meta charset="UTF-8">'
$htmlSections += '<title>合规审计报告</title>'
$htmlSections += '<style>'
$htmlSections += 'body { font-family: "Microsoft YaHei", sans-serif; margin: 20px; background: #f5f5f5; }'
$htmlSections += 'h1 { color: #333; border-bottom: 3px solid #007bff; padding-bottom: 10px; }'
$htmlSections += '.summary { display: flex; gap: 20px; margin: 20px 0; }'
$htmlSections += '.card { background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); flex: 1; text-align: center; }'
$htmlSections += '.rate { font-size: 48px; font-weight: bold; }'
$htmlSections += 'table { width: 100%; border-collapse: collapse; background: white; margin: 10px 0; }'
$htmlSections += 'th, td { border: 1px solid #ddd; padding: 10px; text-align: left; }'
$htmlSections += 'th { background: #007bff; color: white; }'
$htmlSections += '.compliant { color: #28a745; font-weight: bold; }'
$htmlSections += '.non-compliant { color: #dc3545; font-weight: bold; }'
$htmlSections += '</style></head><body>'
$htmlSections += "<h1>合规审计报告</h1>"
$htmlSections += "<p>组织: $Organization | 生成时间: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') | 计算机: $env:COMPUTERNAME</p>"

# 概览卡片
$rateColor = if ($complianceRate -ge 80) { '#28a745' } elseif ($complianceRate -ge 60) { '#ffc107' } else { '#dc3545' }
$htmlSections += '<div class="summary">'
$htmlSections += "<div class='card'><div class='rate' style='color:$rateColor'>$complianceRate%</div><div>总体合规率</div></div>"
$htmlSections += "<div class='card'><div class='rate' style='color:#333'>$compliantCount / $totalCount</div><div>合规项 / 总检查项</div></div>"

$criticalCount = ($severityStats | Where-Object { $_.Name -eq 'Critical' }).Count
$htmlSections += "<div class='card'><div class='rate' style='color:#dc3545'>$criticalCount</div><div>Critical 不合规项</div></div>"
$htmlSections += '</div>'

# 类别合规率表格
$htmlSections += '<h2>分类合规率</h2><table><tr><th>类别</th><th>总数</th><th>合规</th><th>不合规</th><th>合规率</th></tr>'
foreach ($cat in $categoryStats) {
$catRateColor = if ($cat.ComplianceRate -ge 80) { 'compliant' } else { 'non-compliant' }
$htmlSections += "<tr><td>$($cat.Category)</td><td>$($cat.Total)</td><td>$($cat.Compliant)</td><td>$($cat.NonCompliant)</td><td class='$catRateColor'>$($cat.ComplianceRate)%</td></tr>"
}
$htmlSections += '</table>'

# 详细结果表格
$htmlSections += '<h2>详细检查结果</h2><table><tr><th>类别</th><th>检查项</th><th>期望值</th><th>实际值</th><th>状态</th><th>严重等级</th></tr>'
foreach ($item in ($allResults | Sort-Object Severity, Category)) {
$statusClass = if ($item.Status -eq 'Compliant') { 'compliant' } else { 'non-compliant' }
$sevColor = $severityColorMap[$item.Severity]
$htmlSections += "<tr><td>$($item.Category)</td><td>$($item.CheckItem)</td><td>$($item.Expected)</td><td>$($item.Actual)</td><td class='$statusClass'>$($item.Status)</td><td style='color:$sevColor'>$($item.Severity)</td></tr>"
}
$htmlSections += '</table>'

# 修复建议
if ($remediations) {
$htmlSections += '<h2>修复建议</h2><table><tr><th>检查项</th><th>严重等级</th><th>修复命令</th></tr>'
foreach ($rem in $remediations) {
$remSevColor = $severityColorMap[$rem.Severity]
$htmlSections += "<tr><td>$($rem.CheckItem)</td><td style='color:$remSevColor'>$($rem.Severity)</td><td><code>$($rem.Remediation)</code></td></tr>"
}
$htmlSections += '</table>'
}

$htmlSections += '</body></html>'

$htmlSections -join "`n" | Out-File -FilePath $OutputPath -Encoding UTF8

# 控制台输出汇总
[PSCustomObject]@{
ReportPath = $OutputPath
TotalChecks = $totalCount
CompliantItems = $compliantCount
NonCompliantItems = $totalCount - $compliantCount
ComplianceRate = "$complianceRate%"
CriticalIssues = $criticalCount
GeneratedAt = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
}
}

运行报告生成函数后,控制台输出汇总信息,同时在桌面生成 HTML 报告文件:

1
2
3
4
5
6
7
8
9
10
11
正在执行安全基线检查...
正在执行系统配置审计...
正在生成 HTML 报告...

ReportPath : C:\Users\Admin\Desktop\ComplianceReport_20260327_100530.html
TotalChecks : 24
CompliantItems : 17
NonCompliantItems: 7
ComplianceRate : 70.8%
CriticalIssues : 2
GeneratedAt : 2026-03-27 10:05:30

注意事项

  1. 管理员权限要求:安全基线检查和系统配置审计需要以管理员身份运行 PowerShell。账户策略、审核策略和注册表部分操作均涉及系统级配置,普通用户权限无法获取完整信息。

  2. CIS Benchmark 版本对应:本文中的检查项基于 CIS Microsoft Windows Server Benchmark v3.0 编写。不同版本的 Benchmark 可能存在差异,建议根据实际部署的基线版本调整 Expected 字段中的判定标准。

  3. 生产环境慎用修复命令:报告中给出的修复命令(Remediation 字段)仅供参考。直接在生产环境中执行这些命令可能导致服务中断(如禁用 RemoteRegistry 可能影响远程管理工具),务必先在测试环境验证。

  4. 远程审计扩展:上述函数目前仅审计本地计算机。如需批量审计多台服务器,可以结合 Invoke-Command -ComputerName $servers -ScriptBlock { ... } 实现,但需注意 WinRM 服务的连通性和防火墙配置。

  5. HTML 报告编码问题Out-File -Encoding UTF8 在 Windows PowerShell 5.1 中会生成带 BOM 的 UTF-8 文件,在浏览器中通常能正确显示。如果遇到中文乱码,可尝试使用 [System.IO.File]::WriteAllText($OutputPath, $htmlContent, [System.Text.Encoding]::UTF8) 替代。

  6. 定期审计与趋势跟踪:建议将合规审计脚本纳入计划任务(schtasks),每周或每月自动执行一次,并将报告归档保存。通过对比不同时期的合规率变化,可以追踪安全态势的改善趋势,为管理层的合规决策提供数据支撑。

PowerShell 技能连载 - Excel 自动化报表

适用于 PowerShell 5.1 及以上版本

在企业环境中,Excel 是最通用的数据交换格式之一。无论是系统运维的周报、安全审计的统计报告,还是业务分析的汇总数据,Excel 报表几乎无处不在。然而,手动从多个数据源收集信息、格式化表格、生成图表并分发报表,这个过程既耗时又容易出错。

ImportExcel 模块的出现彻底改变了这一局面。它是一个纯 PowerShell 实现的 .xlsx 文件操作库,无需安装 Microsoft Excel 即可完成读取、写入、图表生成、条件格式等操作。这意味着你可以在 Windows Server Core 甚至 Linux 服务器上运行报表生成脚本,完全不依赖 Office 组件。

本文将通过三个递进的场景,带你掌握从基础数据导入导出到自动化报表分发的完整工作流。无论你是需要将系统日志转换为可读报表,还是要定期向管理层发送格式化的运维报告,这些技巧都能帮你节省大量时间。

数据导入导出

ImportExcel 最基础的能力是读取和写入 Excel 文件。下面的示例展示了如何读取一个包含服务器资产清单的 Excel 文件,按状态筛选数据,并将结果导出为新的报表文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
# 安装 ImportExcel 模块(仅需执行一次)
Install-Module -Name ImportExcel -Scope CurrentUser -Force

# 读取服务器资产清单
$assetFile = "C:\Reports\ServerAssets.xlsx"
$allServers = Import-Excel -Path $assetFile -WorksheetName "资产清单"

Write-Host "共读取 $($allServers.Count) 条服务器记录"

# 筛选在线服务器并按内存使用率排序
$onlineServers = $allServers |
Where-Object { $_.状态 -eq '在线' } |
Sort-Object -Property { [double]$_.内存使用率 } -Descending

# 计算汇总统计
$summary = [PSCustomObject]@{
总数 = $allServers.Count
在线数 = ($allServers | Where-Object { $_.状态 -eq '在线' }).Count
离线数 = ($allServers | Where-Object { $_.状态 -eq '离线' }).Count
平均内存 = '{0:N1}%' -f (($onlineServers | ForEach-Object { [double]$_.内存使用率 } | Measure-Object -Average).Average)
}

# 导出筛选结果和汇总到新 Excel 文件
$outputPath = "C:\Reports\OnlineServers_$(Get-Date -Format 'yyyyMMdd').xlsx"

# 使用自动表格样式导出
$onlineServers | Select-Object 主机名, IP地址, 操作系统, CPU核数, 内存使用率, 磁盘剩余, 最后响应时间 |
Export-Excel -Path $outputPath -WorksheetName "在线服务器" -TableName "ServerTable" -TableStyle Light1 -AutoSize

$summary | Export-Excel -Path $outputPath -WorksheetName "汇总" -StartRow 1

Write-Host "报表已导出到: $outputPath"
1
2
共读取 156 条服务器记录
报表已导出到: C:\Reports\OnlineServers_20260121.xlsx

导出的 Excel 文件包含两个工作表:「在线服务器」以表格形式展示筛选后的服务器详情,「汇总」提供一目了然的统计数字。-AutoSize 参数自动调整列宽,-TableStyle 参数应用内置表格样式,让报表专业且易读。

图表与格式化

纯数字的报表缺少直观性。ImportExcel 支持通过 Add-ExcelChart 命令在 Excel 中创建柱状图、饼图、折线图等多种图表,还能使用 Set-ExcelRange 添加条件格式,让关键数据一目了然。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# 准备示例数据:各区域服务器状态统计
$regionData = @(
[PSCustomObject]@{ 区域 = '华东'; 在线 = 45; 离线 = 3; 维护中 = 2 }
[PSCustomObject]@{ 区域 = '华北'; 在线 = 38; 离线 = 5; 维护中 = 1 }
[PSCustomObject]@{ 区域 = '华南'; 在线 = 32; 离线 = 2; 维护中 = 3 }
[PSCustomObject]@{ 区域 = '西南'; 在线 = 15; 离线 = 1; 维护中 = 1 }
[PSCustomObject]@{ 区域 = '东北'; 在线 = 8; 离线 = 0; 维护中 = 0 }
)

$chartFile = "C:\Reports\RegionStatus_$(Get-Date -Format 'yyyyMMdd').xlsx"

# 导出数据并附加柱状图
$excel = $regionData | Export-Excel -Path $chartFile -WorksheetName "区域统计" -TableName "RegionTable" -PassThru

$sheet = $excel.Workbook.Worksheets["区域统计"]

# 添加簇状柱状图
Add-ExcelChart -Worksheet $sheet -ChartType ColumnClustered `
-XRange "A2:A6" `
-YRange "B2:D6" `
-Title "各区域服务器状态分布" `
-SeriesHeader "在线", "离线", "维护中" `
-Width 800 -Height 400 `
-Row 8 -Column 1

# 为在线列添加数据条条件格式
Set-ExcelRange -Worksheet $sheet -Range "B2:B6" -ConditionalFormat {
param($cond)
$cond.SetDataBar(
[OfficeOpenXml.ConditionalFormatting.ExcelBarColor]::Green
)
}

# 为离线列添加高亮条件格式(值大于 3 时显示红色)
Set-ExcelRange -Worksheet $sheet -Range "C2:C6" -ConditionalFormat {
param($cond)
$rule = $cond.AddGreaterThanOrEqual()
$rule.Formula = "3"
$rule.Style.Fill.BackgroundColor.Color = [System.Drawing.Color]::LightPink
}

# 设置标题行样式
Set-ExcelRange -Worksheet $sheet -Range "A1:G1" -Bold -FontSize 12 -BackgroundColor ([System.Drawing.Color]::SteelBlue) -FontColor ([System.Drawing.Color]::White)

Close-ExcelPackage -ExcelPackage $excel -Save

Write-Host "图表报表已生成: $chartFile"
1
图表报表已生成: C:\Reports\RegionStatus_20260121.xlsx

这段代码生成了一个包含簇状柱状图的 Excel 报表。在线服务器数量列带有绿色数据条,直观反映各区域的容量规模;离线数量超过阈值的单元格自动标红,方便运维人员快速定位问题区域。-PassThru 参数返回 Excel 包对象,允许我们在保存前进行更多自定义操作。

自动化报表生成

将前面的技术整合起来,就可以构建一个完整的自动化报表工作流:从多个数据源汇总信息,生成多 Sheet 报表,并通过邮件自动分发。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
function New-WeeklyOpsReport {
<#
.SYNOPSIS
生成运维周报并自动发送邮件
#>
param(
[string]$OutputPath = "C:\Reports\WeeklyOps_$(Get-Date -Format 'yyyyMMdd').xlsx",
[string[]]$Recipients = @("ops-team@company.com", "manager@company.com")
)

$reportDate = Get-Date -Format "yyyy年MM月dd日"

# --- 数据采集 ---
# 模拟从各系统采集的数据(实际环境中可替换为 API 调用、数据库查询等)
$eventStats = @(
[PSCustomObject]@{ 类型 = '错误'; 本周 = 23; 上周 = 31; 趋势 = '下降' }
[PSCustomObject]@{ 类型 = '警告'; 本周 = 156; 上周 = 142; 趋势 = '上升' }
[PSCustomObject]@{ 类型 = '信息'; 本周 = 8432; 上周 = 7891; 趋势 = '上升' }
)

$patchCompliance = @(
[PSCustomObject]@{ 分类 = '安全补丁'; 合格率 = '94.2%'; 待处理 = 12 }
[PSCustomObject]@{ 分类 = '功能更新'; 合格率 = '87.5%'; 待处理 = 28 }
[PSCustomObject]@{ 分类 = '驱动程序'; 合格率 = '96.1%'; 待处理 = 5 }
)

# --- 生成 Excel 报表 ---
$excel = Export-Excel -Path $OutputPath -PassThru

# Sheet 1:概览摘要
$overview = [PSCustomObject]@{
报告日期 = $reportDate
报告周期 = "$(Get-Date (Get-Date).AddDays(-7) -Format 'MM/dd') - $(Get-Date -Format 'MM/dd')"
服务器总数 = 156
在线率 = '97.4%'
平均响应时间 = '23ms'
}
$overview | Export-Excel -ExcelPackage $excel -WorksheetName "概览" -AutoSize -BoldTopRow

# Sheet 2:事件统计(含图表)
$eventSheet = $eventStats | Export-Excel -ExcelPackage $excel -WorksheetName "事件统计" -TableName "EventTable" -PassThru
Add-ExcelChart -Worksheet $eventSheet.Workbook.Worksheets["事件统计"] `
-ChartType BarClustered `
-XRange "A2:A4" -YRange "B2:C4" `
-Title "事件趋势对比(本周 vs 上周)" `
-SeriesHeader "本周", "上周" `
-Row 6 -Column 1 -Width 700 -Height 350

# Sheet 3:补丁合规率
$patchCompliance | Export-Excel -ExcelPackage $excel -WorksheetName "补丁合规" -TableName "PatchTable" -AutoSize -NumberFormat '0.0%'

# 保存并关闭
Close-ExcelPackage -ExcelPackage $excel -Save

Write-Host "报表已生成: $OutputPath"

# --- 发送邮件 ---
$smtpServer = "smtp.company.com"
$from = "ops-report@company.com"
$subject = "[自动化] 运维周报 - $reportDate"

$body = @"
各位好,

本周运维报表已自动生成,请查阅附件。

主要指标:
- 服务器在线率:97.4%
- 安全补丁合规率:94.2%
- 错误事件:23 起(较上周下降 25.8%)

此邮件由自动化系统发送,请勿直接回复。
"@

Send-MailMessage -From $from -To $Recipients -Subject $subject -Body $body -SmtpServer $smtpServer -Attachments $OutputPath -Encoding UTF8

Write-Host "报表已发送至: $($Recipients -join ', ')"
}

# 执行周报生成
New-WeeklyOpsReport
1
2
报表已生成: C:\Reports\WeeklyOps_20260121.xlsx
报表已发送至: ops-team@company.com, manager@company.com

这个函数实现了一个完整的自动化报表管道。它将数据采集、报表生成和邮件分发整合到一个可复用的函数中。三个 Sheet 分别承载概览摘要、事件趋势图表和补丁合规明细,满足不同受众的需求。结合 Windows 任务计划程序或 Linux 的 cron,就可以实现真正的无人值守周报。

注意事项

  • ImportExcel 模块依赖 .NET 的 EPPlus 库,无需安装 Microsoft Excel,可以在 Server Core 和 Linux 环境中运行,但需要确保 PowerShell 版本满足 5.1 及以上。
  • 使用 Export-Excel-PassThru 参数时,务必在操作完成后调用 Close-ExcelPackage -Save 保存文件,否则修改不会写入磁盘,且可能造成文件句柄泄漏。
  • Add-ExcelChart-XRange-YRange 参数引用的是 Excel 单元格范围,如果数据行数动态变化,需要先计算范围字符串再传入,避免图表数据截断。
  • 条件格式的设置使用 EPPlus 的原生 API,语法与 VBA 中的条件格式对象模型有差异,编写时建议参考 EPPlus 官方文档。
  • 邮件发送部分使用 Send-MailMessage,该 cmdlet 在 PowerShell 7.x 中标记为已过时,生产环境建议使用 MailKit 库或调用 REST API(如 Microsoft Graph)来发送邮件。
  • 处理大型 Excel 文件(超过 10 万行)时,建议使用 -NoNumberFormat-AsText 参数关闭自动类型推断,可以显著提升导入导出的性能。

PowerShell 技能连载 - 年终报告自动生成

适用于 PowerShell 5.1 及以上版本

每到年底,IT 部门都需要向管理层提交各类运维报告:服务器可用性统计、安全事件汇总、资源使用趋势分析、成本核算等。这些数据往往分散在事件日志、CSV 文件、数据库、REST API 等多个来源中,手动汇总整理费时费力,而且容易出现遗漏和统计口径不一致的问题。

借助 PowerShell 强大的数据采集与处理能力,我们可以编写脚本自动从多个数据源采集原始数据,按照预定义的维度进行聚合统计,再结合 HTML 模板引擎生成包含表格、图表和趋势线的高质量报告。整个过程无需手动干预,一条命令即可产出一份数据完整、格式专业的年终总结。

本文将围绕多源数据采集与聚合、HTML 报告渲染(含内联图表)、以及报告分发与归档三个核心环节,展示如何用 PowerShell 构建一套可复用的年终报告自动生成流水线。

多源数据采集与聚合

年终报告的第一步是从多个数据源采集原始数据并进行统一聚合。下面的脚本演示如何从 Windows 事件日志、CSV 日志文件和自定义数据结构中提取关键指标,生成结构化的年度汇总数据集。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
function Get-YearEndReportData {
<#
.SYNOPSIS
从多个数据源采集并聚合年终报告所需的数据
.PARAMETER Year
统计年份,默认为当前年份
#>
param(
[int]$Year = (Get-Date).Year
)

$startDate = Get-Date -Year $Year -Month 1 -Day 1
$endDate = Get-Date -Year $Year -Month 12 -Day 31

# ---- 1. 从 Windows 事件日志采集安全事件统计 ----
$securityEvents = @()
try {
$logonEvents = Get-WinEvent -FilterHashtable @{
LogName = 'Security'
Id = 4624, 4625
StartTime = $startDate
EndTime = $endDate
} -ErrorAction SilentlyContinue

$logonSuccess = ($logonEvents | Where-Object Id -eq 4624 | Measure-Object).Count
$logonFailed = ($logonEvents | Where-Object Id -eq 4625 | Measure-Object).Count

$securityEvents = [PSCustomObject]@{
成功登录 = $logonSuccess
失败登录 = $logonFailed
登录失败率 = if ($logonSuccess + $logonFailed -gt 0) {
[math]::Round($logonFailed / ($logonSuccess + $logonFailed) * 100, 2)
} else { 0 }
}
} catch {
$securityEvents = [PSCustomObject]@{
成功登录 = 0; 失败登录 = 0; 登录失败率 = 0
}
}

# ---- 2. 从系统事件日志采集可用性数据 ----
$rebootEvents = Get-WinEvent -FilterHashtable @{
LogName = 'System'
Id = 1074, 6008
StartTime = $startDate
EndTime = $endDate
} -ErrorAction SilentlyContinue

$plannedReboots = ($rebootEvents | Where-Object Id -eq 1074 | Measure-Object).Count
$unexpectedShutdowns = ($rebootEvents | Where-Object Id -eq 6008 | Measure-Object).Count

# ---- 3. 从 CSV 日志文件采集资源使用数据 ----
$csvPath = ".\server-metrics-$Year.csv"
$monthlyStats = @()

if (Test-Path $csvPath) {
$metrics = Import-Csv -Path $csvPath -Encoding UTF8
$monthlyStats = $metrics | Group-Object { $_.Date.Substring(0, 7) } | ForEach-Object {
$avgCpu = [math]::Round(($_.Group | Measure-Object -Property CPU -Average).Average, 1)
$avgMem = [math]::Round(($_.Group | Measure-Object -Property Memory -Average).Average, 1)
$maxDisk = [math]::Round(($_.Group | Measure-Object -Property DiskUsage -Maximum).Maximum, 1)

[PSCustomObject]@{
月份 = $_.Name
平均CPU使用率 = "$avgCpu%"
平均内存使用率 = "$avgMem%"
最大磁盘使用率 = "$maxDisk%"
}
}
} else {
# 模拟数据用于演示
1..12 | ForEach-Object {
$month = "{0:D2}" -f $_
$monthlyStats += [PSCustomObject]@{
月份 = "$Year-$month"
平均CPU使用率 = "$([math]::Round((Get-Random -Min 25 -Max 75) + (Get-Random -Min 1 -Max 99) / 100, 1))%"
平均内存使用率 = "$([math]::Round((Get-Random -Min 40 -Max 85) + (Get-Random -Min 1 -Max 99) / 100, 1))%"
最大磁盘使用率 = "$([math]::Round((Get-Random -Min 50 -Max 90) + (Get-Random -Min 1 -Max 99) / 100, 1))%"
}
}
}

# ---- 4. 汇总输出 ----
[PSCustomObject]@{
报告年份 = $Year
数据周期 = "$($startDate.ToString('yyyy-MM-dd')) 至 $($endDate.ToString('yyyy-MM-dd'))"
安全事件统计 = $securityEvents
计划重启次数 = $plannedReboots
非计划关机次数 = $unexpectedShutdowns
月度资源统计 = $monthlyStats
生成时间 = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
}
}

# 采集 2025 年度报告数据
$reportData = Get-YearEndReportData -Year 2025

Write-Host "报告年份: $($reportData.报告年份)" -ForegroundColor Cyan
Write-Host "数据周期: $($reportData.数据周期)" -ForegroundColor Cyan
Write-Host "安全事件: 成功登录 $($reportData.安全事件统计.成功登录) 次, 失败登录 $($reportData.安全事件统计.失败登录) 次" -ForegroundColor Yellow
Write-Host "月度数据条数: $($reportData.月度资源统计.Count)" -ForegroundColor Green

执行结果示例:

1
2
3
4
报告年份: 2025
数据周期: 2025-01-01 至 2025-12-31
安全事件: 成功登录 15238 次, 失败登录 347 次
月度数据条数: 12

生成 HTML 报告(含 CSS 样式与内联图表)

数据采集完成后,下一步是将结构化数据渲染为美观的 HTML 报告。下面的脚本使用 PowerShell 的 here-string 构建 HTML 模板,内嵌 CSS 样式,并通过纯 CSS 的柱状图直观展示月度趋势数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
function New-YearEndHtmlReport {
<#
.SYNOPSIS
将年度报告数据渲染为带样式和内联图表的 HTML 文件
.PARAMETER ReportData
由 Get-YearEndReportData 返回的数据对象
.PARAMETER OutputPath
HTML 报告输出路径
#>
param(
[Parameter(Mandatory)]
$ReportData,
[string]$OutputPath = ".\YearEnd-Report-$($ReportData.报告年份).html"
)

# 构建月度资源表格行
$tableRows = $ReportData.月度资源统计 | ForEach-Object {
" <tr><td>$($_.月份)</td><td>$($_.平均CPU使用率)</td><td>$($_.平均内存使用率)</td><td>$($_.最大磁盘使用率)</td></tr>"
}
$tableRowsHtml = $tableRows -join "`n"

# 构建简易柱状图(纯 CSS 实现,无需 JavaScript 库)
$chartBars = $ReportData.月度资源统计 | ForEach-Object {
$cpuVal = [int]($_.平均CPU使用率 -replace '%', '')
$barHeight = $cpuVal * 2
$label = $_.月份.Substring(5)
" <div class='bar-col'><div class='bar' style='height:${barHeight}px'></div><div class='bar-label'>$label</div></div>"
}
$chartHtml = $chartBars -join "`n"

$sec = $ReportData.安全事件统计

# HTML 报告内容
$htmlContent = @"
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>IT 运维年度报告 - $($ReportData.报告年份)</title>
<style>
body { font-family: "Microsoft YaHei", "Segoe UI", sans-serif; margin: 40px; background: #f5f7fa; color: #333; }
h1 { color: #2c3e50; border-bottom: 3px solid #3498db; padding-bottom: 10px; }
h2 { color: #2c3e50; margin-top: 30px; }
.summary-box { display: flex; gap: 20px; margin: 20px 0; flex-wrap: wrap; }
.stat-card { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); min-width: 200px; }
.stat-card h3 { margin: 0 0 10px 0; color: #7f8c8d; font-size: 14px; }
.stat-card .value { font-size: 28px; font-weight: bold; color: #2c3e50; }
table { border-collapse: collapse; width: 100%; background: white; box-shadow: 0 2px 8px rgba(0,0,0,0.1); border-radius: 8px; overflow: hidden; }
th { background: #3498db; color: white; padding: 12px; text-align: left; }
td { padding: 10px 12px; border-bottom: 1px solid #ecf0f1; }
tr:hover { background: #ebf5fb; }
.chart-container { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); margin: 20px 0; }
.chart-area { display: flex; align-items: flex-end; gap: 8px; height: 200px; padding: 10px 0; }
.bar-col { display: flex; flex-direction: column; align-items: center; flex: 1; }
.bar { background: linear-gradient(to top, #3498db, #2ecc71); border-radius: 4px 4px 0 0; width: 100%; max-width: 40px; }
.bar-label { font-size: 11px; margin-top: 5px; color: #7f8c8d; }
.footer { margin-top: 30px; color: #95a5a6; font-size: 12px; text-align: center; }
</style>
</head>
<body>
<h1>IT 运维年度报告 - $($ReportData.报告年份)</h1>
<p>报告周期:$($ReportData.数据周期) &nbsp;|&nbsp; 生成时间:$($ReportData.生成时间)</p>

<h2>核心指标概览</h2>
<div class="summary-box">
<div class="stat-card"><h3>成功登录</h3><div class="value">$($sec.成功登录)</div></div>
<div class="stat-card"><h3>失败登录</h3><div class="value" style="color:#e74c3c">$($sec.失败登录)</div></div>
<div class="stat-card"><h3>登录失败率</h3><div class="value">$($sec.登录失败率)%</div></div>
<div class="stat-card"><h3>计划重启</h3><div class="value">$($ReportData.计划重启次数)</div></div>
<div class="stat-card"><h3>非计划关机</h3><div class="value" style="color:#e67e22">$($ReportData.非计划关机次数)</div></div>
</div>

<h2>月度 CPU 使用趋势</h2>
<div class="chart-container">
<div class="chart-area">
$chartHtml
</div>
</div>

<h2>月度资源使用明细</h2>
<table>
<thead><tr><th>月份</th><th>平均 CPU 使用率</th><th>平均内存使用率</th><th>最大磁盘使用率</th></tr></thead>
<tbody>
$tableRowsHtml
</tbody>
</table>

<div class="footer">本报告由 PowerShell 自动生成 | $($ReportData.生成时间)</div>
</body>
</html>
"@

$htmlContent | Out-File -FilePath $OutputPath -Encoding UTF8
Write-Host "HTML 报告已生成: $OutputPath" -ForegroundColor Green
return (Resolve-Path $OutputPath).Path
}

# 生成 HTML 报告
$reportPath = New-YearEndHtmlReport -ReportData $reportData

执行结果示例:

1
HTML 报告已生成: /Users/user/reports/YearEnd-Report-2025.html

生成的 HTML 文件包含卡片式核心指标概览、纯 CSS 柱状图展示月度 CPU 趋势、以及带悬停效果的资源使用明细表格。整个报告无需任何 JavaScript 依赖,打开即可查看完整内容,也可通过浏览器”另存为 PDF”直接导出。

报告分发与归档

报告生成后,还需要将其自动发送给相关干系人并归档保存。下面的脚本实现了邮件分发、PDF 转换和按月份归档的完整流程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
function Send-YearEndReport {
<#
.SYNOPSIS
通过邮件发送年终报告并归档到指定目录
.PARAMETER ReportPath
HTML 报告文件路径
.PARAMETER Recipients
收件人邮箱地址数组
.PARAMETER ArchiveBasePath
归档根目录路径
#>
param(
[Parameter(Mandatory)]
[string]$ReportPath,
[string[]]$Recipients = @("manager@company.com", "it-ops@company.com"),
[string]$ArchiveBasePath = ".\Reports\Archive"
)

# ---- 1. 创建归档目录结构 ----
$year = (Get-Date).Year
$archiveDir = Join-Path $ArchiveBasePath "$year"
if (-not (Test-Path $archiveDir)) {
New-Item -Path $archiveDir -ItemType Directory -Force | Out-Null
Write-Host "已创建归档目录: $archiveDir" -ForegroundColor Cyan
}

# 复制报告到归档目录(带时间戳)
$timestamp = Get-Date -Format 'yyyyMMdd-HHmmss'
$archiveName = "YearEnd-Report-$year-$timestamp.html"
$archivePath = Join-Path $archiveDir $archiveName
Copy-Item -Path $ReportPath -Destination $archivePath
Write-Host "报告已归档: $archivePath" -ForegroundColor Green

# ---- 2. 生成 PDF 副本(利用 Chrome 无头模式) ----
$pdfPath = $archivePath -replace '\.html$', '.pdf'
$chromePaths = @(
"${env:ProgramFiles}\Google\Chrome\Application\chrome.exe",
"${env:ProgramFiles(x86)}\Google\Chrome\Application\chrome.exe",
"${env:LOCALAPPDATA}\Google\Chrome\Application\chrome.exe"
)
$chrome = $chromePaths | Where-Object { Test-Path $_ } | Select-Object -First 1

if ($chrome) {
$pdfArgs = @(
"--headless",
"--disable-gpu",
"--no-sandbox",
"--print-to-pdf=$pdfPath",
"--print-to-pdf-no-header",
$archivePath
)
Start-Process -FilePath $chrome -ArgumentList $pdfArgs -Wait -NoNewWindow
Write-Host "PDF 已生成: $pdfPath" -ForegroundColor Green
} else {
Write-Host "未找到 Chrome,跳过 PDF 生成。可手动在浏览器中另存为 PDF。" -ForegroundColor Yellow
$pdfPath = $null
}

# ---- 3. 发送邮件 ----
$mailParams = @{
From = "it-automation@company.com"
To = $Recipients
Subject = "[IT 运维] $year 年度报告"
Body = "各位好,`n`n附件为 $year 年度 IT 运维报告。`n`n报告由系统自动生成,如有疑问请联系 IT 运维团队。`n`n祝好"
SmtpServer = "smtp.company.com"
Port = 587
Encoding = [System.Text.Encoding]::UTF8
}

# 添加 HTML 报告作为附件
$mailParams["Attachments"] = @($archivePath)
if ($pdfPath -and (Test-Path $pdfPath)) {
$mailParams["Attachments"] += $pdfPath
}

try {
Send-MailMessage @mailParams -ErrorAction Stop
Write-Host "邮件已发送至: $($Recipients -join ', ')" -ForegroundColor Green
} catch {
Write-Host "邮件发送失败: $($_.Exception.Message)" -ForegroundColor Red
Write-Host "请检查 SMTP 配置或网络连接" -ForegroundColor Yellow
}

# ---- 4. 生成归档索引 ----
$indexEntry = [PSCustomObject]@{
报告名称 = $archiveName
生成时间 = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
文件大小KB = [math]::Round((Get-Item $archivePath).Length / 1KB, 1)
归档路径 = $archivePath
PDF路径 = $pdfPath
}

$indexPath = Join-Path $archiveDir "index.csv"
if (Test-Path $indexPath) {
$indexEntry | Export-Csv -Path $indexPath -Append -NoTypeInformation -Encoding UTF8
} else {
$indexEntry | Export-Csv -Path $indexPath -NoTypeInformation -Encoding UTF8
}
Write-Host "归档索引已更新: $indexPath" -ForegroundColor Green

return $indexEntry
}

# 执行完整的分发与归档流程
$result = Send-YearEndReport -ReportPath $reportPath `
-Recipients @("cto@company.com", "it-manager@company.com") `
-ArchiveBasePath ".\Reports\Archive"

$result | Format-List

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
已创建归档目录: .\Reports\Archive\2025
报告已归档: .\Reports\Archive\2025\YearEnd-Report-2025-20251229-143022.html
PDF 已生成: .\Reports\Archive\2025\YearEnd-Report-2025-20251229-143022.pdf
邮件已发送至: cto@company.com, it-manager@company.com
归档索引已更新: .\Reports\Archive\2025\index.csv

报告名称 : YearEnd-Report-2025-20251229-143022.html
生成时间 : 2025-12-29 14:30:22
文件大小KB : 4.2
归档路径 : .\Reports\Archive\2025\YearEnd-Report-2025-20251229-143022.html
PDF路径 : .\Reports\Archive\2025\YearEnd-Report-2025-20251229-143022.pdf

注意事项

  1. 事件日志权限:读取 Windows 安全日志(Security Log)需要管理员权限。在生产环境中运行采集脚本时,应以提升权限启动 PowerShell,或者通过计划任务配置为以 SYSTEM 账户运行,确保能正常读取所有事件源。

  2. 大数据量性能:如果事件日志条目超过数十万条,直接使用 Get-WinEvent 可能占用大量内存。建议按月份分批查询,或使用 -MaxEvents 参数配合 -FilterHashtable 的时间范围分段采集,避免一次性加载过多数据导致内存溢出。

  3. CSV 数据格式约定:资源指标 CSV 文件应包含 DateCPUMemoryDiskUsage 等标准列名。如果监控系统导出的字段名不同,需在采集函数中添加字段映射逻辑,或使用 Select-Object 的计算属性进行重命名。

  4. SMTP 认证配置:示例中的 Send-MailMessage 使用了简化的参数。在生产环境中,SMTP 服务器通常需要身份认证和 TLS 加密。建议将凭据存储在 Windows 凭据管理器中,通过 Get-StoredCredential 或环境变量获取,不要在脚本中硬编码密码。

  5. Chrome 无头模式依赖:PDF 转换功能依赖 Chrome 或 Chromium 浏览器。在无 GUI 的 Windows Server 上,需要安装 Chrome 并确保无头模式可以正常运行。如果无法安装 Chrome,也可以考虑使用 wkhtmltopdf 等轻量级替代方案,或者直接使用 ConvertTo-PDF 模块。

  6. 报告模板维护:HTML 报告模板中的样式和结构应与企业管理规范保持一致。建议将模板抽取为独立的 HTML 文件,通过 PowerShell 的 -replace 操作符替换占位符,而非在脚本中硬编码整个模板。这样设计人员可以独立调整样式,开发人员只需关注数据填充逻辑。

PowerShell 技能连载 - 节日自动化与年度总结

适用于 PowerShell 5.1 及以上版本

每到年底,系统管理员面临着大量收尾工作:汇总全年的运维数据、归档陈旧日志、清理过期文件、检查系统健康状态,还要为来年的自动化计划做准备。这些任务如果逐一手动完成,往往要耗费数天时间。而通过 PowerShell 脚本,可以将这些重复性工作编排成可一键执行的自动化流程,大幅缩短收尾周期。

更重要的是,年度总结不仅是对过去一年工作的回顾,更是为来年制定计划的数据基础。通过脚本化汇总,可以确保数据的准确性和一致性——每年生成相同格式的报告,方便横向对比,发现趋势。将枯燥的数据整理交给脚本,管理员才能把精力集中在分析和决策上。

本文将从年度运维数据汇总、数据归档与清理、来年自动化准备三个维度,展示如何用 PowerShell 高效完成年末收尾工作。

年度运维数据汇总

年终总结的第一步是收集全年的关键运维指标。下面的脚本从 Windows 事件日志、系统运行时间等数据源中提取统计信息,生成一份结构化的年度运维报告。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
# 年度运维数据汇总脚本
function Get-YearEndOpsSummary {
param(
[int]$Year = (Get-Date).Year
)

$startDate = Get-Date -Year $Year -Month 1 -Day 1
$endDate = Get-Date -Year $Year -Month 12 -Day 31 -Hour 23 -Minute 59 -Second 59

Write-Host "正在汇总 $Year 年度运维数据..." -ForegroundColor Cyan
Write-Host ("=" * 50)

# 1. 事件日志统计
Write-Host "`n[事件日志统计]" -ForegroundColor Yellow

$logStats = @{}
$logNames = @('System', 'Application', 'Security')

foreach ($logName in $logNames) {
try {
$events = Get-WinEvent -LogName $logName |
Where-Object { $_.TimeCreated -ge $startDate -and $_.TimeCreated -le $endDate }

$logStats[$logName] = [PSCustomObject]@{
Total = $events.Count
Critical = ($events | Where-Object Level -eq 1).Count
Error = ($events | Where-Object Level -eq 2).Count
Warning = ($events | Where-Object Level -eq 3).Count
Information = ($events | Where-Object Level -eq 4).Count
}
Write-Host " $logName : $($logStats[$logName].Total) 条"
} catch {
Write-Host " $logName : 无法读取或无记录" -ForegroundColor DarkGray
}
}

# 2. 服务器可用性统计
Write-Host "`n[服务器可用性统计]" -ForegroundColor Yellow

$reboots = Get-WinEvent -LogName System -ErrorAction SilentlyContinue |
Where-Object {
$_.TimeCreated -ge $startDate -and $_.TimeCreated -le $endDate -and
$_.Id -in 1074, 6006, 6008, 41
}

$uptimeSpan = (Get-Date) - (Get-CimInstance Win32_OperatingSystem).LastBootUpTime
$totalDaysInYear = ($endDate - $startDate).TotalDays

# 3. 磁盘使用趋势
Write-Host "`n[磁盘使用情况]" -ForegroundColor Yellow

$diskInfo = Get-CimInstance Win32_LogicalDisk -Filter 'DriveType=3' |
ForEach-Object {
$usedGB = [math]::Round(($_.Size - $_.FreeSpace) / 1GB, 2)
$freeGB = [math]::Round($_.FreeSpace / 1GB, 2)
$totalGB = [math]::Round($_.Size / 1GB, 2)
$usedPct = [math]::Round(($usedGB / $totalGB) * 100, 1)

[PSCustomObject]@{
Drive = $_.DeviceID
UsedGB = $usedGB
FreeGB = $freeGB
TotalGB = $totalGB
UsedPct = "$usedPct%"
}
}

$diskInfo | Format-Table -AutoSize

# 4. 汇总报告对象
$report = [PSCustomObject]@{
Year = $Year
GeneratedAt = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
EventLogs = $logStats
RebootCount = $reboots.Count
CurrentUptime = "$([math]::Floor($uptimeSpan.TotalDays)) 天 $($uptimeSpan.Hours) 小时"
DiskStatus = $diskInfo
}

# 导出报告
$reportPath = Join-Path $env:USERPROFILE "Desktop\OpsSummary-$Year.json"
$report | ConvertTo-Json -Depth 5 | Out-File $reportPath -Encoding UTF8
Write-Host "`n报告已保存至:$reportPath" -ForegroundColor Green

return $report
}

# 执行年度汇总
Get-YearEndOpsSummary -Year 2025

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
正在汇总 2025 年度运维数据...
==================================================

[事件日志统计]
System : 48256 条
Application : 31420 条
Security : 128750 条

[服务器可用性统计]

[磁盘使用情况]
Drive UsedGB FreeGB TotalGB UsedPct
----- ------ ------ ------- -------
C: 186.42 63.58 250.00 74.6%
D: 412.30 587.70 1000.00 41.2%

报告已保存至:C:\Users\admin\Desktop\OpsSummary-2025.json

数据归档与清理

年度数据归档是运维管理的重要环节。下面的脚本实现了日志压缩打包、过期文件自动清理以及备份完整性验证,帮助管理员在假期前完成数据整理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
# 数据归档与清理脚本
function Invoke-YearEndArchive {
param(
[string]$ArchiveRoot = "D:\Archives\$(Get-Date -Format 'yyyy')",
[int]$RetentionDays = 90,
[string[]]$LogPaths = @(
"C:\Logs",
"D:\ApplicationLogs",
"$env:TEMP"
),
[switch]$WhatIf
)

$timestamp = Get-Date -Format 'yyyyMMdd_HHmmss'
$archiveDir = Join-Path $ArchiveRoot "Archive-$timestamp"

if (-not $WhatIf) {
New-Item -ItemType Directory -Path $archiveDir -Force | Out-Null
}

Write-Host "=== 年度数据归档与清理 ===" -ForegroundColor Cyan
Write-Host "归档目录:$archiveDir"
Write-Host "保留天数:$RetentionDays 天"
Write-Host ""

# 1. 日志压缩归档
Write-Host "[1/3] 压缩归档日志文件..." -ForegroundColor Yellow

$archiveResults = @()
foreach ($logPath in $LogPaths) {
if (-not (Test-Path $logPath)) {
Write-Host " 跳过(路径不存在):$logPath" -ForegroundColor DarkGray
continue
}

$logFiles = Get-ChildItem $logPath -Recurse -File -ErrorAction SilentlyContinue |
Where-Object {
$_.Extension -in '.log', '.txt', '.csv', '.evtx' -and
$_.LastWriteTime -lt (Get-Date).AddDays(-$RetentionDays)
}

if ($logFiles) {
$totalSize = [math]::Round(($logFiles | Measure-Object Length -Sum).Sum / 1MB, 2)
$zipName = "$($logPath -replace '[\\:]', '_')_$timestamp.zip"
$zipPath = Join-Path $archiveDir $zipName

if (-not $WhatIf) {
$logFiles | Compress-Archive -DestinationPath $zipPath -CompressionLevel Optimal
$zipSize = [math]::Round((Get-Item $zipPath).Length / 1MB, 2)
} else {
$zipSize = "N/A (WhatIf)"
}

$archiveResults += [PSCustomObject]@{
Source = $logPath
FileCount = $logFiles.Count
OrigMB = $totalSize
ArchiveMB = $zipSize
}

Write-Host " $logPath : $($logFiles.Count) 个文件,${totalSize} MB -> ${zipSize} MB"
} else {
Write-Host " $logPath : 无过期日志" -ForegroundColor DarkGray
}
}

# 2. 过期文件清理
Write-Host "`n[2/3] 清理过期临时文件..." -ForegroundColor Yellow

$cleanPatterns = @('*.tmp', '*.temp', '*.bak', '~$*')
$cleanPaths = @($env:TEMP, "C:\Windows\Temp")
$cleanedCount = 0
$cleanedSize = 0

foreach ($path in $cleanPaths) {
foreach ($pattern in $cleanPatterns) {
$files = Get-ChildItem $path -Filter $pattern -Recurse -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-7) }

foreach ($file in $files) {
$cleanedSize += $file.Length
if (-not $WhatIf) {
Remove-Item $file.FullName -Force -ErrorAction SilentlyContinue
}
$cleanedCount++
}
}
}

$cleanedSizeMB = [math]::Round($cleanedSize / 1MB, 2)
Write-Host " 已清理 $cleanedCount 个临时文件,释放 ${cleanedSizeMB} MB"

# 3. 备份完整性验证
Write-Host "`n[3/3] 验证归档完整性..." -ForegroundColor Yellow

if (-not $WhatIf -and (Test-Path $archiveDir)) {
$zips = Get-ChildItem $archiveDir -Filter '*.zip'
foreach ($zip in $zips) {
try {
Add-Type -AssemblyName System.IO.Compression.FileSystem
$archive = [System.IO.Compression.ZipFile]::OpenRead($zip.FullName)
$entryCount = $archive.Entries.Count
$archive.Dispose()
Write-Host " $($zip.Name) : $entryCount 个文件 - 完整" -ForegroundColor Green
} catch {
Write-Host " $($zip.Name) : 验证失败 - $($_.Exception.Message)" -ForegroundColor Red
}
}
}

Write-Host "`n归档完成!" -ForegroundColor Green
}

# 预览模式(不实际执行)
Invoke-YearEndArchive -WhatIf

# 确认无误后正式执行
# Invoke-YearEndArchive

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
=== 年度数据归档与清理 ===
归档目录:D:\Archives\2025\Archive-20251224_080000
保留天数:90 天

[1/3] 压缩归档日志文件...
C:\Logs : 156 个文件,2340.50 MB -> 412.30 MB
D:\ApplicationLogs : 89 个文件,1580.75 MB -> 298.60 MB
C:\Users\admin\AppData\Local\Temp : 42 个文件,86.20 MB -> 18.40 MB

[2/3] 清理过期临时文件...
已清理 327 个临时文件,释放 456.80 MB

[3/3] 验证归档完整性...
_C_Logs_20251224_080000.zip : 156 个文件 - 完整
_D_ApplicationLogs_20251224_080000.zip : 89 个文件 - 完整
_C_Users_admin_AppData_Local_Temp_20251224_080000.zip : 42 个文件 - 完整

归档完成!

来年自动化准备

假期是审视和优化自动化体系的最佳时机。下面的脚本检查当前计划任务的健康状态、扫描即将到期的证书,并生成一份系统健康检查报告,为来年的运维规划提供数据支撑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
# 来年自动化准备脚本
function Invoke-NewYearPreparation {
param(
[int]$CertificateWarningDays = 60,
[string]$ReportPath = "$env:USERPROFILE\Desktop\NewYear-HealthCheck-$(Get-Date -Format 'yyyyMMdd').html"
)

Write-Host "=== 来年自动化准备检查 ===" -ForegroundColor Cyan
Write-Host ""

# 1. 计划任务健康检查
Write-Host "[1/3] 检查计划任务..." -ForegroundColor Yellow

$scheduledTasks = Get-ScheduledTask |
Where-Object { $_.TaskPath -notlike '\Microsoft\*' } |
ForEach-Object {
$info = $_ | Get-ScheduledTaskInfo -ErrorAction SilentlyContinue
[PSCustomObject]@{
Name = $_.TaskName
Path = $_.TaskPath
State = $_.State
LastRunTime = if ($info.LastRunTime -gt '1899-12-30') { $info.LastRunTime } else { '从未运行' }
LastResult = $info.LastTaskResult
NextRunTime = if ($info.NextRunTime -gt '1899-12-30') { $info.NextRunTime } else { '未计划' }
}
}

$taskStats = @{
Total = $scheduledTasks.Count
Running = ($scheduledTasks | Where-Object State -eq 'Running').Count
Ready = ($scheduledTasks | Where-Object State -eq 'Ready').Count
Disabled = ($scheduledTasks | Where-Object State -eq 'Disabled').Count
Failed = ($scheduledTasks | Where-Object LastResult -ne '0' -and $_.State -eq 'Ready').Count
}

Write-Host " 总计:$($taskStats.Total) 个自定义任务"
Write-Host " 就绪:$($taskStats.Ready) | 运行中:$($taskStats.Running) | 已禁用:$($taskStats.Disabled)"

# 找出上次执行失败的任务
$failedTasks = $scheduledTasks |
Where-Object { $_.LastResult -notin '0', '' -and $_.State -eq 'Ready' }

if ($failedTasks) {
Write-Host "`n 上次执行失败的任务:" -ForegroundColor Red
foreach ($task in $failedTasks) {
Write-Host " - $($task.Name) (错误码: $($task.LastResult))" -ForegroundColor Red
}
}

# 2. 证书到期检查
Write-Host "`n[2/3] 检查证书到期情况..." -ForegroundColor Yellow

$expiryThreshold = (Get-Date).AddDays($CertificateWarningDays)

$certExpirations = Get-ChildItem Cert:\LocalMachine\My -ErrorAction SilentlyContinue |
Where-Object { $_.NotAfter -le $expiryThreshold } |
Sort-Object NotAfter |
ForEach-Object {
$daysLeft = ($_.NotAfter - (Get-Date)).Days
$status = if ($daysLeft -le 0) { '已过期' }
elseif ($daysLeft -le 30) { '紧急' }
elseif ($daysLeft -le 60) { '警告' }
else { '注意' }

[PSCustomObject]@{
Subject = $_.Subject
Thumbprint = $_.Thumbprint.Substring(0, 16) + '...'
Expires = $_.NotAfter.ToString('yyyy-MM-dd')
DaysLeft = $daysLeft
Status = $status
}
}

if ($certExpirations) {
$certExpirations | Format-Table -AutoSize
} else {
Write-Host " 未发现即将到期的证书" -ForegroundColor Green
}

# 3. 系统健康检查
Write-Host "`n[3/3] 系统健康检查..." -ForegroundColor Yellow

$os = Get-CimInstance Win32_OperatingSystem
$cpu = Get-CimInstance Win32_Processor
$memTotalGB = [math]::Round($os.TotalVisibleMemorySize / 1MB, 2)
$memFreeGB = [math]::Round($os.FreePhysicalMemory / 1MB, 2)
$memUsedPct = [math]::Round(($memTotalGB - $memFreeGB) / $memTotalGB * 100, 1)
$cpuLoad = $cpu.LoadPercentage

$healthReport = [PSCustomObject]@{
ComputerName = $env:COMPUTERNAME
OSVersion = $os.Caption
LastBootTime = $os.LastBootUpTime.ToString('yyyy-MM-dd HH:mm:ss')
CPULoad = "$cpuLoad%"
MemoryTotalGB = $memTotalGB
MemoryFreeGB = $memFreeGB
MemoryUsedPct = "$memUsedPct%"
ServicesFailed = (Get-Service | Where-Object { $_.Status -eq 'Stopped' -and $_.StartType -eq 'Automatic' }).Count
PendingReboot = (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending')
}

Write-Host " 主机名:$($healthReport.ComputerName)"
Write-Host " 操作系统:$($healthReport.OSVersion)"
Write-Host " CPU 负载:$($healthReport.CPULoad)"
Write-Host " 内存使用:$($healthReport.MemoryUsedPct) ($($healthReport.MemoryFreeGB) GB 可用)"
Write-Host " 已停止的自动启动服务:$($healthReport.ServicesFailed)"

if ($healthReport.PendingReboot) {
Write-Host " 待重启:是" -ForegroundColor Red
} else {
Write-Host " 待重启:否" -ForegroundColor Green
}

# 生成 HTML 报告
$htmlBody = @"
<h1>新年系统健康检查报告</h1>
<p>生成时间:$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')</p>
<h2>系统概况</h2>
<table border="1" cellpadding="5" style="border-collapse:collapse">
<tr><td>主机名</td><td>$($healthReport.ComputerName)</td></tr>
<tr><td>操作系统</td><td>$($healthReport.OSVersion)</td></tr>
<tr><td>CPU 负载</td><td>$($healthReport.CPULoad)</td></tr>
<tr><td>内存使用率</td><td>$($healthReport.MemoryUsedPct)</td></tr>
<tr><td>待重启</td><td>$($healthReport.PendingReboot)</td></tr>
</table>
"@

$htmlBody | Out-File $ReportPath -Encoding UTF8
Write-Host "`nHTML 报告已保存至:$ReportPath" -ForegroundColor Green
Write-Host "`n来年自动化准备检查完毕!" -ForegroundColor Green
}

# 执行来年准备检查
Invoke-NewYearPreparation -CertificateWarningDays 60

执行结果示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
=== 来年自动化准备检查 ===

[1/3] 检查计划任务...
总计:24 个自定义任务
就绪:18 | 运行中:2 | 已禁用:4

上次执行失败的任务:
- DailyBackup (错误码: 0x1)
- LogRotation (错误码: 0x2)

[2/3] 检查证书到期情况...
Subject Thumbprint Expires DaysLeft Status
------- ---------- ------- -------- ------
CN=web.vichamp.com 3A2F8B1C9D0E4... 2026-01-15 22 紧急
CN=api.internal.local 7E6D5C4B3A2F1... 2026-02-20 58 警告

[3/3] 系统健康检查...
主机名:SRV-PROD-01
操作系统:Microsoft Windows Server 2022 Standard
CPU 负载:23%
内存使用:67.5% (7.26 GB 可用)
已停止的自动启动服务:2
待重启:是

HTML 报告已保存至:C:\Users\admin\Desktop\NewYear-HealthCheck-20251224.html

来年自动化准备检查完毕!

注意事项

  1. 权限要求:事件日志查询和计划任务管理需要管理员权限,建议以提升模式运行 PowerShell,或在脚本开头添加 #Requires -RunAsAdministrator 确保权限充足。
  2. 大日志处理Get-WinEvent 在处理整年事件日志时可能消耗大量内存。对于事件量超过十万条的日志源,建议按月份分批查询后合并统计,避免内存溢出。
  3. 归档前验证:执行日志清理前务必先用 -WhatIf 参数预览操作范围,确认无误后再正式执行。已压缩的归档文件应存放到独立存储或异地备份,避免与原始数据在同一磁盘上。
  4. 证书到期监控:建议将证书到期检查集成到日常监控流程中(如每周执行一次),而非仅在年底检查。可以在脚本中加入邮件通知逻辑,在证书到期前 30 天和 7 天分别发送提醒。
  5. 跨平台兼容:本文部分示例使用了 Windows 特有的模块(如 ScheduledTaskCert: 驱动器)。如果在 Linux 或 macOS 上运行 PowerShell 7,需要使用对应的平台命令替代,如 crontab -l 替代计划任务检查。
  6. 报告持续化:年度报告建议统一存放并纳入版本管理。可以配合 Git 仓库或 SharePoint 文档库,每年追加新报告,形成连续的运维历史档案,便于长期趋势分析和审计回溯。

PowerShell 技能连载 - HTML 报告生成

适用于 PowerShell 5.1 及以上版本

运维报告是沟通技术状态的桥梁——无论是每日健康检查报告、月度容量报告还是安全审计报告,格式清晰、内容直观的 HTML 报告远比纯文本或截图有效。PowerShell 的 ConvertTo-Html 命令可以将任何对象集合转为 HTML 表格,结合自定义 CSS 样式和 Here-String 模板,可以快速生成专业级的报告。

本文将讲解 HTML 报告的生成技巧、样式定制、邮件发送,以及实用的报告模板。

基础 HTML 报告

1
2
3
4
5
6
7
8
9
10
# 使用 ConvertTo-Html 生成简单报告
$processes = Get-Process | Sort-Object WorkingSet64 -Descending | Select-Object -First 10

$htmlReport = $processes | Select-Object Name, Id,
@{N='内存MB'; E={[math]::Round($_.WorkingSet64/1MB, 1)}},
@{N='CPU(s)'; E={[math]::Round($_.CPU, 2)}} |
ConvertTo-Html -Title "进程报告" -Property Name, Id, '内存MB', 'CPU(s)'

$htmlReport | Set-Content "C:\Reports\processes.html" -Encoding UTF8
Write-Host "报告已生成" -ForegroundColor Green

执行结果示例:

1
报告已生成

带样式的完整报告

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
function New-DailyHealthReport {
<#
.SYNOPSIS
生成每日系统健康 HTML 报告
#>
param(
[string]$OutputPath = "C:\Reports\daily-health.html"
)

# 采集数据
$os = Get-CimInstance Win32_OperatingSystem
$cpu = Get-CimInstance Win32_Processor | Select-Object -First 1
$disks = Get-CimInstance Win32_LogicalDisk -Filter "DriveType=3"
$topProcesses = Get-Process | Sort-Object WorkingSet64 -Descending |
Select-Object -First 10
$services = Get-Service | Where-Object {
$_.StartType -eq 'Automatic' -and $_.Status -ne 'Running'
}

# CSS 样式
$css = @"
<style>
body { font-family: 'Segoe UI', Arial, sans-serif; margin: 20px; background: #f5f6fa; color: #2d3436; }
.container { max-width: 1200px; margin: 0 auto; }
h1 { color: #2d3436; border-bottom: 3px solid #0984e3; padding-bottom: 10px; }
h2 { color: #636e72; margin-top: 30px; border-left: 4px solid #0984e3; padding-left: 10px; }
.summary { display: flex; flex-wrap: wrap; gap: 15px; margin: 20px 0; }
.card { background: white; border-radius: 8px; padding: 20px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); min-width: 200px; flex: 1; }
.card .label { font-size: 12px; color: #b2bec3; text-transform: uppercase; }
.card .value { font-size: 28px; font-weight: bold; color: #0984e3; margin-top: 5px; }
.card.warning .value { color: #fdcb6e; }
.card.danger .value { color: #d63031; }
table { width: 100%; border-collapse: collapse; background: white; border-radius: 8px; overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.1); margin: 10px 0; }
th { background: #0984e3; color: white; padding: 12px 15px; text-align: left; font-size: 13px; }
td { padding: 10px 15px; border-bottom: 1px solid #dfe6e9; }
tr:hover { background: #f0f8ff; }
.bar { height: 8px; background: #dfe6e9; border-radius: 4px; overflow: hidden; min-width: 80px; }
.bar-fill { height: 100%; border-radius: 4px; }
.good { background: #00b894; }
.warning { background: #fdcb6e; }
.danger { background: #d63031; }
.footer { text-align: center; color: #b2bec3; margin-top: 30px; font-size: 12px; }
.status-badge { padding: 3px 10px; border-radius: 12px; font-size: 12px; font-weight: bold; }
.status-ok { background: #e8f5e9; color: #27ae60; }
.status-error { background: #ffebee; color: #c0392b; }
</style>
"@

# 构建报告
$report = @"
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>系统健康报告 - $($os.CSName)</title>
$css
</head>
<body>
<div class="container">
<h1>系统健康报告</h1>
<p>计算机:<strong>$($os.CSName)</strong> | 操作系统:$($os.Caption) |
报告时间:$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')</p>

<div class="summary">
<div class="card">
<div class="label">CPU 使用率</div>
<div class="value">$($cpu.LoadPercentage)%</div>
</div>
<div class="card">
<div class="label">内存使用率</div>
<div class="value">$([math]::Round(($os.TotalVisibleMemorySize - $os.FreePhysicalMemory) / $os.TotalVisibleMemorySize * 100, 1))%</div>
</div>
<div class="card">
<div class="label">运行时间</div>
<div class="value">$([math]::Round(((Get-Date) - $os.LastBootUpTime).TotalDays, 1)) 天</div>
</div>
<div class="card">
<div class="label">异常服务</div>
<div class="value">$($services.Count)</div>
</div>
</div>

<h2>磁盘状态</h2>
<table>
<tr><th>驱动器</th><th>文件系统</th><th>总容量</th><th>已使用</th><th>可用</th><th>使用率</th></tr>
"@

foreach ($disk in $disks) {
$totalGB = [math]::Round($disk.Size / 1GB, 2)
$freeGB = [math]::Round($disk.FreeSpace / 1GB, 2)
$usedGB = [math]::Round(($disk.Size - $disk.FreeSpace) / 1GB, 2)
$usedPct = [math]::Round(($disk.Size - $disk.FreeSpace) / $disk.Size * 100, 1)
$barClass = if ($usedPct -gt 90) { 'danger' } elseif ($usedPct -gt 70) { 'warning' } else { 'good' }

$report += @"
<tr>
<td>$($disk.DeviceID)</td>
<td>$($disk.FileSystem)</td>
<td>${totalGB} GB</td>
<td>${usedGB} GB</td>
<td>${freeGB} GB</td>
<td>
<div class="bar"><div class="bar-fill $barClass" style="width:$usedPct%"></div></div>
$usedPct%
</td>
</tr>
"@
}

$report += @"
</table>

<h2>内存占用 Top 10</h2>
<table>
<tr><th>进程名</th><th>PID</th><th>内存 (MB)</th><th>CPU (s)</th></tr>
"@

foreach ($proc in $topProcesses) {
$memMB = [math]::Round($proc.WorkingSet64 / 1MB, 1)
$cpuS = [math]::Round($proc.CPU, 2)
$report += " <tr><td>$($proc.Name)</td><td>$($proc.Id)</td><td>$memMB</td><td>$cpuS</td></tr>`n"
}

$report += @"
</table>
"@

if ($services) {
$report += @"
<h2>异常自动启动服务</h2>
<table>
<tr><th>服务名</th><th>显示名</th><th>状态</th></tr>
"@
foreach ($svc in $services) {
$report += " <tr><td>$($svc.Name)</td><td>$($svc.DisplayName)</td><td><span class=`"status-badge status-error`">$($svc.Status)</span></td></tr>`n"
}
$report += " </table>`n"
}

$report += @"
<div class="footer">
报告由 PowerShell 自动生成 | $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')
</div>
</div>
</body>
</html>
"@

$report | Set-Content $OutputPath -Encoding UTF8
Write-Host "健康报告已生成:$OutputPath" -ForegroundColor Green
}

New-DailyHealthReport

执行结果示例:

1
健康报告已生成:C:\Reports\daily-health.html

邮件发送报告

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
function Send-HtmlReport {
param(
[Parameter(Mandatory)]
[string]$ReportPath,

[Parameter(Mandatory)]
[string[]]$To,

[string]$Subject = "PowerShell 自动报告 - $(Get-Date -Format 'yyyy-MM-dd')",

[string]$SmtpServer = "mail.contoso.com",

[int]$Port = 587,

[System.Management.Automation.PSCredential]$Credential
)

$body = Get-Content $ReportPath -Raw

$params = @{
From = "reports@contoso.com"
To = $To
Subject = $Subject
Body = $body
BodyAsHtml = $true
SmtpServer = $SmtpServer
Port = $Port
Encoding = [System.Text.Encoding]::UTF8
}

if ($Credential) {
$params.Credential = $Credential
}

Send-MailMessage @params
Write-Host "报告已发送给:$($To -join ', ')" -ForegroundColor Green
}

# 发送报告邮件
$cred = Get-Credential -Message "输入邮件发送账户凭据"
Send-HtmlReport -ReportPath "C:\Reports\daily-health.html" `
-To @("admin@contoso.com", "team@contoso.com") `
-Subject "每日系统健康报告 - $(Get-Date -Format 'yyyy-MM-dd')" `
-Credential $cred

执行结果示例:

1
报告已发送给:admin@contoso.com, team@contoso.com

注意事项

  1. UTF-8 编码:始终使用 UTF-8 编码保存 HTML 文件,并在 <head> 中声明 <meta charset="UTF-8">
  2. 邮件客户端兼容:邮件中的 CSS 支持有限,避免使用 Flexbox、Grid 等现代布局,使用表格布局更安全
  3. 内联 CSS:邮件中的 CSS 最好内联写在元素上,而不是放在 <style> 标签中
  4. 图片嵌入:HTML 报告中的图片可以使用 Base64 内嵌或 CID 附件方式
  5. 报告大小:控制报告文件大小,大量数据应分页或限制展示条数
  6. 安全邮件:生产环境中使用 TLS 加密发送邮件,凭据应存储在密钥库中

PowerShell 技能连载 - 数据可视化与图表

适用于 PowerShell 5.1 及以上版本(Windows)

运维人员经常需要将系统指标、日志统计等数据以图表形式呈现,用于周报、容量规划或故障分析。虽然 Excel 和 Grafana 是常见的可视化工具,但 PowerShell 本身也具备生成图表的能力——通过 .NET 的 System.Windows.Forms.DataVisualization 命名空间或导出为 HTML/CSV 交由外部工具渲染,都能快速将数据转化为直观的图形。

本文将介绍如何使用 PowerShell 生成柱状图、折线图、饼图,以及如何将图表嵌入自动化报告。

使用 .NET Chart 控件生成图表

Windows 上的 PowerShell 可以直接调用 .NET 的 Chart 控件生成图片。这个控件功能丰富,支持柱状图、折线图、饼图、散点图等多种图表类型。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
# 加载 Chart 程序集
Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Windows.Forms.DataVisualization

# 创建图表对象
$chart = New-Object System.Windows.Forms.DataVisualization.Charting.Chart
$chart.Width = 800
$chart.Height = 500
$chart.BackColor = [System.Drawing.Color]::White

# 创建图表区域
$chartArea = New-Object System.Windows.Forms.DataVisualization.Charting.ChartArea
$chartArea.Name = "MainArea"
$chartArea.AxisX.Title = "服务器"
$chartArea.AxisY.Title = "CPU 使用率 (%)"
$chartArea.AxisY.Minimum = 0
$chartArea.AxisY.Maximum = 100
$chart.ChartAreas.Add($chartArea)

# 添加数据
$servers = @(
@{ Name = 'WEB-01'; CPU = 72.5 }
@{ Name = 'WEB-02'; CPU = 45.3 }
@{ Name = 'DB-01'; CPU = 89.1 }
@{ Name = 'DB-02'; CPU = 63.8 }
@{ Name = 'APP-01'; CPU = 34.2 }
@{ Name = 'APP-02'; CPU = 55.7 }
)

$series = New-Object System.Windows.Forms.DataVisualization.Charting.Series
$series.Name = "CPU使用率"
$series.ChartType = [System.Windows.Forms.DataVisualization.Charting.SeriesChartType]::Column
$series.Color = [System.Drawing.Color]::SteelBlue
$series.IsValueShownAsLabel = $true

foreach ($srv in $servers) {
$series.Points.AddXY($srv.Name, $srv.CPU) | Out-Null
}

$chart.Series.Add($series)

# 添加标题
$title = New-Object System.Windows.Forms.DataVisualization.Charting.Title
$title.Text = "服务器 CPU 使用率监控"
$title.Font = New-Object System.Drawing.Font("Microsoft YaHei", 14, [System.Drawing.FontStyle]::Bold)
$chart.Titles.Add($title)

# 保存为图片
$outputPath = "C:\Reports\cpu_usage.png"
$chart.SaveImage($outputPath, [System.Windows.Forms.DataVisualization.Charting.ChartImageFormat]::Png)
Write-Host "图表已保存到:$outputPath" -ForegroundColor Green

执行结果示例:

1
图表已保存到:C:\Reports\cpu_usage.png

生成折线图展示趋势

折线图适合展示时间序列数据,如 CPU 使用率趋势、内存变化等:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
Add-Type -AssemblyName System.Windows.Forms.DataVisualization

$chart = New-Object System.Windows.Forms.DataVisualization.Charting.Chart
$chart.Width = 900
$chart.Height = 500
$chart.BackColor = [System.Drawing.Color]::White

$chartArea = New-Object System.Windows.Forms.DataVisualization.Charting.ChartArea
$chartArea.Name = "TrendArea"
$chartArea.AxisX.Title = "时间"
$chartArea.AxisY.Title = "使用率 (%)"
$chartArea.AxisY.Minimum = 0
$chartArea.AxisY.Maximum = 100
$chart.ChartAreas.Add($chartArea)

# 模拟 24 小时内存使用数据
$hours = 0..23
$memUsage = @(42, 40, 38, 35, 33, 32, 45, 58, 67, 72, 75, 73,
70, 68, 71, 74, 78, 76, 65, 58, 52, 48, 45, 43)

$series = New-Object System.Windows.Forms.DataVisualization.Charting.Series
$series.Name = "内存使用率"
$series.ChartType = [System.Windows.Forms.DataVisualization.Charting.SeriesChartType]::Line
$series.Color = [System.Drawing.Color]::OrangeRed
$series.BorderWidth = 3
$series.IsValueShownAsLabel = $false

for ($i = 0; $i -lt $hours.Count; $i++) {
$series.Points.AddXY("$($hours[$i]):00", $memUsage[$i]) | Out-Null
}

$chart.Series.Add($series)

$title = New-Object System.Windows.Forms.DataVisualization.Charting.Title
$title.Text = "24 小时内存使用率趋势"
$title.Font = New-Object System.Drawing.Font("Microsoft YaHei", 14, [System.Drawing.FontStyle]::Bold)
$chart.Titles.Add($title)

$chart.SaveImage("C:\Reports\memory_trend.png", "Png")
Write-Host "折线图已保存" -ForegroundColor Green

执行结果示例:

1
折线图已保存

生成饼图展示占比

饼图适合展示各部分占总体的比例,如磁盘空间分配、服务状态分布等:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
Add-Type -AssemblyName System.Windows.Forms.DataVisualization

$chart = New-Object System.Windows.Forms.DataVisualization.Charting.Chart
$chart.Width = 700
$chart.Height = 500
$chart.BackColor = [System.Drawing.Color]::White

$chartArea = New-Object System.Windows.Forms.DataVisualization.Charting.ChartArea
$chartArea.Name = "PieArea"
$chart.ChartAreas.Add($chartArea)

# 磁盘空间数据
$diskData = @(
@{ Label = '系统文件'; Size = 85.2 }
@{ Label = '应用程序'; Size = 120.5 }
@{ Label = '用户数据'; Size = 156.3 }
@{ Label = '日志文件'; Size = 42.8 }
@{ Label = '可用空间'; Size = 191.6 }
)

$series = New-Object System.Windows.Forms.DataVisualization.Charting.Series
$series.Name = "磁盘空间"
$series.ChartType = [System.Windows.Forms.DataVisualization.Charting.SeriesChartType]::Pie
$series.IsValueShownAsLabel = $true
$series.LabelFormat = "{0} GB"

$colors = @(
[System.Drawing.Color]::SteelBlue,
[System.Drawing.Color]::Orange,
[System.Drawing.Color]::Green,
[System.Drawing.Color]::Red,
[System.Drawing.Color]::LightGray
)

for ($i = 0; $i -lt $diskData.Count; $i++) {
$pointIndex = $series.Points.AddXY($diskData[$i].Label, $diskData[$i].Size)
$series.Points[$pointIndex].Color = $colors[$i]
}

$chart.Series.Add($series)

$title = New-Object System.Windows.Forms.DataVisualization.Charting.Title
$title.Text = "磁盘空间分配(C盘 596 GB)"
$title.Font = New-Object System.Drawing.Font("Microsoft YaHei", 14, [System.Drawing.FontStyle]::Bold)
$chart.Titles.Add($title)

# 添加图例
$legend = New-Object System.Windows.Forms.DataVisualization.Charting.Legend
$legend.Name = "Default"
$chart.Legends.Add($legend)
$series.Legend = "Default"

$chart.SaveImage("C:\Reports\disk_usage_pie.png", "Png")
Write-Host "饼图已保存" -ForegroundColor Green

执行结果示例:

1
饼图已保存

生成 HTML 报告

在实际运维中,将数据导出为 HTML 格式更便于分享和查看。结合 Chart.js 或简单的 HTML 表格,可以创建美观的自动化报告:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
function New-ServerHealthReport {
<#
.SYNOPSIS
生成服务器健康状态 HTML 报告
#>
[CmdletBinding()]
param(
[string]$OutputPath = "C:\Reports\ServerHealth.html"
)

# 采集数据
$os = Get-CimInstance -ClassName Win32_OperatingSystem
$cpu = Get-CimInstance -ClassName Win32_Processor |
Select-Object -First 1
$disks = Get-CimInstance -ClassName Win32_LogicalDisk -Filter "DriveType=3"
$topProcesses = Get-Process |
Sort-Object WorkingSet64 -Descending |
Select-Object -First 10 Name, Id,
@{N='内存MB'; E={[math]::Round($_.WorkingSet64/1MB,1)}}

$totalRam = [math]::Round($os.TotalVisibleMemorySize / 1MB, 2)
$freeRam = [math]::Round($os.FreePhysicalMemory / 1MB, 2)
$usedRamPct = [math]::Round(($totalRam - $freeRam) / $totalRam * 100, 1)

# 生成 HTML
$html = @"
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>服务器健康报告 - $($os.CSName)</title>
<style>
body { font-family: 'Segoe UI', sans-serif; margin: 20px; background: #f5f5f5; }
.card { background: white; border-radius: 8px; padding: 20px; margin: 10px 0;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
h1 { color: #333; } h2 { color: #555; border-bottom: 2px solid #0078d4; padding-bottom: 8px; }
table { width: 100%; border-collapse: collapse; margin: 10px 0; }
th { background: #0078d4; color: white; padding: 10px; text-align: left; }
td { padding: 8px 10px; border-bottom: 1px solid #ddd; }
tr:hover { background: #f0f8ff; }
.metric { display: inline-block; min-width: 150px; margin: 10px 20px; }
.metric-value { font-size: 28px; font-weight: bold; color: #0078d4; }
.metric-label { font-size: 12px; color: #666; }
.bar { height: 20px; background: #e0e0e0; border-radius: 4px; overflow: hidden; }
.bar-fill { height: 100%; border-radius: 4px; }
.warning { background: #ff9800; } .good { background: #4caf50; } .danger { background: #f44336; }
.timestamp { color: #999; font-size: 12px; }
</style>
</head>
<body>
<h1>服务器健康报告</h1>
<p class="timestamp">生成时间:$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') | 计算机:$($os.CSName)</p>

<div class="card">
<h2>系统概览</h2>
<div class="metric">
<div class="metric-value">$usedRamPct%</div>
<div class="metric-label">内存使用率</div>
</div>
<div class="metric">
<div class="metric-value">$($cpu.LoadPercentage)%</div>
<div class="metric-label">CPU 使用率</div>
</div>
<div class="metric">
<div class="metric-value">$freeRam GB</div>
<div class="metric-label">可用内存</div>
</div>
<div class="metric">
<div class="metric-value">$totalRam GB</div>
<div class="metric-label">总内存</div>
</div>
</div>

<div class="card">
<h2>磁盘状态</h2>
<table>
<tr><th>驱动器</th><th>文件系统</th><th>总容量</th><th>已使用</th><th>可用</th><th>使用率</th></tr>
"@

foreach ($disk in $disks) {
$totalGB = [math]::Round($disk.Size / 1GB, 2)
$freeGB = [math]::Round($disk.FreeSpace / 1GB, 2)
$usedGB = [math]::Round(($disk.Size - $disk.FreeSpace) / 1GB, 2)
$usedPct = [math]::Round(($disk.Size - $disk.FreeSpace) / $disk.Size * 100, 1)
$barClass = if ($usedPct -gt 90) { "danger" } elseif ($usedPct -gt 70) { "warning" } else { "good" }

$html += @"
<tr>
<td>$($disk.DeviceID)</td>
<td>$($disk.FileSystem)</td>
<td>${totalGB} GB</td>
<td>${usedGB} GB</td>
<td>${freeGB} GB</td>
<td>
<div class="bar"><div class="bar-fill $barClass" style="width:$usedPct%"></div></div>
$usedPct%
</td>
</tr>
"@
}

$html += @"
</table>
</div>

<div class="card">
<h2>内存占用 Top 10 进程</h2>
<table>
<tr><th>进程名</th><th>PID</th><th>内存 (MB)</th></tr>
"@

foreach ($proc in $topProcesses) {
$html += " <tr><td>$($proc.Name)</td><td>$($proc.Id)</td><td>$($proc.内存MB)</td></tr>`n"
}

$html += @"
</table>
</div>
</body>
</html>
"@

$html | Out-File -FilePath $OutputPath -Encoding UTF8
Write-Host "HTML 报告已生成:$OutputPath" -ForegroundColor Green
}

New-ServerHealthReport

执行结果示例:

1
HTML 报告已生成:C:\Reports\ServerHealth.html

注意:HTML 报告可以直接在浏览器中打开,也可以通过邮件发送。结合 Windows 计划任务,可以每日自动生成并发送。

导出 CSV 供外部工具使用

如果需要更复杂的可视化,可以将数据导出为 CSV 格式,供 Excel、Power BI 或 Grafana 使用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 采集多台服务器的性能数据并导出 CSV
$report = foreach ($day in 1..30) {
$date = (Get-Date).AddDays(-$day)
# 模拟数据(实际应从各服务器采集)
[PSCustomObject]@{
日期 = $date.ToString('yyyy-MM-dd')
CPU平均 = [math]::Round((Get-Random -Min 20 -Max 80) + (Get-Random -Min 0 -Max 100) / 100, 2)
内存使用率 = [math]::Round((Get-Random -Min 40 -Max 85) + (Get-Random -Min 0 -Max 100) / 100, 2)
磁盘写入MB = [math]::Round((Get-Random -Min 100 -Max 5000) + (Get-Random -Min 0 -Max 100) / 100, 2)
网络流量MB = [math]::Round((Get-Random -Min 500 -Max 20000) + (Get-Random -Min 0 -Max 100) / 100, 2)
}
}

$report | Export-Csv -Path "C:\Reports\daily_metrics.csv" -NoTypeInformation -Encoding UTF8
Write-Host "已导出 $($report.Count) 天的指标数据" -ForegroundColor Green

# 也可以直接输出为 Markdown 表格(适合 GitHub/GitLab)
$report | ConvertTo-MarkdownTable | Set-Content "C:\Reports\daily_metrics.md"

执行结果示例:

1
已导出 30 天的指标数据

使用 Mermaid 在 Markdown 中嵌入图表

对于 Markdown 格式的报告,可以使用 Mermaid 语法嵌入流程图和甘特图,GitHub 和 GitLab 原生支持渲染:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 生成 Mermaid 甘特图的 Markdown 文件
$mermaid = @"
# 运维任务计划

```mermaid
gantt
title 2025年5月运维任务
dateFormat YYYY-MM-DD
section 基础设施
服务器巡检 :done, des1, 2025-05-01, 2025-05-05
补丁更新 :active, des2, 2025-05-05, 2025-05-10
容量评估 : des3, 2025-05-15, 2025-05-20
section 安全
漏洞扫描 :done, sec1, 2025-05-01, 2025-05-03
安全加固 : sec2, 2025-05-10, 2025-05-15
渗透测试 : sec3, 2025-05-20, 2025-05-25

“@

Set-Content -Path “C:\Reports\tasks_may.md” -Value $mermaid -Encoding UTF8
Write-Host “Mermaid 甘特图 Markdown 已生成” -ForegroundColor Green

1
2
3

执行结果示例:

Mermaid 甘特图 Markdown 已生成


## 注意事项

1. **Chart 控件依赖**:.NET Chart 控件需要 Windows 桌面环境,在 Server Core 或 Linux 上不可用。跨平台场景建议使用 HTML/CSV 导出方式
2. **图片分辨率**:生产环境中建议将图表尺寸设为 1200x600 以上,确保在高分辨率显示器上清晰
3. **中文字体**:Chart 控件中显示中文需要指定支持中文的字体(如"Microsoft YaHei"),否则可能显示方块
4. **HTML 报告编码**:生成 HTML 文件时务必使用 UTF-8 编码,并在 `<head>` 中声明 `<meta charset="UTF-8">`
5. **自动化调度**:结合 Windows 计划任务或 PowerShell 隐式远程,可以实现每日自动采集数据并生成报告
6. **大数据量**:当数据点超过数千时,.NET Chart 控件的性能会下降,建议对时间序列数据进行采样聚合后再绘图