26Y02n1

Young-Kyoo Kim·5일 전
import os
import glob
import re
import sys
import logging
import pandas as pd
import openpyxl
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils.dataframe import dataframe_to_rows
from openpyxl.chart import BarChart, LineChart, Reference
from openpyxl.formatting.rule import CellIsRule
from openpyxl.drawing.image import Image as OpenpyxlImage
from minio import Minio
from minio.error import S3Error

# 로깅 설정
logging.basicConfig(
    level=logging.INFO,
    format='%(asctime)s [%(levelname)s] %(message)s',
    handlers=[logging.StreamHandler(sys.stdout)]
)
logger = logging.getLogger(__name__)

# 환경 설정 (기본값 설정 및 환경변수 오버라이드 가능)
DATA_DIR = os.getenv("DATA_DIR", "./data/merged")
CHARTS_DIR = os.getenv("CHARTS_DIR", "./data/charts") # 차트 이미지 폴더 기본값
OUTPUT_DIR = os.getenv("OUTPUT_DIR", "./data")
MINIO_ENDPOINT = os.getenv("MINIO_ENDPOINT", "minio.ai-stor.local:9000")
MINIO_ACCESS_KEY = os.getenv("MINIO_ACCESS_KEY", "admin")
MINIO_SECRET_KEY = os.getenv("MINIO_SECRET_KEY", "password")
MINIO_BUCKET = os.getenv("MINIO_BUCKET", "devops-test")
USE_SSL = os.getenv("MINIO_USE_SSL", "False").lower() in ("true", "1", "yes")

def get_minio_client():
    """MinIO AIStor 클라이언트 초기화"""
    try:
        return Minio(
            MINIO_ENDPOINT,
            access_key=MINIO_ACCESS_KEY,
            secret_key=MINIO_SECRET_KEY,
            secure=USE_SSL
        )
    except Exception as e:
        logger.error(f"MinIO 클라이언트 초기화 실패: {e}")
        return None

def upload_to_aistor(local_file_path, object_name):
    """생성된 엑셀 리포트를 MinIO AIStor devops-test 버킷에 업로드"""
    client = get_minio_client()
    if not client:
        logger.warning("MinIO 클라이언트를 사용할 수 없어 업로드를 건너뜁니다.")
        return False
    
    try:
        if not client.bucket_exists(MINIO_BUCKET):
            logger.info(f"버킷이 존재하지 않아 생성합니다: {MINIO_BUCKET}")
            client.make_bucket(MINIO_BUCKET)
            
        logger.info(f"AIStor 업로드 시작: {local_file_path} -> bucket: {MINIO_BUCKET}/{object_name}")
        client.fput_file(MINIO_BUCKET, object_name, local_file_path)
        logger.info("AIStor 업로드 완료 성공.")
        return True
    except S3Error as e:
        logger.error(f"AIStor 업로드 중 S3 에러 발생: {e}")
    except Exception as e:
        logger.error(f"AIStor 업로드 중 알 수 없는 오류 발생: {e}")
    return False

def style_sheet(ws, title_text):
    """시트 기본 스타일링 (헤더, 그리드라인, 폰트 등)"""
    ws.views.sheetView[0].showGridLines = True
    
    font_family = "Arial"
    header_fill = PatternFill(start_color="2F4F4F", end_color="2F4F4F", fill_type="solid") # Muted Dark Slate
    header_font = Font(name=font_family, size=11, bold=True, color="FFFFFF")
    data_font = Font(name=font_family, size=10, color="000000")
    zebra_fill = PatternFill(start_color="F9FBFB", end_color="F9FBFB", fill_type="solid")
    
    thin_border = Border(
        left=Side(style='thin', color='E0E0E0'),
        right=Side(style='thin', color='E0E0E0'),
        top=Side(style='thin', color='E0E0E0'),
        bottom=Side(style='thin', color='E0E0E0')
    )
    
    for row_idx, row in enumerate(ws.iter_rows(min_row=1, max_row=ws.max_row, min_col=1, max_col=ws.max_column), start=1):
        is_header = (row_idx == 1)
        for cell in row:
            if is_header:
                cell.fill = header_fill
                cell.font = header_font
                cell.alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
            else:
                cell.font = data_font
                cell.border = thin_border
                if row_idx % 2 == 0:
                    cell.fill = zebra_fill
                
                if isinstance(cell.value, (int, float)):
                    if cell.value == 0:
                        cell.number_format = '#,##0'
                    elif 'pct' in str(cell.column) or '비율' in str(ws.cell(1, cell.column).value or ''):
                        cell.number_format = '0.0%'
                    elif isinstance(cell.value, float):
                        cell.number_format = '#,##0.00'
                    else:
                        cell.number_format = '#,##0'
                        
    for col in ws.columns:
        max_len = 0
        col_letter = openpyxl.utils.get_column_letter(col[0].column)
        for cell in col:
            if cell.value:
                max_len = max(max_len, len(str(cell.value)))
        ws.column_dimensions[col_letter].width = max(max_len + 3, 12)
        
    ws.freeze_panes = 'A2'

