【Grafana】grafana-image-renderer配合python脚本实现仪表盘导出pdf

背景

  • os:centos7
  • Grafana:v12
  • grafana-image-renderer:v4.0.10
  • 插件:否

grafana-image-renderer可以以插件形式启动,也可以以单独服务启动,在centos7插件启动时,报错glibc版本太低,未找到Glibc_2.27,所以以单独服务启动。

一、安装Grafana

bash 复制代码
# 解压部署
tar zxvf grafana-12.1.0.linux-amd64.tar.gz -C /usr/local/
cd /usr/local/grafana-v12.1.0/
# 编辑配置文件
vi conf/defaults.ini
# 调试简单
[auth.anonymous]
enabled = true
# 配置rendering地址
[rendering]
server_url = http://192.168.113.138:8081/render
callback_url = http://192.168.113.138:3000/
# 启动服务
nohup bin/grafana server &

二、启动grafana-image-renderer

bash 复制代码
docker network create grafana
docker pull docker.1ms.run/grafana/grafana-image-renderer:v4.0.10
docker run --network grafana --name renderer -p 8081:8081 --rm --detach docker.1ms.run/grafana/grafana-image-renderer:v4.0.10

三、测试接口

直接访问:http://192.168.113.138:3000/render/d-solo/bdd48668-7073-4adc-b548-276133845e71?orgId=1&panelId=2&width=1000&height=500&from=now-24h&to=now

四、编写py脚本及效果

脚本由AI大模型生成。如果有能力可以修改Grafana源码,页面传入DASHBOARD_UID 即可实现点击下载。

python 复制代码
import requests
import time
from typing import List, Dict
from PIL import Image, ImageDraw, ImageFont  # ✅ 确保包含 ImageFont
import img2pdf
from datetime import datetime
import os

# ================== 配置区 ==================
GRAFANA_HOST = "http://192.168.113.138:3000"
API_KEY = "YOUR_API_KEY"  # Service Account Token 或 API Key
DASHBOARD_UID = "bdd48668-7073-4adc-b548-276133845e71"  # 替换为你的 Dashboard UID
OUTPUT_DIR = "panels_output"  # 输出目录
DELAY_BETWEEN_SHOTS = 1.0  # 每次截图间隔(秒),避免渲染服务压力过大
FROM = "now-24h"
TO = "now"
WIDTH = 1200
HEIGHT = 600
TIMEOUT = 30
# ==========================================
# 创建输出目录
os.makedirs(OUTPUT_DIR, exist_ok=True)
headers = {
    "Authorization": f"Bearer {API_KEY}",
    "User-Agent": "Grafana-Report-Service/v1.0"
}


def get_dashboard_json(uid: str) -> Dict:
    """获取 Dashboard JSON 结构"""
    url = f"{GRAFANA_HOST}/api/dashboards/uid/{uid}"
    response = requests.get(url, headers=headers, timeout=10)
    if response.status_code != 200:
        raise Exception(f"获取 Dashboard 失败: {response.status_code} {response.text}")
    return response.json()


def extract_panels(dashboard_data: Dict) -> List[Dict]:
    """
    精准提取所有可渲染的 panel(排除 row、text 等不可截图类型)
    基于 Grafana v12 的 Dashboard JSON 结构
    """
    panels = []
    dashboard = dashboard_data["dashboard"]
    skip_types = {"row", "text", "alertlist", "dashboard-link", "separator"}

    def is_renderable_panel(obj):
        # 必须是字典,有 id 和 type
        if not isinstance(obj, dict):
            return False
        if "id" not in obj or "type" not in obj:
            return False
        panel_type = obj["type"]
        # 排除不可渲染的类型
        if panel_type in skip_types:
            return False
        # 确保 id 是数字(真实的 panel id)
        if not isinstance(obj["id"], int):
            return False
        return True

    def walk(obj):
        if isinstance(obj, dict):
            # 如果当前对象是可渲染 panel,加入结果
            if is_renderable_panel(obj):
                title = obj.get("title") or f"Panel_{obj['id']}"
                panels.append({
                    "id": obj["id"],
                    "title": title.strip(),
                    "type": obj["type"]
                })
            # 递归遍历所有值
            for value in obj.values():
                walk(value)
        elif isinstance(obj, list):
            for item in obj:
                walk(item)

    walk(dashboard)
    return panels


def sanitize_filename(name: str) -> str:
    """清理文件名,移除不合法字符"""
    return "".join(c if c.isalnum() or c in " _-." else "_" for c in name)


