#!/usr/bin/env python3
"""
Zabbix告警报表生成脚本
自动连接数据库、调用存储过程、生成HTML报表
支持饼图文字标签、数值显示、更美观的图表
"""
import pymysql
import json
from datetime import datetime, timedelta
import smtplib
import os
import re
import pandas as pd
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
from email.mime.base import MIMEBase
from email import encoders
from email.utils import formataddr
from bs4 import BeautifulSoup
# ============================================
# 配置参数(全部在这里修改)
# ============================================
# 数据库配置
DB_CONFIG = {
'host': '10.2.11.6', # 修改为你的数据库地址
'port': 3306,
'user': 'zabbix', # 用户名
'password': '111', # 密码
'database': '111',
'charset': 'utf8mb4'
}
# 时间范围配置(二选一)
USE_RECENT_DAYS = True # True=使用最近N天,False=使用固定时间范围
RECENT_DAYS = 7 # 最近N天(当USE_RECENT_DAYS=True时生效)
FIXED_START_TIME = '2026-06-01 00:00:00' # 固定开始时间(当USE_RECENT_DAYS=False时生效)
FIXED_END_TIME = '2026-06-30 23:59:59' # 固定结束时间(当USE_RECENT_DAYS=False时生效)
# 输出配置
if USE_RECENT_DAYS:
end_time = datetime.now().strftime('%Y-%m-%d 13:00:00')
start_time = (datetime.now() - timedelta(days=RECENT_DAYS)).strftime('%Y-%m-%d 12:00:00')
OUTPUT_FILE = 'zabbix_report' + start_time[:10] + '-' + end_time[:10] + '.html' # HTML报表输出路径
else:
end_time=input("结束时间 %Y-%m-%d 13:00:00")
start_time = input("开始时间 %Y-%m-%d 12:00:00")
OUTPUT_FILE = 'zabbix_report.html' # HTML报表输出路径
AUTO_OPEN = True # 是否自动打开浏览器查看报表(仅Windows/Mac支持)
# 存储过程名称
STORED_PROCEDURE = 'GetZabbixAlerts'
def extract_element_html(html_content, xpath):
"""
使用XPath或CSS选择器提取HTML中的特定元素
Args:
html_content: HTML字符串
xpath: 元素路径,如 '/html/body/div/div[5]/div/div/h3'
Returns:
提取的HTML字符串,如果未找到则返回None
"""
from lxml import html
try:
# 解析HTML
tree = html.fromstring(html_content)
# 使用XPath查找元素
elements = tree.xpath(xpath)
if elements:
# 返回找到的第一个元素的HTML
return html.tostring(elements[0], encoding='unicode')
else:
print(f"未找到匹配XPath的元素: {xpath}")
return None
except Exception as e:
print(f"解析HTML时出错: {e}")
return None
def extract_element_css(html_content, css_selector):
"""
使用CSS选择器提取HTML中的特定元素
Args:
html_content: HTML字符串
css_selector: CSS选择器,如 'div.content h3'
Returns:
提取的HTML字符串,如果未找到则返回None
"""
soup = BeautifulSoup(html_content, 'html.parser')
try:
# 使用CSS选择器查找元素
element = soup.select_one(css_selector)
if element:
return str(element)
else:
print(f"未找到匹配CSS选择器的元素: {css_selector}")
return None
except Exception as e:
print(f"解析HTML时出错: {e}")
return None
def send_html_email_with_extraction(smtp_server, smtp_port, sender_email, sender_password,
receiver_email, html_file_path,
extraction_method='xpath',
extraction_selector='/html/body/div/div[5]/div/div/h3',
subject=None, cc_emails=None):
"""
发送HTML邮件,只提取特定部分作为正文,完整HTML作为附件
Args:
smtp_server: SMTP服务器地址
smtp_port: SMTP服务器端口
sender_email: 发件人邮箱
sender_password: 发件人邮箱密码或授权码
receiver_email: 收件人邮箱
html_file_path: HTML文件路径
extraction_method: 提取方法,'xpath' 或 'css'
extraction_selector: 选择器表达式
subject: 邮件主题
cc_emails: 抄送邮箱列表
"""
# 读取完整的HTML文件
try:
with open(html_file_path, 'r', encoding='utf-8') as file:
full_html_content = file.read()
except FileNotFoundError:
print(f"错误:文件 {html_file_path} 未找到")
return False
except Exception as e:
print(f"读取文件时出错:{e}")
return False
# 提取特定部分的HTML
if extraction_method == 'xpath':
extracted_html = extract_element_html(full_html_content, extraction_selector)
else: # css
extracted_html = extract_element_css(full_html_content, extraction_selector)
if not extracted_html:
print("未提取到任何内容,将使用完整HTML作为正文")
extracted_html = full_html_content
# 创建邮件对象
msg = MIMEMultipart('mixed')
msg['From'] = formataddr(('Sender', sender_email))
# 处理收件人
if isinstance(receiver_email, list):
msg['To'] = ', '.join(receiver_email)
else:
msg['To'] = receiver_email
# 设置主题
if not subject:
subject = f"HTML Report (Extracted): {os.path.basename(html_file_path)}"
msg['Subject'] = subject
# 处理抄送
if cc_emails:
if isinstance(cc_emails, list):
msg['Cc'] = ', '.join(cc_emails)
else:
msg['Cc'] = cc_emails
# 创建alternative部分
msg_alternative = MIMEMultipart('alternative')
msg.attach(msg_alternative)
# 添加纯文本版本
plain_text = f"请查看附件中的完整HTML报告: {os.path.basename(html_file_path)}\n\n正文中显示的是摘录内容。"
msg_alternative.attach(MIMEText(plain_text, 'plain', 'utf-8'))
# 添加提取的HTML部分作为正文
# 包裹提取的内容,使其成为完整的HTML文档
wrapped_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {{
font-family: Arial, sans-serif;
padding: 20px;
background-color: #f9f9f9;
}}
.extracted-content {{
background-color: white;
padding: 20px;
border: 1px solid #ddd;
border-radius: 5px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}}
.note {{
color: #666;
font-size: 12px;
margin-top: 20px;
padding: 10px;
background-color: #f5f5f5;
border-left: 3px solid #007bff;
}}
</style>
</head>
<body>
<div class="extracted-content">
{extracted_html}
</div>
<div class="note">
<p>此邮件显示的是HTML文件的部分摘录内容。完整内容请查看附件中的HTML文件: <strong>{os.path.basename(html_file_path)}</strong></p>
</div>
</body>
</html>
"""
msg_alternative.attach(MIMEText(wrapped_html, 'html', 'utf-8'))
# 将完整的HTML文件作为附件添加
try:
with open(html_file_path, 'rb') as attachment:
part = MIMEBase('text', 'html')
part.set_payload(attachment.read())
encoders.encode_base64(part)
filename = os.path.basename(html_file_path)
part.add_header(
'Content-Disposition',
f'attachment; filename="{filename}"'
)
msg.attach(part)
except Exception as e:
print(f"添加附件时出错:{e}")
# 发送邮件
try:
server = smtplib.SMTP(smtp_server, smtp_port)
server.starttls()
server.login(sender_email, sender_password)
recipients = [receiver_email] if isinstance(receiver_email, str) else receiver_email
if cc_emails:
if isinstance(cc_emails, str):
recipients.append(cc_emails)
else:
recipients.extend(cc_emails)
server.sendmail(sender_email, recipients, msg.as_string())
server.quit()
print("邮件发送成功!")
print(f"- 正文:从HTML中提取的 {extraction_selector} 部分")
print(f"- 附件:完整的 {os.path.basename(html_file_path)}")
return True
except smtplib.SMTPAuthenticationError:
print("SMTP认证失败,请检查邮箱账号和密码")
return False
except smtplib.SMTPException as e:
print(f"SMTP错误:{e}")
return False
except Exception as e:
print(f"发送邮件时出错:{e}")
return False
def send_html_email_with_extraction_advanced(smtp_server, smtp_port, sender_email, sender_password,
receiver_email, html_file_path,
selector_type='xpath',
selector_value='/html/body/div/div[5]/div/div/h3',
include_parent=False,
subject=None, cc_emails=None):
"""
高级版本:支持更多自定义选项
Args:
selector_type: 'xpath' 或 'css'
selector_value: 选择器表达式
include_parent: 是否包含父元素
"""
# 读取HTML文件
try:
with open(html_file_path, 'r', encoding='utf-8') as file:
full_html = file.read()
except Exception as e:
print(f"读取文件出错: {e}")
return False
# 提取元素
if selector_type == 'xpath':
extracted = extract_element_html(full_html, selector_value)
if include_parent and extracted:
# 找到父元素并提取
from lxml import html
tree = html.fromstring(full_html)
elements = tree.xpath(selector_value)
if elements:
parent = elements[0].getparent()
extracted = html.tostring(parent, encoding='unicode')
else:
extracted = extract_element_css(full_html, selector_value)
if include_parent and extracted:
soup = BeautifulSoup(full_html, 'html.parser')
element = soup.select_one(selector_value)
if element and element.parent:
extracted = str(element.parent)
# 创建邮件(后续代码与之前相同...)
# 创建邮件对象
msg = MIMEMultipart('mixed')
msg['From'] = formataddr(('Sender', sender_email))
if isinstance(receiver_email, list):
msg['To'] = ', '.join(receiver_email)
else:
msg['To'] = receiver_email
if not subject:
subject = f"HTML Report (Extracted): {os.path.basename(html_file_path)}"
msg['Subject'] = subject
if cc_emails:
if isinstance(cc_emails, list):
msg['Cc'] = ', '.join(cc_emails)
else:
msg['Cc'] = cc_emails
# 构建邮件正文
msg_alternative = MIMEMultipart('alternative')
msg.attach(msg_alternative)
plain_text = f"请查看附件: {os.path.basename(html_file_path)}"
msg_alternative.attach(MIMEText(plain_text, 'plain', 'utf-8'))
if extracted:
wrapped_html = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {{ font-family: Arial, sans-serif; padding: 20px; }}
.extracted-content {{ background: white; padding: 20px; border: 1px solid #ddd; border-radius: 5px; }}
.note {{ color: #666; font-size: 12px; margin-top: 20px; }}
</style>
</head>
<body>
<div class="extracted-content">{extracted}</div>
<div class="note">完整内容请查看附件: {os.path.basename(html_file_path)}</div>
</body>
</html>
"""
msg_alternative.attach(MIMEText(wrapped_html, 'html', 'utf-8'))
else:
msg_alternative.attach(MIMEText(full_html, 'html', 'utf-8'))
# 添加附件
try:
with open(html_file_path, 'rb') as f:
part = MIMEBase('text', 'html')
part.set_payload(f.read())
encoders.encode_base64(part)
part.add_header('Content-Disposition', f'attachment; filename="{os.path.basename(html_file_path)}"')
msg.attach(part)
except Exception as e:
print(f"添加附件出错: {e}")
# 发送邮件
try:
server = smtplib.SMTP(smtp_server, smtp_port)
server.starttls()
server.login(sender_email, sender_password)
recipients = [receiver_email] if isinstance(receiver_email, str) else receiver_email
if cc_emails:
if isinstance(cc_emails, str):
recipients.append(cc_emails)
else:
recipients.extend(cc_emails)
server.sendmail(sender_email, recipients, msg.as_string())
server.quit()
print("邮件发送成功!")
return True
except Exception as e:
print(f"发送邮件出错: {e}")
return False
# ============================================
# 报表生成代码
# ============================================
class ZabbixReport:
def __init__(self, db_config):
self.db_config = db_config
self.alerts = []
self.stats = {}
self.findings = []
def get_time_range(self):
"""获取时间范围"""
if USE_RECENT_DAYS:
end_time = datetime.now().strftime('%Y-%m-%d 12:00:00')
start_time = (datetime.now() - timedelta(days=RECENT_DAYS)).strftime('%Y-%m-%d 13:00:00')
# OUTPUT_FILE = 'zabbix_report'+start_time+'-'+'end_time'+'.html' # HTML报表输出路径
else:
end_time = input("结束时间 %Y-%m-%d 13:00:00")
start_time = input("开始时间 %Y-%m-%d 13:00:00")
# start_time = FIXED_START_TIME
# end_time = FIXED_END_TIME
return start_time, end_time
def fetch_alerts(self, start_time, end_time):
"""调用存储过程获取告警数据"""
print(f"正在连接数据库: {self.db_config['host']}:{self.db_config['port']}")
print(f"正在获取告警数据: {start_time} 至 {end_time}")
connection = pymysql.connect(**self.db_config)
try:
with connection.cursor(pymysql.cursors.DictCursor) as cursor:
cursor.callproc(STORED_PROCEDURE, (start_time, end_time))
print(start_time, end_time)
self.alerts = cursor.fetchall()
print(f"成功获取 {len(self.alerts)} 条告警记录")
# 将结果写入 Excel
if self.alerts:
df = pd.DataFrame(self.alerts)
excel_path = "alerts_output"+start_time[:10]+"-"+end_time[:10]+".xlsx" # 指定输出路径
df.to_excel(excel_path, index=False, engine='openpyxl')
print(f"数据已导出到 {excel_path}")
else:
print("没有数据可导出")
finally:
connection.close()
def sort_alerts(self):
"""
排序告警列表:
1. 未恢复的告警放在最前面(按问题发生时间倒序)
2. 已恢复的告警按恢复时间倒序排列
"""
unresolved = [a for a in self.alerts if not a['RecoverTime']]
resolved = [a for a in self.alerts if a['RecoverTime']]
# 未恢复的按发生时间倒序(最新的在前)
unresolved.sort(key=lambda x: x['EventTime'] if x['EventTime'] else '', reverse=True)
# 已恢复的按恢复时间倒序
resolved.sort(key=lambda x: x['RecoverTime'] if x['RecoverTime'] else '', reverse=True)
# 合并:未恢复在前,已恢复在后
self.alerts = unresolved + resolved
print(f"排序完成:未恢复 {len(unresolved)} 条,已恢复 {len(resolved)} 条")
def process_statistics(self):
"""处理统计数据"""
stats = {
'total': len(self.alerts),
'total_count': 0,
'hosts': {},
'problems': {},
'severities': {
'Not Classified': 0, 'Information': 0, 'Warning': 0,
'Average': 0, 'High': 0, 'Disaster': 0
},
'unresolved': 0,
'resolved': 0,
'durations': [],
'daily': {},
'problem_types': {}
}
# 问题类型关键词映射
type_keywords = {
'备份问题': ['Veeam', 'Backup', 'backup'],
'zabbix监控代理': ['zabbix', 'Agent', 'agent'],
'磁盘问题': ['Disk', 'disk', 'Space', 'space', '卷'],
'DNS解析': ['DNS', 'dns'],
'内存问题': ['memory', 'Memory', 'free memory'],
'LDAP服务': ['LDAP', 'ldap'],
'CPU过载': ['CPU', 'cpu'],
'端口服务': ['port', 'Port', '端口'],
'网络问题': ['ICMP','icmp'],
'重启': ['restart','重启'],
'其他问题': []
}
for alert in self.alerts:
stats['total_count'] += 1
# 主机统计
host = alert['HostName']
stats['hosts'][host] = stats['hosts'].get(host, 0) + 1
# 问题统计
problem = alert['TriggerName']
stats['problems'][problem] = stats['problems'].get(problem, 0) + 1
# 严重等级
if alert['Severity'] in stats['severities']:
stats['severities'][alert['Severity']] += 1
# 恢复状态和持续时间
if alert['RecoverTime']:
stats['resolved'] += 1
try:
start = datetime.strptime(str(alert['EventTime']), '%Y-%m-%d %H:%M:%S')
end = datetime.strptime(str(alert['RecoverTime']), '%Y-%m-%d %H:%M:%S')
duration = (end - start).total_seconds()
stats['durations'].append(duration)
except:
pass
else:
stats['unresolved'] += 1
# 每日统计
try:
date = str(alert['EventTime']).split(' ')[0]
stats['daily'][date] = stats['daily'].get(date, 0) + 1
except:
pass
# 问题类型分类
found = False
for ptype, keywords in type_keywords.items():
for keyword in keywords:
if keyword.lower() in problem.lower():
stats['problem_types'][ptype] = stats['problem_types'].get(ptype, 0) + 1
found = True
break
if found:
break
if not found:
stats['problem_types']['其他问题'] = stats['problem_types'].get('其他问题', 0) + 1
# 计算平均持续时间
if stats['durations']:
avg_seconds = sum(stats['durations']) / len(stats['durations'])
hours = int(avg_seconds // 3600)
minutes = int((avg_seconds % 3600) // 60)
seconds = int(avg_seconds % 60)
stats['avg_duration'] = f"{hours:02d}小时{minutes:02d}分{seconds:02d}秒"
else:
stats['avg_duration'] = '0小时0分0秒'
# 排序并取前10
stats['hosts'] = dict(sorted(stats['hosts'].items(), key=lambda x: x[1], reverse=True)[:10])
stats['problems'] = dict(sorted(stats['problems'].items(), key=lambda x: x[1], reverse=True)[:10])
self.stats = stats
return stats
def generate_findings(self):
"""生成关键发现"""
findings = []
stats = self.stats
# 1. 未恢复告警数量(最重要的信息放在第一条)
if stats['unresolved'] > 0:
findings.append({
'level': 'critical',
'text': f"当前有 {stats['unresolved']} 个告警未恢复,需要立即处理!"
})
# 2. 最常见问题
if stats['problems']:
most_common = list(stats['problems'].items())[0]
findings.append({
'level': 'warning',
'text': f"最常见问题:\"{most_common[0]}\" 出现 {most_common[1]} 次"
})
# 3. 受影响最严重主机
if stats['hosts']:
most_affected = list(stats['hosts'].items())[0]
findings.append({
'level': 'warning',
'text': f"受影响最严重主机:{most_affected[0]},共 {most_affected[1]} 次告警"
})
# 4. 严重告警统计
critical = stats['severities']['High'] + stats['severities']['Disaster']
if critical > 0:
findings.append({
'level': 'critical',
'text': f"存在 {critical} 个严重级别告警(High/Disaster),需要立即关注"
})
# 5. 告警解决率
if stats['total'] > 0:
resolution_rate = round((stats['resolved'] / stats['total']) * 100, 1)
findings.append({
'level': 'info',
'text': f"告警解决率:{resolution_rate}%({stats['resolved']}/{stats['total']} 已恢复)"
})
self.findings = findings
return findings
def generate_html(self, start_time, end_time):
"""生成HTML报表"""
stats = self.stats
# 构建JSON数据 - 包含百分比信息
total_severity = sum(stats['severities'].values())
total_type = sum(stats['problem_types'].values())
chart_data = {
'severities': stats['severities'],
'hosts': stats['hosts'],
'problem_types': stats['problem_types'],
'daily': stats['daily'],
'severity_percentages': {k: round(v / total_severity * 100, 1) if total_severity > 0 else 0 for k, v in
stats['severities'].items()},
'type_percentages': {k: round(v / total_type * 100, 1) if total_type > 0 else 0 for k, v in
stats['problem_types'].items()}
}
json_data = json.dumps(chart_data, ensure_ascii=False)
# 严重等级颜色映射
severity_colors = {
'Disaster': '#dc2626',
'High': '#f97316',
'Average': '#eab308',
'Warning': '#3b82f6',
'Information': '#6b7280',
'Not Classified': '#6b7280'
}
# 生成告警表格行(数据已经排序:未恢复在前,已恢复在后)
alert_rows = ''
unresolved_count = 0
resolved_count = 0
for alert in self.alerts:
color = severity_colors.get(alert['Severity'], '#6b7280')
# 区分未恢复和已恢复的样式
if not alert['RecoverTime']:
recover_time = '<span style="color: red; font-weight: bold;">⚠ 未恢复</span>'
duration = '<span style="color: red;">进行中...</span>'
row_class = 'unresolved-row'
unresolved_count += 1
else:
recover_time = self._escape(alert['RecoverTime'])
duration = alert['Duration'] if alert['Duration'] else '已恢复'
row_class = 'resolved-row'
resolved_count += 1
alert_rows += f'''
<tr class="{row_class}">
<td><strong>{self._escape(alert['HostName'])}</strong></td>
<td>{self._escape(alert['IP'])}</td>
<td>{self._escape(alert['TriggerName'])}</td>
<td>
<span class="severity-indicator" style="background: {color}"></span>
{self._escape(alert['Severity'])}
</td>
<td>{self._escape(alert['EventTime'])}</td>
<td>{recover_time}</td>
<td>{duration}</td>
</tr>'''
# 生成关键发现
findings_html = ''
for finding in self.findings:
findings_html += f'''
<li>
<span class="finding-icon {finding['level']}">!</span>
{finding['text']}
</li>'''
# HTML模板
html = f'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Zabbix 告警统计报表</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2"></script>
<style>
* {{ margin: 0; padding: 0; box-sizing: border-box; }}
body {{
font-family: 'Microsoft YaHei', 'Segoe UI', Arial, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
padding: 20px;
}}
.container {{
max-width: 1400px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.95);
border-radius: 20px;
padding: 30px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
}}
.header {{
background: linear-gradient(135deg, #1a73e8 0%, #1557b0 100%);
color: white;
padding: 30px 40px;
border-radius: 15px;
margin-bottom: 30px;
box-shadow: 0 10px 30px rgba(26, 115, 232, 0.3);
}}
.header h1 {{ font-size: 32px; margin-bottom: 10px; }}
.header p {{ font-size: 16px; opacity: 0.9; }}
.summary-cards {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 20px;
margin-bottom: 30px;
}}
.card {{
background: white;
border-radius: 15px;
padding: 25px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
transition: all 0.3s ease;
border: 1px solid #f0f0f0;
}}
.card:hover {{
transform: translateY(-5px);
box-shadow: 0 8px 25px rgba(0,0,0,0.15);
}}
.card-title {{ font-size: 13px; color: #999; margin-bottom: 10px; text-transform: uppercase; letter-spacing: 1px; }}
.card-value {{ font-size: 36px; font-weight: bold; color: #1a73e8; }}
.card-value.warning {{ color: #f59e0b; }}
.card-value.danger {{ color: #ef4444; }}
.card-value.success {{ color: #10b981; }}
.card-subtitle {{ font-size: 12px; color: #bbb; margin-top: 8px; }}
.charts-container {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-bottom: 30px;
}}
.chart-wrapper {{
background: white;
border-radius: 15px;
padding: 25px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
border: 1px solid #f0f0f0;
}}
.chart-title {{
font-size: 18px;
color: #333;
margin-bottom: 20px;
padding-bottom: 15px;
border-bottom: 2px solid #f0f2f5;
font-weight: 600;
}}
.table-container {{
background: white;
border-radius: 15px;
padding: 25px;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
overflow-x: auto;
border: 1px solid #f0f0f0;
}}
table {{ width: 100%; border-collapse: collapse; }}
th {{
background: #f8f9fa;
padding: 15px 20px;
text-align: left;
font-size: 13px;
font-weight: 600;
color: #666;
border-bottom: 2px solid #e9ecef;
white-space: nowrap;
}}
td {{
padding: 12px 20px;
border-bottom: 1px solid #f5f5f5;
font-size: 13px;
}}
tr:hover {{ background: #f8f9ff; }}
/* 未恢复告警行样式 - 高亮显示 */
.unresolved-row {{
background: linear-gradient(90deg, #fff5f5 0%, #ffffff 100%);
}}
.unresolved-row:hover {{
background: linear-gradient(90deg, #ffe0e0 0%, #fff5f5 100%);
}}
.resolved-row {{
opacity: 0.85;
}}
.severity-indicator {{
display: inline-block;
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 8px;
box-shadow: 0 2px 5px rgba(0,0,0,0.2);
}}
.findings {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 15px;
padding: 30px;
margin-bottom: 30px;
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.4);
color: white;
}}
.findings h3 {{
color: white;
margin-bottom: 20px;
font-size: 20px;
text-shadow: 0 2px 4px rgba(0,0,0,0.2);
}}
.findings ul {{ list-style: none; padding: 0; }}
.findings li {{
padding: 12px 0;
border-bottom: 1px solid rgba(255,255,255,0.2);
display: flex;
align-items: center;
gap: 15px;
font-size: 15px;
}}
.findings li:last-child {{ border-bottom: none; }}
.finding-icon {{
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 16px;
font-weight: bold;
flex-shrink: 0;
box-shadow: 0 4px 10px rgba(0,0,0,0.3);
}}
.finding-icon.critical {{ background: #ef4444; }}
.finding-icon.warning {{ background: #f59e0b; }}
.finding-icon.info {{ background: #3b82f6; }}
.toolbar {{
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding: 15px 20px;
background: #f8f9fa;
border-radius: 10px;
}}
.toolbar h3 {{ font-size: 18px; color: #333; }}
.toolbar .status-badge {{
display: inline-block;
padding: 5px 15px;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
margin-left: 10px;
}}
.badge-unresolved {{
background: #fee2e2;
color: #dc2626;
}}
.badge-resolved {{
background: #dcfce7;
color: #16a34a;
}}
.toolbar button {{
background: #1a73e8;
color: white;
border: none;
padding: 10px 20px;
border-radius: 8px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}}
.toolbar button:hover {{ background: #1557b0; }}
.time-stamp {{
text-align: center;
color: #999;
font-size: 12px;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #eee;
}}
@media (max-width: 768px) {{
.charts-container {{ grid-template-columns: 1fr; }}
.summary-cards {{ grid-template-columns: 1fr 1fr; }}
.container {{ padding: 15px; }}
}}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>📊 Zabbix 告警统计报表</h1>
<p>报告期间:{start_time} 至 {end_time} | 生成时间:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
</div>
<!-- 摘要卡片 -->
<div class="summary-cards">
<div class="card">
<div class="card-title">总告警数</div>
<div class="card-value">{stats['total_count']}</div>
<div class="card-subtitle">期间内所有告警</div>
</div>
<div class="card">
<div class="card-title">受影响主机</div>
<div class="card-value">{len(stats['hosts'])}</div>
<div class="card-subtitle">至少有一个告警的主机</div>
</div>
<div class="card">
<div class="card-title">问题类型</div>
<div class="card-value">{len(stats['problems'])}</div>
<div class="card-subtitle">不同类型的告警</div>
</div>
<div class="card">
<div class="card-title">严重告警</div>
<div class="card-value danger">{stats['severities']['High'] + stats['severities']['Disaster']}</div>
<div class="card-subtitle">High/Disaster级别</div>
</div>
<div class="card">
<div class="card-title">未恢复告警</div>
<div class="card-value warning">{stats['unresolved']}</div>
<div class="card-subtitle">需要立即处理</div>
</div>
<div class="card">
<div class="card-title">平均持续时间</div>
<div class="card-value" style="font-size: 24px;">{stats['avg_duration']}</div>
<div class="card-subtitle">已恢复告警平均恢复时间</div>
</div>
</div>
<!-- 图表 -->
<div class="charts-container">
<div class="chart-wrapper" style="position: relative;">
<div class="chart-title">🎯 告警严重等级分布</div>
<div style="position: relative; height: 400px;">
<canvas id="severityChart"></canvas>
</div>
</div>
<div class="chart-wrapper">
<div class="chart-title">🏆 Top 10 问题主机</div>
<div style="height: 400px;">
<canvas id="hostChart"></canvas>
</div>
</div>
<div class="chart-wrapper" style="position: relative;">
<div class="chart-title">📊 告警类型分布</div>
<div style="position: relative; height: 400px;">
<canvas id="typeChart"></canvas>
</div>
</div>
<div class="chart-wrapper">
<div class="chart-title">📈 每日告警趋势</div>
<div style="height: 400px;">
<canvas id="trendChart"></canvas>
</div>
</div>
</div>
<!-- 关键发现 -->
<div class="findings">
<h3>🔍 关键发现与建议</h3>
<ul>
{findings_html}
</ul>
</div>
<!-- 详细告警列表 -->
<div class="table-container">
<div class="toolbar">
<div>
<h3>📋 详细告警列表 ({len(self.alerts)} 条)</h3>
<span class="status-badge badge-unresolved">⚠ {unresolved_count} 条未恢复</span>
<span class="status-badge badge-resolved">✓ {resolved_count} 条已恢复</span>
</div>
<button onclick="exportTableToCSV()">📥 导出CSV</button>
</div>
<table id="alertTable">
<thead>
<tr>
<th>主机名</th>
<th>IP地址</th>
<th>问题描述</th>
<th>严重等级</th>
<th>发生时间</th>
<th>恢复时间</th>
<th>持续时间</th>
</tr>
</thead>
<tbody>
{alert_rows}
</tbody>
</table>
</div>
<div class="time-stamp">
报表由Zabbix告警系统自动生成 | {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
</div>
</div>
<script>
// 注册 datalabels 插件
Chart.register(ChartDataLabels);
const chartData = {json_data};
document.addEventListener('DOMContentLoaded', function() {{
// 1. 严重等级分布图(环形图带文字标签)
const severityColors = ['#6b7280', '#6b7280', '#3b82f6', '#eab308', '#f97316', '#dc2626'];
const severityCtx = document.getElementById('severityChart').getContext('2d');
new Chart(severityCtx, {{
type: 'doughnut',
data: {{
labels: Object.keys(chartData.severities),
datasets: [{{
data: Object.values(chartData.severities),
backgroundColor: severityColors,
borderWidth: 3,
borderColor: 'white',
hoverOffset: 15
}}]
}},
options: {{
responsive: true,
maintainAspectRatio: false,
cutout: '55%',
plugins: {{
legend: {{
position: 'bottom',
labels: {{
padding: 15,
usePointStyle: true,
font: {{ size: 12 }},
generateLabels: function(chart) {{
const data = chart.data;
return data.labels.map(function(label, i) {{
const value = data.datasets[0].data[i];
const total = data.datasets[0].data.reduce((a, b) => a + b, 0);
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0;
return {{
text: label + ': ' + value + '次 (' + percentage + '%)',
fillStyle: data.datasets[0].backgroundColor[i],
hidden: false,
index: i
}};
}});
}}
}}
}},
datalabels: {{
color: '#fff',
font: {{
weight: 'bold',
size: 13
}},
formatter: (value, ctx) => {{
if (value === 0) return '';
const total = ctx.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return percentage + '%';
}},
display: function(context) {{
return context.dataset.data[context.dataIndex] > 0;
}},
backgroundColor: function(context) {{
return context.dataset.backgroundColor[context.dataIndex];
}},
borderRadius: 4,
padding: 6,
clamp: true
}}
}}
}},
plugins: [ChartDataLabels]
}});
// 2. 主机TOP10图(横向柱状图)
const hostCtx = document.getElementById('hostChart').getContext('2d');
const hostLabels = Object.keys(chartData.hosts).reverse();
const hostValues = Object.values(chartData.hosts).reverse();
const hostColors = ['#ef4444','#f97316','#eab308','#22c55e','#3b82f6','#6366f1','#8b5cf6','#ec4899','#14b8a6','#f43f5e'];
new Chart(hostCtx, {{
type: 'bar',
data: {{
labels: hostLabels,
datasets: [{{
label: '告警次数',
data: hostValues,
backgroundColor: hostColors.slice(0, hostLabels.length).reverse(),
borderRadius: 5,
borderSkipped: false
}}]
}},
options: {{
indexAxis: 'y',
responsive: true,
maintainAspectRatio: false,
plugins: {{
legend: {{ display: false }},
datalabels: {{
anchor: 'end',
align: 'end',
color: '#333',
font: {{ weight: 'bold', size: 12 }},
formatter: (value) => value + '次'
}}
}},
scales: {{
x: {{ beginAtZero: true, grid: {{ display: false }} }},
y: {{ grid: {{ display: false }} }}
}}
}},
plugins: [ChartDataLabels]
}});
// 3. 类型分布图(饼图带文字标签和数值)
const typeColors = ['#ef4444','#3b82f6','#22c55e','#f59e0b','#8b5cf6','#ec4899','#14b8a6','#6b7280'];
const typeCtx = document.getElementById('typeChart').getContext('2d');
new Chart(typeCtx, {{
type: 'pie',
data: {{
labels: Object.keys(chartData.problem_types),
datasets: [{{
data: Object.values(chartData.problem_types),
backgroundColor: typeColors,
borderWidth: 3,
borderColor: 'white'
}}]
}},
options: {{
responsive: true,
maintainAspectRatio: false,
plugins: {{
legend: {{
position: 'bottom',
labels: {{
padding: 15,
usePointStyle: true,
font: {{ size: 12 }},
generateLabels: function(chart) {{
const data = chart.data;
return data.labels.map(function(label, i) {{
const value = data.datasets[0].data[i];
const total = data.datasets[0].data.reduce((a, b) => a + b, 0);
const percentage = total > 0 ? ((value / total) * 100).toFixed(1) : 0;
return {{
text: label + ': ' + value + '次 (' + percentage + '%)',
fillStyle: data.datasets[0].backgroundColor[i],
hidden: false,
index: i
}};
}});
}}
}}
}},
datalabels: {{
color: '#fff',
font: {{
weight: 'bold',
size: 13
}},
formatter: (value, ctx) => {{
if (value === 0) return '';
const total = ctx.dataset.data.reduce((a, b) => a + b, 0);
const percentage = ((value / total) * 100).toFixed(1);
return percentage + '%\\n' + value + '次';
}},
textAlign: 'center',
display: function(context) {{
return context.dataset.data[context.dataIndex] > 0;
}},
backgroundColor: function(context) {{
return context.dataset.backgroundColor[context.dataIndex];
}},
borderRadius: 4,
padding: 6,
clamp: true
}}
}}
}},
plugins: [ChartDataLabels]
}});
// 4. 趋势图(折线图)
const trendCtx = document.getElementById('trendChart').getContext('2d');
const dates = Object.keys(chartData.daily).sort();
const counts = dates.map(d => chartData.daily[d]);
new Chart(trendCtx, {{
type: 'line',
data: {{
labels: dates,
datasets: [{{
label: '告警数量',
data: counts,
borderColor: '#3b82f6',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
fill: true,
tension: 0.4,
pointBackgroundColor: '#3b82f6',
pointBorderColor: 'white',
pointBorderWidth: 2,
pointRadius: 6,
pointHoverRadius: 8
}}]
}},
options: {{
responsive: true,
maintainAspectRatio: false,
plugins: {{
legend: {{ display: false }},
datalabels: {{
anchor: 'end',
align: 'top',
color: '#3b82f6',
font: {{ weight: 'bold', size: 11 }},
formatter: (value) => value + '次'
}}
}},
scales: {{
y: {{ beginAtZero: true, grid: {{ color: 'rgba(0,0,0,0.05)' }} }},
x: {{ grid: {{ display: false }} }}
}}
}},
plugins: [ChartDataLabels]
}});
}});
function exportTableToCSV() {{
const table = document.getElementById('alertTable');
const rows = table.querySelectorAll('tr');
let csv = [];
rows.forEach(row => {{
const cols = row.querySelectorAll('th, td');
const rowData = [];
cols.forEach(col => {{
let text = col.textContent.trim();
if (text.includes(',')) text = '"' + text + '"';
rowData.push(text);
}});
csv.push(rowData.join(','));
}});
const BOM = '\\uFEFF';
const blob = new Blob([BOM + csv.join('\\n')], {{ type: 'text/csv;charset=utf-8;' }});
const link = document.createElement('a');
link.href = URL.createObjectURL(blob);
const now = new Date();
const dateStr = now.getFullYear() +
String(now.getMonth() + 1).padStart(2, '0') +
String(now.getDate()).padStart(2, '0');
link.download = 'zabbix_alerts_' + dateStr + '.csv';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}}
</script>
</body>
</html>'''
return html
def _escape(self, text):
"""HTML转义"""
if text is None:
return ''
text = str(text)
text = text.replace('&', '&')
text = text.replace('<', '<')
text = text.replace('>', '>')
text = text.replace('"', '"')
text = text.replace("'", ''')
return text
def save_report(self, html, output_file):
"""保存HTML报表"""
# 确保输出目录存在
output_dir = os.path.dirname(output_file)
if output_dir and not os.path.exists(output_dir):
os.makedirs(output_dir)
with open(output_file, 'w', encoding='utf-8') as f:
f.write(html)
full_path = os.path.abspath(output_file)
print(f"✅ 报表已生成: {full_path}")
return full_path
def open_in_browser(self, file_path):
"""自动打开浏览器"""
if AUTO_OPEN:
try:
import webbrowser
webbrowser.open('file://' + file_path)
print(f"🌐 已自动打开浏览器查看报表")
except:
print(f"⚠️ 无法自动打开浏览器,请手动打开文件: {file_path}")
def run(self):
"""执行完整的报表生成流程"""
print("=" * 60)
print("📊 Zabbix 告警报表生成器")
print("=" * 60)
# 1. 获取时间范围
start_time, end_time = self.get_time_range()
# 2. 获取告警数据
self.fetch_alerts(start_time, end_time)
if not self.alerts:
print("⚠️ 没有找到告警数据,请检查时间范围")
return
# 3. 排序告警列表(未恢复在前,已恢复在后)
print("正在排序告警列表...")
self.sort_alerts()
# 4. 处理统计数据
print("正在处理统计数据...")
self.process_statistics()
# 5. 生成关键发现
print("正在分析关键发现...")
self.generate_findings()
# 6. 生成HTML报表
print("正在生成HTML报表...")
html = self.generate_html(start_time, end_time)
# 7. 保存报表
file_path = self.save_report(html, OUTPUT_FILE)
# 8. 自动打开
self.open_in_browser(file_path)
print("=" * 60)
print("✅ 报表生成完成!")
print("=" * 60)
return file_path
def main():
"""主函数"""
# 创建报表实例
report = ZabbixReport(DB_CONFIG)
# 运行报表生成
report.run()
if __name__ == '__main__':
main()
send_html_email_with_extraction(
smtp_server='10.2.11.1',
smtp_port=25,
sender_email='111@111.com',
sender_password='111#',
receiver_email='111@163.com',
html_file_path='zabbix_report.html',
subject='zabbix_report',
cc_emails=['11@11.com'],
extraction_method='xpath',
extraction_selector='/html/body/div/div[5]'
)