def add_pareto_chart(ws, data_len):
    """네임스페이스별 CPU 낭비 현황 오픈pyxl 자체 파레토 차트 삽입"""
    if data_len <= 0:
        logger.warning("차트를 생성할 데이터가 없어 차트 삽입을 건너뜁니다.")
        return

    try:
        chart = BarChart()
        chart.type = "col"
        chart.style = 10
        chart.title = "Namespace CPU Waste Pareto Analysis (Programmatic)"
        chart.y_axis.title = "CPU Waste (Core-Hours)"
        chart.x_axis.title = "Namespace"

        data = Reference(ws, min_col=2, min_row=1, max_row=data_len + 1)
        cats = Reference(ws, min_col=1, min_row=2, max_row=data_len + 1)
        
        chart.add_data(data, titles_from_data=True)
        chart.set_categories(cats)

        line_chart = LineChart()
        line_data = Reference(ws, min_col=3, min_row=1, max_row=data_len + 1)
        line_chart.add_data(line_data, titles_from_data=True)
        
        line_chart.y_axis.axId = 200
        line_chart.y_axis.title = "Cumulative Percentage"
        line_chart.y_axis.crosses = "max"
        
        chart += line_chart
        ws.add_chart(chart, f"E2")
        logger.info("자체 스크립트 기반 파레토 차트가 시트(E2 위치)에 삽입되었습니다.")
    except Exception as e:
        logger.error(f"파레토 차트 생성 중 오류 발생: {e}")

def inject_step3_external_charts(wb, cluster, date):
    """
    Step 3에서 생성된 19개 차트 중 미사용 중인 나머지 11개 차트를 
    별도의 'Charts' 전용 시트를 생성하여 바둑판 배열로 자동 삽입합니다.
    - 기존 사용 8개: 1, 2, 3, 5, 6, 9, 10, 15
    - 신규 추가 11개: 나머지 번호들
    """
    used_charts = [1, 2, 3, 5, 6, 9, 10, 15]
    all_charts = list(range(1, 20))
    remaining_charts = [c for c in all_charts if c not in used_charts]
    
    logger.info(f"[{cluster} | {date}] 미사용 차트({len(remaining_charts)}개) 일괄 배치를 위한 'Charts' 시트 생성 중...")
    
    ws_charts = wb.create_sheet(title="Charts")
    ws_charts.views.sheetView[0].showGridLines = True
    
    # 폰트 세팅
    title_font = Font(name="Arial", size=14, bold=True, color="2F4F4F")
    ws_charts["A1"] = f"Step 3 Additional Dashboards & Charts ({cluster} - {date})"
    ws_charts["A1"].font = title_font
    
    # 배치를 위한 변수 (2열 구조 배열)
    col_positions = ['B', 'N'] # B열, N열에 차트를 나란히 배치하여 겹침 방지
    current_row = 3
    col_idx = 0
    
    for chart_id in remaining_charts:
        # 파일명 매핑 규칙 수용 (동적 파티션 이름 및 고정 이름 매칭 버퍼 확보)
        possible_filenames = [
            f"chart_{chart_id}_{cluster}_{date}.png",
            f"chart_{cluster}_{date}_{chart_id}.png",
            f"chart_{chart_id}.png",
            f"chart_fixed_{chart_id}.png"
        ]
        
        chart_file_path = None
        # 설정된 차트 경로 및 데이터 통합 경로 검전
        for fname in possible_filenames:
            for d in [CHARTS_DIR, DATA_DIR, "./data", "."]:
                p = os.path.join(d, fname)
                if os.path.exists(p):
                    chart_file_path = p
                    break
            if chart_file_path:
                break
                
        if chart_file_path:
            try:
                img = OpenpyxlImage(chart_file_path)
                cell_address = f"{col_positions[col_idx]}{current_row}"
                ws_charts.add_image(img, cell_address)
                logger.info(f" -> 차트 {chart_id} 삽입 완료: {chart_file_path} -> cell: {cell_address}")
                
                # 다음 이미지 위치 계산 (2열 배치)
                col_idx += 1
                if col_idx >= len(col_positions):
                    col_idx = 0
                    current_row += 20 # 차트 높이를 고려하여 20행 아래로 이동
            except Exception as e:
                logger.error(f" -> 차트 {chart_id}({chart_file_path}) 삽입 중 openpyxl 예외 발생: {e}")
        else:
            # 실시간 로깅 상태창 제공
            logger.warning(f" -> 차트 {chart_id} 원부 이미지를 찾을 수 없어 건너뜁니다. (검색 패턴 샘플: chart_{chart_id}_*.png)")

def generate_report(cluster, date):
    """동적 매핑된 파일들을 결합하여 최종 엑셀 리포트 빌드"""
    usage_file = os.path.join(DATA_DIR, f"daily_ns_usage_{cluster}_{date}.parquet")
    pareto_file = os.path.join(DATA_DIR, f"pareto_ns_{cluster}_{date}.parquet")
    enriched_file = os.path.join(DATA_DIR, f"daily_enriched_{cluster}_{date}.parquet")
    
    # 파일 유실 여부 검증
    missing_files = [f for f in [usage_file, pareto_file, enriched_file] if not os.path.exists(f)]
    if missing_files:
        logger.error(f"[{cluster} | {date}] 필수 가공원부가 유실되어 빌드를 건너뜁니다: {missing_files}")
        return
        
    logger.info(f"[{cluster} | {date}] 엑셀 리포트 가공 시작...")
    
    # 데이터 로드
    df_usage = pd.read_parquet(usage_file)
    df_pareto = pd.read_parquet(pareto_file)
    df_enriched = pd.read_parquet(enriched_file)
    
    # 워크북 및 시트 구성
    wb = openpyxl.Workbook()
    
    # 기본 시트를 Summary로 변경
    ws_summary = wb.active
    ws_summary.title = "Summary Metrics"
    
    # 요약 정보 작성
    ws_summary.append(["Metric Key", "Value"])
    ws_summary.append(["Cluster Name", cluster])
    ws_summary.append(["Analysis Date", date])
    ws_summary.append(["Total Namespaces", len(df_usage['namespace'].unique()) if 'namespace' in df_usage.columns else 0])
    if 'cpu_waste' in df_pareto.columns:
        ws_summary.append(["Total CPU Waste (Core-Hours)", float(df_pareto['cpu_waste'].sum())])
    style_sheet(ws_summary, "Summary Metrics")
    
    # 1. Daily Namespace Usage 시트
    ws_usage = wb.create_sheet(title="Daily Namespace Usage")
    for r in dataframe_to_rows(df_usage, index=False, header=True):
        ws_usage.append(r)
    style_sheet(ws_usage, "Daily Namespace Usage")
    
    # 2. Pareto Analysis 시트
    ws_pareto = wb.create_sheet(title="Pareto Analysis")
    for r in dataframe_to_rows(df_pareto, index=False, header=True):
        ws_pareto.append(r)
    style_sheet(ws_pareto, "Pareto Analysis")
    # 파레토 차트 내부 렌더링 추가
    add_pareto_chart(ws_pareto, len(df_pareto))
    
    # 3. Enriched Metrics 시트
    ws_enriched = wb.create_sheet(title="Enriched Metrics")
    for r in dataframe_to_rows(df_enriched, index=False, header=True):
        ws_enriched.append(r)
    style_sheet(ws_enriched, "Enriched Metrics")
    
    # [요구사항 반영] Step 3 외부 차트 19개 중 미사용 11개 차트 전용 시트 주입 로직
    inject_step3_external_charts(wb, cluster, date)
    
    if 'cpu_waste' in df_pareto.columns:
        red_fill = PatternFill(start_color="FFC7CE", end_color="FFC7CE", fill_type="solid")
        ws_pareto.conditional_formatting.add(
            f"B2:B{len(df_pareto)+1}",
            CellIsRule(operator='greaterThan', formula=['100'], stopIfTrue=True, fill=red_fill)
        )

    # 파일 저장 (요청된 프리픽스 매핑 반영)
    output_filename = f"res_usage_report_{cluster}_{date}.xlsx"
    local_output_path = os.path.join(OUTPUT_DIR, output_filename)
    
    os.makedirs(OUTPUT_DIR, exist_ok=True)
    wb.save(local_output_path)
    logger.info(f"로컬 엑셀 리포트 저장 성공: {local_output_path}")
    
    # AIStor 자동 업로드 연계
    upload_to_aistor(local_output_path, output_filename)

def main():
    logger.info(f"가공원부 자동 검색 스캔 시작 (경로: {DATA_DIR})")
    
    # 방법 B: pareto_ns_*.parquet 패턴을 탐색하여 동적으로 cluster와 date 패러미터 추출
    search_pattern = os.path.join(DATA_DIR, "pareto_ns_*.parquet")
    pareto_files = glob.glob(search_pattern)
    
    if not pareto_files:
        logger.warning(f"탐색된 파레토 가공원부(*.parquet) 파일이 없습니다. 경로를 확인하세요: {search_pattern}")
        return
        
    processed_count = 0
    for file_path in pareto_files:
        filename = os.path.basename(file_path)
        match = re.search(r"pareto_ns_(.*)_(.*)\.parquet", filename)
        if match:
            cluster = match.group(1)
            date = match.group(2)
            
            logger.info(f"파티션 감지 성공 -> Cluster: {cluster}, Date: {date}")
            generate_report(cluster, date)
            processed_count += 1
            
    logger.info(f"전체 자동 매칭 빌드 프로세스 완료. (처리 완료 세트 수: {processed_count})")

if __name__ == "__main__":
    main()

0개의 댓글