from reportlab.lib import colors from reportlab.lib.pagesizes import A4 from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, Image from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.units import inch from datetime import datetime import logging import os from reportlab.pdfgen import canvas logger = logging.getLogger(__name__) # Thomas-Krenn Farbschema TK_ORANGE = colors.HexColor('#F7941D') TK_DARK_GRAY = colors.HexColor('#333333') TK_LIGHT_GRAY = colors.HexColor('#F5F5F5') TK_WHITE = colors.white TK_GREEN = colors.HexColor('#059669') TK_RED = colors.HexColor('#dc2626') def validate_data(data): """Checks if all required data is present.""" required_fields = ['replication_type', 'nodes', 'result'] for field in required_fields: if field not in data: raise ValueError(f"Missing field: {field}") if not data['nodes']: raise ValueError("No nodes defined") if not data['result'].get('raw_total'): raise ValueError("No calculation results available") def safe_int(value, default=0): """Safely converts a value to an integer.""" if value is None: return default try: return int(str(value).strip()) except (ValueError, TypeError): logger.warning(f"Could not convert value '{value}' to integer. Using default value {default}") return default def safe_float(value, default=0.0): """Safely converts a value to a float.""" if value is None: return default try: return float(str(value).strip()) except (ValueError, TypeError): logger.warning(f"Could not convert value '{value}' to float. Using default value {default}") return default def generate_pdf_report(data): """ Generates a PDF report with Ceph configuration data and results. Args: data (dict): Dictionary with configuration and result data Returns: bytes: PDF file as bytes Raises: ValueError: If required data is missing """ try: # Validate input data validate_data(data) # Create a temporary buffer for the PDF from io import BytesIO buffer = BytesIO() # Create the PDF document doc = SimpleDocTemplate( buffer, pagesize=A4, rightMargin=72, leftMargin=72, topMargin=72, bottomMargin=72, title="Ceph Storage Capacity Report" ) # Define styles styles = getSampleStyleSheet() # Custom styles mit weißer Textfarbe aufgrund des grauen Hintergrunds title_style = ParagraphStyle( 'CustomTitle', parent=styles['Heading1'], fontSize=24, spaceAfter=30, textColor=TK_WHITE ) heading_style = ParagraphStyle( 'CustomHeading', parent=styles['Heading2'], fontSize=16, spaceAfter=12, textColor=TK_ORANGE ) normal_style = ParagraphStyle( 'CustomNormal', parent=styles['Normal'], fontSize=10, spaceAfter=6, textColor=TK_WHITE ) # Custom Style for warnings warning_style = ParagraphStyle( 'Warning', parent=styles['Normal'], textColor=TK_RED, fontSize=10, spaceAfter=6 ) # Create story (content) story = [] # Hintergrundfarbe und Canvas-Anpassung # Definiere die Hintergrundfarbe TK_BACKGROUND_GRAY = colors.HexColor('#1f2937') # Dunkelgrau wie in der Website (dark-bg) # Funktion zum Zeichnen des Hintergrunds def add_page_background(canvas, doc): canvas.saveState() canvas.setFillColor(TK_BACKGROUND_GRAY) canvas.rect(0, 0, doc.pagesize[0], doc.pagesize[1], fill=True, stroke=False) canvas.restoreState() # Title with modern styling story.append(Paragraph("Ceph Storage Capacity Report", title_style)) story.append(Paragraph(f"Generated on: {datetime.now().strftime('%d.%m.%Y %H:%M')}", normal_style)) story.append(Spacer(1, 20)) # Configuration overview with modern styling story.append(Paragraph("Configuration Overview", heading_style)) story.append(Spacer(1, 12)) # Replication type with modern table style config_data = [ ["Replication Type", data['replication_type']], ["Storage Unit", data.get('storage_unit', 'GB')] ] # Add replication-specific parameters if data['replication_type'] == 'replication': config_data.extend([ ["Number of Replicas", str(data.get('replicas', 3))], ["Minimum Size", str(data.get('min_size', 2))] ]) else: # Erasure Coding config_data.extend([ ["Data Chunks (k)", str(data.get('k', 4))], ["Coding Chunks (m)", str(data.get('m', 2))] ]) # Modern table style for configurations t = Table(config_data, colWidths=[3*inch, 3*inch]) t.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), TK_ORANGE), ('TEXTCOLOR', (0, 0), (-1, 0), TK_WHITE), ('ALIGN', (0, 0), (-1, -1), 'LEFT'), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, 0), 12), ('BOTTOMPADDING', (0, 0), (-1, 0), 12), ('BACKGROUND', (0, 1), (-1, -1), TK_DARK_GRAY), ('TEXTCOLOR', (0, 1), (-1, -1), TK_WHITE), ('FONTNAME', (0, 1), (-1, -1), 'Helvetica'), ('FONTSIZE', (0, 1), (-1, -1), 10), ('ALIGN', (0, 1), (-1, -1), 'LEFT'), ('GRID', (0, 0), (-1, -1), 1, TK_LIGHT_GRAY) ])) story.append(t) # Add nodes information with modern styling story.append(Spacer(1, 20)) story.append(Paragraph("Nodes Configuration", heading_style)) story.append(Spacer(1, 12)) nodes_data = [["Node", "OSD Count", f"OSD Size ({data.get('storage_unit', 'GB')})"]] for i, node in enumerate(data.get('nodes', []), 1): osd_size = safe_float(node.get('osd_size_gb', 0)) if data.get('storage_unit') == 'TB': osd_size = osd_size / 1024 # Convert GB to TB for display nodes_data.append([ f"Node {i}", str(node.get('osd_count', 0)), f"{osd_size:.2f}" ]) # Nodes table t = Table(nodes_data, colWidths=[2*inch, 2*inch, 2*inch]) t.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), TK_ORANGE), ('TEXTCOLOR', (0, 0), (-1, 0), TK_WHITE), ('ALIGN', (0, 0), (-1, -1), 'CENTER'), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, 0), 12), ('BOTTOMPADDING', (0, 0), (-1, 0), 12), ('BACKGROUND', (0, 1), (-1, -1), TK_DARK_GRAY), ('TEXTCOLOR', (0, 1), (-1, -1), TK_WHITE), ('FONTNAME', (0, 1), (-1, -1), 'Helvetica'), ('FONTSIZE', (0, 1), (-1, -1), 10), ('ALIGN', (0, 1), (-1, -1), 'CENTER'), ('GRID', (0, 0), (-1, -1), 1, TK_LIGHT_GRAY) ])) story.append(t) # Results with modern styling story.append(Spacer(1, 20)) story.append(Paragraph("Results", heading_style)) story.append(Spacer(1, 12)) result_data = [ ["Total Raw Storage", f"{safe_float(data['result']['raw_total'])} {data.get('storage_unit', 'GB')}"], ["Max. Recommended Usage", f"{safe_float(data['result'].get('max_usage_percent', 0))}%"], ["Max. Usable Storage", f"{safe_float(data['result'].get('max_usage_tb' if data.get('storage_unit') == 'TB' else 'max_usage_gb', 0))} {data.get('storage_unit', 'GB')}"] ] # Results table t = Table(result_data, colWidths=[3*inch, 3*inch]) t.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, 0), TK_ORANGE), ('TEXTCOLOR', (0, 0), (-1, 0), TK_WHITE), ('ALIGN', (0, 0), (-1, -1), 'LEFT'), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, 0), 12), ('BOTTOMPADDING', (0, 0), (-1, 0), 12), ('BACKGROUND', (0, 1), (-1, -1), TK_DARK_GRAY), ('TEXTCOLOR', (0, 1), (-1, -1), TK_WHITE), ('FONTNAME', (0, 1), (-1, -1), 'Helvetica'), ('FONTSIZE', (0, 1), (-1, -1), 10), ('ALIGN', (0, 1), (-1, -1), 'LEFT'), ('GRID', (0, 0), (-1, -1), 1, TK_LIGHT_GRAY) ])) story.append(t) # Fault tolerance with modern styling story.append(Spacer(1, 20)) story.append(Paragraph("Fault Tolerance Analysis", heading_style)) story.append(Spacer(1, 12)) # Node fault tolerance with status indicator node_tolerance = "Available" if data['result'].get('node_failure_tolerance', False) else "Not Available" status_color = TK_GREEN if node_tolerance == "Available" else TK_RED status_style = ParagraphStyle( 'Status', parent=normal_style, textColor=status_color, fontSize=12, spaceAfter=6 ) story.append(Paragraph(f"Node Fault Tolerance: {node_tolerance}", status_style)) story.append(Paragraph(data['result'].get('node_failure_info', 'No information available'), normal_style)) if data['result'].get('multi_failure_tolerance', False): story.append(Paragraph(f"Multi-Node Fault Tolerance: {safe_int(data['result'].get('max_failure_nodes', 0))} Nodes", normal_style)) # OSD fault tolerance with status indicator story.append(Spacer(1, 12)) osd_tolerance = "Available" if data['result'].get('osd_failure_tolerance', False) else "Limited" status_color = TK_GREEN if osd_tolerance == "Available" else TK_RED status_style = ParagraphStyle( 'Status', parent=normal_style, textColor=status_color, fontSize=12, spaceAfter=6 ) story.append(Paragraph(f"OSD Fault Tolerance: {osd_tolerance}", status_style)) story.append(Paragraph(data['result'].get('osd_failure_info', 'No information available'), normal_style)) # Recommendations mit besserem Kontrast für den dunklen Hintergrund story.append(Spacer(1, 20)) story.append(Paragraph("Recommendations", heading_style)) story.append(Spacer(1, 12)) recommendations = [ "Ensure that after node failures, enough capacity is available to avoid reaching the 'full ratio'.", "For replication, the number of nodes should be greater than the replication factor.", "For erasure coding, the number of nodes should be greater than k+m (data chunks + coding chunks).", "Recovery performance depends on network and storage speed.", "Note that after a node failure, rebalancing the cluster takes longer the larger the failed node is." ] # Erstelle Box für Empfehlungen mit besserer Lesbarkeit recommendations_text = [] for rec in recommendations: recommendations_text.append(Paragraph(f"• {rec}", normal_style)) recommendation_table = Table([[item] for item in recommendations_text], colWidths=[6*inch]) recommendation_table.setStyle(TableStyle([ ('BACKGROUND', (0, 0), (-1, -1), TK_DARK_GRAY), ('TEXTCOLOR', (0, 0), (-1, -1), TK_WHITE), ('ALIGN', (0, 0), (-1, -1), 'LEFT'), ('VALIGN', (0, 0), (-1, -1), 'TOP'), ('GRID', (0, 0), (-1, -1), 1, TK_LIGHT_GRAY) ])) story.append(recommendation_table) # Footer with Thomas-Krenn.AG branding story.append(Spacer(1, 30)) footer_style = ParagraphStyle( 'Footer', parent=normal_style, fontSize=8, textColor=colors.HexColor('#9ca3af') # Helleres Grau für bessere Lesbarkeit auf dunklem Hintergrund ) story.append(Paragraph("Generated by Thomas-Krenn.AG Ceph Storage Calculator", footer_style)) # Generate PDF with background doc.build(story, onFirstPage=add_page_background, onLaterPages=add_page_background) # Return PDF bytes return buffer.getvalue() except Exception as e: logger.error(f"Error during PDF generation: {str(e)}") raise