def render_panel_to_png(panel_id: int, title: str, output_path: str) -> bool:
    """渲染单个 panel 为 PNG"""
    slug = DASHBOARD_UID
    render_url = f"{GRAFANA_HOST}/render/d-solo/{DASHBOARD_UID}/{slug}"
    params = {
        "orgId": 1,
        "panelId": panel_id,
        "width": WIDTH,
        "height": HEIGHT,
        "tz": "Asia/Shanghai",
        "from": FROM,
        "to": TO,
        "deviceScaleFactor": 1.5,
        "renderFormat": "png"
    }
    try:
        response = requests.get(
            render_url,
            params=params,
            headers=headers,
            timeout=TIMEOUT
        )
        if response.status_code == 200:
            with open(output_path, "wb") as f:
                f.write(response.content)
            print(f"✅ [{panel_id}] '{title}' → 保存成功: {output_path}")
            return True
        else:
            print(f"❌ [{panel_id}] '{title}' → 渲染失败: {response.status_code}")
            print(f"   响应: {response.text[:200]}")
            return False
    except Exception as e:
        print(f"❌ [{panel_id}] '{title}' → 异常: {e}")
        return False


def generate_pdf_report(dashboard_title: str, panel_images: list, output_pdf: str):
    """
    使用 img2pdf + Pillow 生成 PDF 报告
    panel_images: 图片文件路径列表(字符串)
    """
    try:
        # ========== 1. 创建封面页 ==========
        try:
            # 尝试使用黑体(Windows 通常有 simhei.ttf)
            font_title = ImageFont.truetype("simhei.ttf", 60)
            font_text = ImageFont.truetype("simhei.ttf", 40)
        except:
            # 如果没有中文字体,用默认(可能显示乱码)
            font_title = ImageFont.load_default()
            font_text = ImageFont.load_default()

        # 使用与面板图像相同的宽度
        panel_width = WIDTH  # 从配置中获取宽度,默认为1200
        # 保持宽高比计算封面高度(使用A4纸的宽高比)
        cover_height = int(panel_width * 1.414)  # A4纸的宽高比约为1:1.414

        cover = Image.new('RGB', (panel_width, cover_height), 'white')
        draw = ImageDraw.Draw(cover)

        # 绘制标题
        title_pos = (panel_width // 2, cover_height // 3)  # 居中 X,Y=1/3处
        time_pos = (panel_width // 2, cover_height // 2)  # 居中 X,Y=1/2处

        # 获取文本边界框以居中
        bbox_title = draw.textbbox((0, 0), dashboard_title, font=font_title)
        title_width = bbox_title[2] - bbox_title[0]
        draw.text((panel_width // 2 - title_width // 2, cover_height // 3),
                  dashboard_title, fill="black", font=font_title)

        generate_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
        time_text = f"生成时间:{generate_time}"
        bbox_time = draw.textbbox((0, 0), time_text, font=font_text)
        time_width = bbox_time[2] - bbox_time[0]
        draw.text((panel_width // 2 - time_width // 2, cover_height // 2),
                  time_text, fill="gray", font=font_text)

        # 保存封面
        cover_path = os.path.join(OUTPUT_DIR, "cover_page.png")
        cover.save(cover_path, "PNG")
        print(f"🎨 封面页已生成: {cover_path} (宽度: {panel_width}px)")

        # ========== 2. 构建图片路径列表 ==========
        # 确保所有 panel_images 都是字符串路径,且文件存在
        image_paths = [cover_path]  # 先加封面
        for img_path in panel_images:
            if isinstance(img_path, str) and os.path.exists(img_path):
                image_paths.append(img_path)
            else:
                print(f"⚠️ 跳过不存在的图片: {img_path}")

        if len(image_paths) < 2:
            print("❌ 没有足够的图片生成 PDF")
            return False

        # ========== 3. 转换为 PDF ==========
        with open(output_pdf, "wb") as f:
            f.write(img2pdf.convert(image_paths))

        print(f"📄 PDF 报告已生成: {output_pdf}")
        return True
    except Exception as e:
        print(f"❌ PDF 生成失败: {e}")
        import traceback
        traceback.print_exc()
        return False


def main():
    print(f"🔍 正在获取 Dashboard: {DASHBOARD_UID}")
    try:
        data = get_dashboard_json(DASHBOARD_UID)
        dashboard_title = data["dashboard"]["title"]
        print(f"📊 获取成功: '{dashboard_title}'")
        panels = extract_panels(data)
        print(f"📦 共找到 {len(panels)} 个可渲染的 panel\n")
        success_count = 0
        image_paths = []
        for i, panel in enumerate(panels, 1):
            title_clean = sanitize_filename(panel["title"])
            output_file = f"panel_{panel['id']}_{title_clean}.png"
            output_path = os.path.join(OUTPUT_DIR, output_file)
            print(f"🖼️  [{i}/{len(panels)}] 渲染中: {panel['title']}")
            if render_panel_to_png(panel["id"], panel["title"], output_path):
                success_count += 1
                image_paths.append(output_path)  # 收集成功生成的图片
            time.sleep(DELAY_BETWEEN_SHOTS)
        print(f"\n🎉 批量截图完成!成功: {success_count}/{len(panels)} 个")
        # ==== 生成 PDF ====
        if success_count > 0:
            output_pdf = f"report_{DASHBOARD_UID}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
            generate_pdf_report(dashboard_title, sorted(image_paths), output_pdf)
        else:
            print("❌ 没有成功截图,跳过 PDF 生成")
    except Exception as e:
        print(f"💥 执行失败: {e}")


if __name__ == "__main__":
    main()

Grafana可视化:

效果: