Files
nvidia-bug/app.py
T

1130 lines
49 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
NV-Log Analyzer - Streamlit 대시보드
nvidia-bug-report.log.gz 파일을 업로드하면 즉시 분석 + DB 저장.
이력 조회, GPU 시리얼 추적, XID 통계 등 케이스 축적 기능 포함.
"""
import streamlit as st
import pandas as pd
import json
from pathlib import Path
from log_parser import analyze_log, read_log_file, XID_DB
from db import init_db, save_analysis, update_memo, delete_analysis
from db import get_analysis_list, get_analysis_detail
from db import get_xid_statistics, get_gpu_serial_history, get_all_gpu_serials, get_status_summary
from auth import (
init_user_db, authenticate, register_user, get_user_permissions,
get_all_users, get_pending_users, approve_user, reject_user,
update_user_role, delete_user, update_password, ROLES,
)
# --- DB 초기화 (앱 시작 시 1회) ---
init_db()
init_user_db()
# --- 페이지 설정 ---
st.set_page_config(
page_title="NV-Log Analyzer",
page_icon="🖥️",
layout="wide",
initial_sidebar_state="expanded",
)
# --- 커스텀 CSS ---
st.markdown("""
<style>
.main-header { font-size: 2.2rem; font-weight: 700; color: #76b900; margin-bottom: 0.5rem; }
.sub-header { font-size: 1rem; color: #888; margin-bottom: 2rem; }
.status-critical {
background-color: #ff4b4b20; border-left: 4px solid #ff4b4b;
padding: 1rem; border-radius: 0.5rem; margin: 0.5rem 0;
}
.status-warning {
background-color: #ffa72620; border-left: 4px solid #ffa726;
padding: 1rem; border-radius: 0.5rem; margin: 0.5rem 0;
}
.status-healthy {
background-color: #66bb6a20; border-left: 4px solid #66bb6a;
padding: 1rem; border-radius: 0.5rem; margin: 0.5rem 0;
}
div[data-testid="stMetricValue"] { font-size: 1.5rem; }
</style>
""", unsafe_allow_html=True)
# =============================================
# 인증 헬퍼
# =============================================
def get_current_user():
"""현재 로그인된 사용자 정보 반환. 없으면 None"""
return st.session_state.get("user")
def get_perms():
"""현재 사용자 권한 dict"""
user = get_current_user()
if not user:
return ROLES["viewer"]
return get_user_permissions(user["role"])
def require_login():
"""로그인 안 되었으면 로그인 페이지 표시 후 True 반환 (= 중단 필요)"""
if get_current_user():
return False
page_login()
return True
# =============================================
# 로그인 / 회원가입 페이지
# =============================================
def page_login():
# 고정 너비 중앙 정렬
col_left, col_center, col_right = st.columns([1, 2, 1])
with col_center:
st.markdown('<p class="main-header" style="text-align:center">🖥️ NV-Log Analyzer</p>',
unsafe_allow_html=True)
tab_login, tab_register = st.tabs(["🔑 로그인", "📝 회원가입"])
with tab_login:
with st.form("login_form"):
username = st.text_input("아이디")
password = st.text_input("비밀번호", type="password")
submitted = st.form_submit_button("로그인", use_container_width=True)
if submitted and username and password:
result = authenticate(username, password)
if result is None:
st.error("아이디 또는 비밀번호가 올바르지 않습니다.")
elif "error" in result:
if result["status"] == "pending":
st.warning("가입 승인 대기 중입니다. 관리자 승인 후 로그인할 수 있습니다.")
elif result["status"] == "rejected":
st.error("가입이 거절되었습니다. 관리자에게 문의하세요.")
else:
st.session_state["user"] = result
st.rerun()
with tab_register:
with st.form("register_form"):
new_username = st.text_input("아이디 (3자 이상)")
new_display = st.text_input("표시 이름")
new_password = st.text_input("비밀번호 (6자 이상)", type="password")
new_password2 = st.text_input("비밀번호 확인", type="password")
reg_submitted = st.form_submit_button("회원가입 신청", use_container_width=True)
if reg_submitted:
if new_password != new_password2:
st.error("비밀번호가 일치하지 않습니다.")
elif new_username and new_password:
res = register_user(new_username, new_password, new_display or new_username)
if res["success"]:
st.success("회원가입이 신청되었습니다. 관리자 승인 후 로그인할 수 있습니다.")
else:
st.error(res["error"])
st.markdown("---")
st.caption("기본 관리자: admin / admin1234 (최초 로그인 후 반드시 비밀번호를 변경하세요)")
# =============================================
# 페이지 5: 사용자 관리 (admin 전용)
# =============================================
def page_user_management():
st.markdown('<p class="main-header">👤 사용자 관리</p>', unsafe_allow_html=True)
st.markdown('<p class="sub-header">사용자 가입 승인, 역할 변경, 계정 관리를 수행합니다.</p>',
unsafe_allow_html=True)
user = get_current_user()
if not user or user["role"] != "admin":
st.error("관리자만 접근할 수 있습니다.")
return
# 승인 대기 사용자
pending = get_pending_users()
if pending:
st.subheader(f"🔔 승인 대기 ({len(pending)}명)")
for p in pending:
with st.container():
pc1, pc2, pc3, pc4 = st.columns([2, 2, 2, 2])
pc1.write(f"**{p['display_name']}** (`{p['username']}`)")
pc2.write(f"가입 요청: {p['created_at']}")
with pc3:
role_choice = st.selectbox(
"역할", list(ROLES.keys()),
index=2, # default viewer
key=f"role_{p['id']}",
format_func=lambda r: f"{ROLES[r]['label']} - {ROLES[r]['description'][:20]}",
)
with pc4:
bc1, bc2 = st.columns(2)
if bc1.button("✅ 승인", key=f"approve_{p['id']}"):
approve_user(p["id"], role_choice, user["username"])
st.success(f"{p['username']} 승인 완료 ({ROLES[role_choice]['label']})")
st.rerun()
if bc2.button("❌ 거절", key=f"reject_{p['id']}"):
reject_user(p["id"])
st.warning(f"{p['username']} 거절됨")
st.rerun()
st.markdown("---")
else:
st.info("승인 대기 중인 사용자가 없습니다.")
# 전체 사용자 목록
st.subheader("📋 전체 사용자")
all_users = get_all_users()
status_emoji = {"approved": "🟢", "pending": "🟡", "rejected": "🔴"}
user_table = [{
"ID": u["id"],
"아이디": u["username"],
"이름": u["display_name"],
"역할": f"{ROLES.get(u['role'], {}).get('label', u['role'])}",
"상태": f"{status_emoji.get(u['status'], '')} {u['status']}",
"가입일": u["created_at"],
"승인자": u.get("approved_by") or "-",
"최근 로그인": u.get("last_login") or "-",
} for u in all_users]
st.dataframe(pd.DataFrame(user_table), use_container_width=True, hide_index=True)
# 개별 사용자 관리
st.markdown("---")
st.subheader("🔧 사용자 설정 변경")
manageable = [u for u in all_users if u["username"] != "admin"]
if not manageable:
st.info("관리할 사용자가 없습니다.")
return
selected_user = st.selectbox(
"사용자 선택",
manageable,
format_func=lambda u: f"{u['display_name']} ({u['username']}) - {ROLES.get(u['role'], {}).get('label', u['role'])}",
)
if selected_user:
mc1, mc2, mc3 = st.columns(3)
with mc1:
new_role = st.selectbox(
"역할 변경",
list(ROLES.keys()),
index=list(ROLES.keys()).index(selected_user["role"]),
key="change_role",
format_func=lambda r: f"{ROLES[r]['label']}",
)
if st.button("역할 적용"):
update_user_role(selected_user["id"], new_role)
st.success(f"{selected_user['username']}의 역할이 {ROLES[new_role]['label']}(으)로 변경되었습니다.")
st.rerun()
with mc2:
new_pw = st.text_input("새 비밀번호 (6자 이상)", type="password", key="reset_pw")
if st.button("비밀번호 초기화"):
if new_pw and len(new_pw) >= 6:
update_password(selected_user["id"], new_pw)
st.success(f"{selected_user['username']}의 비밀번호가 변경되었습니다.")
else:
st.error("비밀번호는 6자 이상이어야 합니다.")
with mc3:
st.write("")
st.write("")
if st.button("🗑️ 계정 삭제", type="secondary"):
delete_user(selected_user["id"])
st.success(f"{selected_user['username']} 계정이 삭제되었습니다.")
st.rerun()
# 역할별 권한 안내
st.markdown("---")
with st.expander("📖 역할별 권한 안내"):
for role_key, role_info in ROLES.items():
perms = []
if role_info["can_upload"]: perms.append("로그 업로드")
if role_info["can_view"]: perms.append("조회")
if role_info["can_edit_memo"]: perms.append("메모 수정")
if role_info["can_delete"]: perms.append("삭제")
if role_info["can_manage_users"]: perms.append("사용자 관리")
st.write(f"**{role_info['label']}** ({role_key}): {', '.join(perms)}")
# =============================================
# 공통 렌더링 함수
# =============================================
def render_status_badge(status):
status_map = {
"critical": ("🔴 심각", "status-critical",
"즉각적인 조치가 필요합니다. 하드웨어 손상 또는 서비스 중단 위험이 감지되었습니다."),
"warning": ("🟡 경고", "status-warning",
"주의가 필요한 항목이 발견되었습니다. 방치 시 문제가 악화될 수 있습니다."),
"healthy": ("🟢 정상", "status-healthy",
"심각한 문제가 감지되지 않았습니다."),
}
label, css_class, desc = status_map.get(status, status_map["healthy"])
st.markdown(f'<div class="{css_class}"><h3 style="margin:0">{label}</h3>'
f'<p style="margin:0.5rem 0 0 0">{desc}</p></div>', unsafe_allow_html=True)
def render_gpu_details_from_result(gpus):
"""GPU 상세 (AnalysisResult.gpus 객체 리스트용)"""
if not gpus:
st.info("GPU 정보를 로그에서 추출하지 못했습니다.")
return
for gpu in gpus:
with st.expander(f"GPU {gpu.index}: {gpu.name}", expanded=True):
col1, col2, col3, col4 = st.columns(4)
temp_delta = "정상" if gpu.temperature < 85 else "과열 주의!"
col1.metric("온도", f"{gpu.temperature}°C", delta=temp_delta,
delta_color="off" if gpu.temperature < 85 else "inverse")
if gpu.power_limit > 0:
col2.metric("전력", f"{gpu.power_draw}W / {gpu.power_limit}W",
delta=f"{round(gpu.power_draw/gpu.power_limit*100)}%")
else:
col2.metric("전력", f"{gpu.power_draw}W")
if gpu.memory_total > 0:
col3.metric("메모리", f"{gpu.memory_used} / {gpu.memory_total} MiB",
delta=f"{round(gpu.memory_used/gpu.memory_total*100)}%")
else:
col3.metric("메모리", "N/A")
col4.metric("GPU 사용률", f"{gpu.gpu_utilization}%")
spec_col1, spec_col2 = st.columns(2)
with spec_col1:
for k, v in {"제품명": gpu.name, "브랜드": gpu.product_brand,
"아키텍처": gpu.product_architecture,
"시리얼 번호": gpu.serial_number,
"GPU UUID": gpu.gpu_uuid,
"VBIOS": gpu.vbios_version,
"InfoROM": gpu.inforom_version}.items():
if v and v != "N/A":
st.write(f"**{k}:** `{v}`")
with spec_col2:
pcie = f"{gpu.pci_link_gen_current} x{gpu.pci_link_width_current}" if gpu.pci_link_gen_current != "N/A" else "N/A"
for k, v in {"PCI 주소": gpu.pci_address, "PCIe 링크": pcie,
"Power State": gpu.power_state,
"팬 속도": f"{gpu.fan_speed}%" if gpu.fan_speed > 0 else "N/A",
"Persistence": gpu.persistence_mode,
"Compute Mode": gpu.compute_mode,
"MIG Mode": gpu.mig_mode,
"ECC Mode": gpu.ecc_mode}.items():
if v and v != "N/A":
st.write(f"**{k}:** `{v}`")
if gpu.clock_graphics != "N/A" or gpu.clock_memory != "N/A":
st.markdown("**클럭 정보**")
cc1, cc2 = st.columns(2)
if gpu.clock_graphics != "N/A": cc1.write(f"**Graphics:** `{gpu.clock_graphics}`")
if gpu.clock_memory != "N/A": cc2.write(f"**Memory:** `{gpu.clock_memory}`")
if gpu.hw_slowdown != "N/A":
hw_sd = gpu.hw_slowdown
hw_th = gpu.hw_thermal_slowdown
hw_pb = gpu.hw_power_brake_slowdown
any_active = hw_sd == "Active" or hw_th == "Active" or hw_pb == "Active"
label = "⚠️ **HW Slowdown 상태**" if any_active else "**HW Slowdown 상태**"
st.markdown(label)
sd1, sd2, sd3 = st.columns(3)
sd1.metric("HW Slowdown", hw_sd,
delta="Active" if hw_sd == "Active" else None,
delta_color="inverse")
if hw_th != "N/A":
sd2.metric("HW Thermal Slowdown", hw_th,
delta="Active" if hw_th == "Active" else None,
delta_color="inverse")
if hw_pb != "N/A":
sd3.metric("HW Power Brake Slowdown", hw_pb,
delta="Active" if hw_pb == "Active" else None,
delta_color="inverse")
def render_gpu_details_from_dict(gpus):
"""GPU 상세 (DB dict 리스트용)"""
if not gpus:
st.info("GPU 정보 없음")
return
for gpu in gpus:
with st.expander(f"GPU {gpu['gpu_index']}: {gpu['name']}", expanded=True):
col1, col2, col3, col4 = st.columns(4)
temp = gpu.get("temperature", 0) or 0
col1.metric("온도", f"{temp}°C",
delta="정상" if temp < 85 else "과열 주의!",
delta_color="off" if temp < 85 else "inverse")
pw = gpu.get("power_draw", 0) or 0
pl = gpu.get("power_limit", 0) or 0
if pl > 0:
col2.metric("전력", f"{pw}W / {pl}W", delta=f"{round(pw/pl*100)}%")
else:
col2.metric("전력", f"{pw}W")
mu = gpu.get("memory_used", 0) or 0
mt = gpu.get("memory_total", 0) or 0
if mt > 0:
col3.metric("메모리", f"{mu} / {mt} MiB", delta=f"{round(mu/mt*100)}%")
else:
col3.metric("메모리", "N/A")
col4.metric("GPU 사용률", f"{gpu.get('gpu_utilization', 0)}%")
spec_col1, spec_col2 = st.columns(2)
with spec_col1:
for k, field in [("제품명", "name"), ("브랜드", "product_brand"),
("아키텍처", "product_architecture"),
("시리얼 번호", "serial_number"),
("GPU UUID", "gpu_uuid"),
("VBIOS", "vbios_version"),
("InfoROM", "inforom_version")]:
v = gpu.get(field, "N/A") or "N/A"
if v != "N/A":
st.write(f"**{k}:** `{v}`")
with spec_col2:
gen = gpu.get("pci_link_gen", "N/A") or "N/A"
width = gpu.get("pci_link_width", "N/A") or "N/A"
pcie = f"{gen} x{width}" if gen != "N/A" else "N/A"
fan = gpu.get("fan_speed", 0) or 0
for k, v in [("PCI 주소", gpu.get("pci_address", "N/A")),
("PCIe 링크", pcie),
("Power State", gpu.get("power_state", "N/A")),
("팬 속도", f"{fan}%" if fan > 0 else "N/A"),
("Persistence", gpu.get("persistence_mode", "N/A")),
("Compute Mode", gpu.get("compute_mode", "N/A")),
("MIG Mode", gpu.get("mig_mode", "N/A")),
("ECC Mode", gpu.get("ecc_mode", "N/A"))]:
if v and v != "N/A":
st.write(f"**{k}:** `{v}`")
hw_sd = gpu.get("hw_slowdown", "N/A") or "N/A"
if hw_sd != "N/A":
hw_th = gpu.get("hw_thermal_slowdown", "N/A") or "N/A"
hw_pb = gpu.get("hw_power_brake_slowdown", "N/A") or "N/A"
any_active = hw_sd == "Active" or hw_th == "Active" or hw_pb == "Active"
label = "⚠️ **HW Slowdown 상태**" if any_active else "**HW Slowdown 상태**"
st.markdown(label)
sd1, sd2, sd3 = st.columns(3)
sd1.metric("HW Slowdown", hw_sd,
delta="Active" if hw_sd == "Active" else None,
delta_color="inverse")
if hw_th != "N/A":
sd2.metric("HW Thermal Slowdown", hw_th,
delta="Active" if hw_th == "Active" else None,
delta_color="inverse")
if hw_pb != "N/A":
sd3.metric("HW Power Brake Slowdown", hw_pb,
delta="Active" if hw_pb == "Active" else None,
delta_color="inverse")
def render_rma_section(rma_verdicts, source="result"):
"""RMA 판정 결과 섹션 (AnalysisResult.rma_verdicts 또는 DB dict 리스트 공용)"""
st.subheader("🔧 RMA 적격 판정 (NVIDIA Hopper 기준)")
if not rma_verdicts:
st.info("RMA 판정 데이터가 없습니다.")
return
rma_qualified = []
rma_warnings = []
rma_clean = []
for v in rma_verdicts:
if source == "result":
# AnalysisResult.rma_verdicts (RmaVerdict 객체)
qualifies = v.qualifies_for_rma
idx = v.gpu_index
name = v.gpu_name
serial = v.serial_number
reasons = v.reasons
warnings = v.warnings
remap = v.row_remap_details
sram = v.sram_details
else:
# DB dict
qualifies = v.get("qualifies_for_rma", 0) == 1
idx = v.get("gpu_index", 0)
name = v.get("gpu_name", "N/A")
serial = v.get("serial_number", "N/A")
reasons = v.get("reasons", [])
warnings = v.get("warnings", [])
remap = v.get("row_remap_details", {})
sram = v.get("sram_details", {})
entry = {
"idx": idx, "name": name, "serial": serial,
"qualifies": qualifies, "reasons": reasons, "warnings": warnings,
"remap": remap, "sram": sram,
}
if qualifies:
rma_qualified.append(entry)
elif warnings:
rma_warnings.append(entry)
else:
rma_clean.append(entry)
# RMA 대상 GPU 요약
if rma_qualified:
st.markdown(f"""
<div class="status-critical">
<h3 style="margin:0">🔴 RMA 대상 GPU: {len(rma_qualified)}개</h3>
<p style="margin:0.5rem 0 0 0">아래 GPU는 NVIDIA RMA 기준에 해당합니다. 벤더사에 교체를 요청하세요.</p>
</div>
""", unsafe_allow_html=True)
elif rma_warnings:
st.markdown(f"""
<div class="status-warning">
<h3 style="margin:0">🟡 주의 필요 GPU: {len(rma_warnings)}개</h3>
<p style="margin:0.5rem 0 0 0">아직 RMA 기준 미달이나 모니터링이 필요한 GPU가 있습니다.</p>
</div>
""", unsafe_allow_html=True)
else:
st.markdown(f"""
<div class="status-healthy">
<h3 style="margin:0">🟢 RMA 해당 없음</h3>
<p style="margin:0.5rem 0 0 0">모든 GPU가 RMA 기준 내 정상 범위입니다.</p>
</div>
""", unsafe_allow_html=True)
# GPU별 상세
for entry in rma_qualified + rma_warnings + rma_clean:
status_icon = "🔴" if entry["qualifies"] else ("🟡" if entry["warnings"] else "🟢")
status_text = "RMA 대상" if entry["qualifies"] else ("주의 필요" if entry["warnings"] else "정상")
with st.expander(
f"{status_icon} GPU {entry['idx']}: {entry['name']} (S/N: {entry['serial']}) — {status_text}",
expanded=entry["qualifies"]
):
# RMA 사유
if entry["reasons"]:
st.markdown("**🔴 RMA 사유:**")
for r in entry["reasons"]:
st.error(r)
# 경고
if entry["warnings"]:
st.markdown("**🟡 경고:**")
for w in entry["warnings"]:
st.warning(w)
# Row Remapping 상세
remap = entry["remap"]
if remap:
st.markdown("**Row Remapping 상태:**")
rc1, rc2, rc3, rc4 = st.columns(4)
rc1.metric("Correctable 리매핑", remap.get("correctable_error", 0))
rc2.metric("Uncorrectable 리매핑", remap.get("uncorrectable_error", 0))
rc3.metric("리매핑 대기", remap.get("pending", "N/A"))
rc4.metric("리매핑 실패", remap.get("failure_occurred", "N/A"))
st.markdown("**Bank Remap 가용성:**")
bc1, bc2, bc3, bc4, bc5 = st.columns(5)
bc1.metric("Max", f"{remap.get('bank_max', 0)} banks")
bc2.metric("High", f"{remap.get('bank_high', 0)} banks")
bc3.metric("Partial", f"{remap.get('bank_partial', 0)} banks")
bc4.metric("Low", f"{remap.get('bank_low', 0)} banks")
bc5.metric("None", f"{remap.get('bank_none', 0)} banks")
# SRAM 상세
sram = entry["sram"]
if sram:
st.markdown("**SRAM ECC 상태:**")
agg = sram.get("aggregate", {})
vol = sram.get("volatile", {})
dram = sram.get("dram", {})
sc1, sc2, sc3 = st.columns(3)
with sc1:
st.markdown("*Aggregate (누적)*")
st.write(f"Correctable: `{agg.get('correctable', 'N/A')}`")
st.write(f"Parity UCE: `{agg.get('uncorrectable_parity', 'N/A')}`")
st.write(f"SECDED UCE: `{agg.get('uncorrectable_secded', 'N/A')}`")
threshold = agg.get("threshold_exceeded", "N/A")
if threshold.lower() == "yes":
st.write(f"**Threshold Exceeded: `{threshold}`** ← RMA 대상")
else:
st.write(f"Threshold Exceeded: `{threshold}`")
with sc2:
st.markdown("*Volatile (휘발성)*")
st.write(f"Correctable: `{vol.get('correctable', 'N/A')}`")
st.write(f"Parity UCE: `{vol.get('uncorrectable_parity', 'N/A')}`")
st.write(f"SECDED UCE: `{vol.get('uncorrectable_secded', 'N/A')}`")
with sc3:
st.markdown("*DRAM*")
st.write(f"Corr (Vol): `{dram.get('corr_volatile', 'N/A')}`")
st.write(f"Uncorr (Vol): `{dram.get('uncorr_volatile', 'N/A')}`")
st.write(f"Corr (Agg): `{dram.get('corr_aggregate', 'N/A')}`")
st.write(f"Uncorr (Agg): `{dram.get('uncorr_aggregate', 'N/A')}`")
# SRAM Sources
sources = sram.get("sources", {})
if any(v != "N/A" for v in sources.values()):
st.markdown("**Uncorrectable SRAM Sources:**")
ss1, ss2, ss3, ss4, ss5 = st.columns(5)
ss1.write(f"L2: `{sources.get('l2', 'N/A')}`")
ss2.write(f"SM: `{sources.get('sm', 'N/A')}`")
ss3.write(f"MC: `{sources.get('microcontroller', 'N/A')}`")
ss4.write(f"PCIe: `{sources.get('pcie', 'N/A')}`")
ss5.write(f"Other: `{sources.get('other', 'N/A')}`")
if not entry["qualifies"] and not entry["warnings"]:
st.success("Row-Remapping, SRAM 모두 정상 범위입니다.")
# RMA 기준 안내
with st.expander("📖 NVIDIA Hopper RMA 기준 안내", expanded=False):
st.markdown("""
**Row-Remapping RMA 기준 (DRAM)**
- `Remapping Failure Occurred = Yes` → 즉시 RMA
- 한 Bank에서 Uncorrectable Error Row 리매핑 **8회 이상**
- 총 리매핑 **512회** 이상
- 8회 리매핑 후 Field Diagnostics에서 문제 감지
**SRAM RMA 기준 (L2 Cache)**
- `SRAM Threshold Exceeded = Yes` → 즉시 RMA
- Parity 보호 SRAM: 주소 뱅크 내 **UCE 4개** 이상
- SECDED ECC 보호 SRAM: 주소 뱅크 내 **UCE 2개** 이상
> 참고: DRAM은 별도 RMA 기준 없음. NVIDIA Field Diagnostic 도구로 최종 확인 필요.
> [NVIDIA 공식 문서](https://docs.nvidia.com/deploy/a100-gpu-mem-error-mgmt/rma-policy-thresholds-for-row-remapping.html)
""")
# =============================================
# 페이지 1: 로그 분석 (메인)
# =============================================
def page_analyze():
st.markdown('<p class="main-header">🖥️ NV-Log Analyzer</p>', unsafe_allow_html=True)
st.markdown('<p class="sub-header">nvidia-bug-report.log.gz 파일을 업로드하여 GPU 상태를 즉시 분석하고 DB에 저장합니다.</p>',
unsafe_allow_html=True)
perms = get_perms()
if not perms["can_upload"]:
st.warning("로그 업로드 권한이 없습니다. 관리자에게 문의하세요.")
st.info("분석 이력 조회는 '📚 분석 이력' 메뉴에서 가능합니다.")
return
uploaded_file = st.file_uploader(
"nvidia-bug-report.log.gz 또는 .log 파일 업로드",
type=["gz", "log", "txt"],
help="nvidia-bug-report.sh가 생성한 로그 파일을 업로드하세요.",
)
if uploaded_file is None:
st.markdown("---")
col1, col2, col3 = st.columns(3)
with col1:
st.markdown("#### 🔍 XID 에러 탐지\n`NVRM: Xid` 패턴을 자동 검출하고, 에러 코드별 의미와 권장 조치를 제공합니다.")
with col2:
st.markdown("#### 🌡️ GPU 헬스 체크\n온도, 전력, ECC 에러, 메모리 상태를 한눈에 확인할 수 있습니다.")
with col3:
st.markdown("#### 🐧 커널 로그 필터링\ndmesg 내 Critical, Error, OOM 등 핵심 이벤트만 추출합니다.")
st.info("위에서 nvidia-bug-report.log.gz 파일을 업로드하세요.")
return
# 분석 실행
with st.spinner("🔄 로그 분석 중..."):
file_content = uploaded_file.read()
result = analyze_log(file_content, uploaded_file.name)
# DB 저장 (중복 방지: 같은 파일명+크기가 이미 있으면 스킵)
existing = get_analysis_list(limit=1, search=uploaded_file.name)
if existing and existing[0]["file_size_mb"] == result.file_size_mb:
analysis_id = existing[0]["id"]
st.info(f"이미 저장된 분석 결과입니다. (ID: {analysis_id})")
else:
analysis_id = save_analysis(result)
st.success(f"분석 결과가 DB에 저장되었습니다. (ID: {analysis_id})")
# 메모 입력 (editor/admin만)
if perms["can_edit_memo"]:
memo = st.text_input("📝 이 분석에 대한 메모 (장애 상황, 조치 내용 등)", key="memo_input",
placeholder="예: Dell 서버 R750xa, GPU 4번 XID 61 반복 발생으로 RMA 접수")
if memo:
update_memo(analysis_id, memo)
st.caption("메모가 저장되었습니다.")
# --- 결과 출력 ---
st.markdown("---")
render_status_badge(result.overall_status)
col1, col2, col3, col4, col5, col6 = st.columns(6)
col1.metric("전체 이슈", result.total_issues)
col2.metric("심각", result.critical_count)
col3.metric("경고", result.warning_count)
col4.metric("정보", result.info_count)
col5.metric("GPU 수", len(result.gpus))
col6.metric("🔧 RMA 대상", result.rma_count)
st.markdown("---")
st.subheader("📋 시스템 정보")
ic1, ic2, ic3, ic4 = st.columns(4)
ic1.metric("파일명", result.filename[:30] + ("..." if len(result.filename) > 30 else ""))
ic2.metric("파일 크기", f"{result.file_size_mb} MB")
ic3.metric("드라이버", result.driver_version)
ic4.metric("CUDA", result.cuda_version)
st.markdown("---")
st.subheader("🎮 GPU 상태")
render_gpu_details_from_result(result.gpus)
# RMA 판정
st.markdown("---")
render_rma_section(result.rma_verdicts)
# XID 에러
st.markdown("---")
st.subheader("⚠️ XID 에러 분석")
if not result.xid_events:
st.success("XID 에러가 감지되지 않았습니다.")
else:
st.write(f"**총 {len(result.xid_events)}건의 XID 이벤트 감지**")
summary_data = []
for code, info in result.xid_summary.items():
sev = XID_DB["severity_levels"].get(info["severity"], {})
summary_data.append({
"XID": code, "이름": info["name"], "횟수": info["count"],
"심각도": f"{sev.get('emoji','')} {sev.get('label', info['severity'])}",
"발생 GPU": ", ".join(info.get("gpus", [])) or "N/A",
})
st.dataframe(pd.DataFrame(summary_data), use_container_width=True, hide_index=True)
displayed = set()
for event in result.xid_events:
if event.xid_code in displayed:
continue
displayed.add(event.xid_code)
sev = XID_DB["severity_levels"].get(event.severity, {})
gpu_label = f"GPU {event.gpu_index} ({event.gpu_name})" if event.gpu_name else event.pci_address
serial_label = event.gpu_serial if event.gpu_serial else "N/A"
with st.expander(f"{sev.get('emoji','')} XID {event.xid_code}: {event.name} "
f"({result.xid_summary[event.xid_code]['count']}건)",
expanded=(event.severity == "critical")):
st.markdown(f"**발생 GPU:** {gpu_label}")
st.markdown(f"**시리얼 번호:** `{serial_label}`")
st.markdown(f"**PCI 주소:** `{event.pci_address}`")
st.markdown(f"**설명:** {event.description}")
st.markdown("**권장 조치:**")
for ln in event.action.split("\n"):
st.markdown(f" {ln}")
# 커널 이벤트
st.markdown("---")
st.subheader("🐧 커널 로그 분석")
if not result.kernel_events:
st.success("주요 커널 에러가 감지되지 않았습니다.")
else:
kc1, kc2, kc3, kc4 = st.columns(4)
kc1.metric("Critical", result.kernel_summary.get("critical", 0))
kc2.metric("Error", result.kernel_summary.get("error", 0))
kc3.metric("OOM", result.kernel_summary.get("oom", 0))
kc4.metric("PCIe", result.kernel_summary.get("pcie_error", 0))
event_data = [{"시각": e.timestamp, "유형": e.level.upper(), "키워드": e.message,
"내용": e.raw_line[:200]} for e in result.kernel_events[:100]]
st.dataframe(pd.DataFrame(event_data), use_container_width=True, hide_index=True)
# 원본 로그
st.markdown("---")
st.subheader("📄 원본 로그 검색")
search_term = st.text_input("키워드 검색", placeholder="예: Xid, error, temperature...")
log_text = read_log_file(file_content, uploaded_file.name)
if search_term:
matched = [f"**L{i+1}:** `{ln.strip()}`"
for i, ln in enumerate(log_text.splitlines())
if search_term.lower() in ln.lower()]
st.write(f"**{len(matched)}건 발견** (처음 50건)")
for ln in matched[:50]:
st.markdown(ln)
else:
st.text_area("로그 미리보기 (5000자)", log_text[:5000], height=200)
# =============================================
# 페이지 2: 분석 이력
# =============================================
def page_history():
st.markdown('<p class="main-header">📚 분석 이력</p>', unsafe_allow_html=True)
st.markdown('<p class="sub-header">이전에 분석한 모든 로그 결과를 조회하고 관리합니다.</p>',
unsafe_allow_html=True)
# 필터
fc1, fc2 = st.columns([1, 3])
with fc1:
status_filter = st.selectbox("상태 필터", ["전체", "critical", "warning", "healthy"])
with fc2:
search = st.text_input("검색 (파일명, 메모, 드라이버 버전)", placeholder="키워드 입력...")
records = get_analysis_list(limit=200, status_filter=status_filter, search=search or None)
if not records:
st.info("저장된 분석 이력이 없습니다. 로그 파일을 업로드하면 자동으로 저장됩니다.")
return
# 요약 통계
summary = get_status_summary()
sc1, sc2, sc3, sc4 = st.columns(4)
sc1.metric("전체 보고서", summary["total_reports"])
sc2.metric("🔴 심각", summary["by_status"].get("critical", 0))
sc3.metric("🟡 경고", summary["by_status"].get("warning", 0))
sc4.metric("🟢 정상", summary["by_status"].get("healthy", 0))
st.markdown("---")
# 세션 상태 초기화 (최초 방문 시 기본 보고서 ID 세팅)
if "selected_analysis_id" not in st.session_state:
st.session_state["selected_analysis_id"] = records[0]["id"] if records else 1
# 세션 상태 보정 (현재 필터링된 목록에 세션 ID가 없으면 첫 번째 항목으로 강제 세팅)
valid_ids = [r["id"] for r in records]
if st.session_state["selected_analysis_id"] not in valid_ids and records:
st.session_state["selected_analysis_id"] = records[0]["id"]
# 이력 테이블 데이터 포맷팅
status_emoji = {"critical": "🔴", "warning": "🟡", "healthy": "🟢"}
table_data = [{
"ID": r["id"],
"분석일시": r["created_at"],
"상태": f"{status_emoji.get(r['overall_status'], '')} {r['overall_status']}",
"파일명": r["filename"][:40],
"GPU 수": r["gpu_count"],
"XID": r["xid_count"],
"심각": r["critical_count"],
"경고": r["warning_count"],
"드라이버": r["driver_version"],
"메모": (r.get("memo") or "")[:50],
} for r in records]
st.write("### 📋 분석 이력 테이블 목록")
st.caption("💡 표 왼쪽 끝의 **동그라미 선택항목(라디오 버튼)**을 클릭하시거나, 하단 드롭다운에서 보고서를 선택하시면 상세 내역이 표시됩니다.")
# st.dataframe의 selection 기능을 사용하여 마우스 행 선택 감지 및 Rerun 트리거
df_display = pd.DataFrame(table_data)
selection_event = st.dataframe(
df_display,
use_container_width=True,
hide_index=True,
selection_mode="single-row",
on_select="rerun",
key="history_table"
)
# 테이블에서 마우스 클릭으로 행을 선택했을 때 세션 상태 ID 갱신
selected_rows = selection_event.get("selection", {}).get("rows", [])
if selected_rows:
selected_row_idx = selected_rows[0]
if selected_row_idx < len(records):
st.session_state["selected_analysis_id"] = records[selected_row_idx]["id"]
# 상세 조회
st.markdown("---")
st.subheader("🔎 상세 조회")
# 보고서 선택을 돕는 UI 개선: 드롭다운 선택박스 제공
history_options = [
(r["id"], f"[ID: {r['id']}] {status_emoji.get(r['overall_status'], '')} {r['filename'][:30]} ({r['created_at']})")
for r in records
]
# 현재 선택된 ID에 해당하는 옵션의 인덱스 찾기 (양방향 연동용)
default_index = 0
for idx, opt in enumerate(history_options):
if opt[0] == st.session_state["selected_analysis_id"]:
default_index = idx
break
selected_option = st.selectbox(
"조회할 보고서 선택",
options=history_options,
index=default_index,
format_func=lambda x: x[1]
)
if selected_option:
st.session_state["selected_analysis_id"] = selected_option[0]
selected_id = st.session_state["selected_analysis_id"]
detail = get_analysis_detail(selected_id)
if not detail:
st.warning(f"ID {selected_id}에 해당하는 분석 결과가 없습니다.")
return
# 메모 편집 (editor/admin만)
perms = get_perms()
current_memo = detail.get("memo", "") or ""
if perms["can_edit_memo"]:
new_memo = st.text_area("📝 메모 수정", value=current_memo, key=f"memo_{selected_id}")
if new_memo != current_memo:
update_memo(selected_id, new_memo)
st.success("메모가 업데이트되었습니다.")
elif current_memo:
st.text_area("📝 메모", value=current_memo, key=f"memo_{selected_id}", disabled=True)
# 삭제 버튼 (admin만)
if perms["can_delete"]:
if st.button("🗑️ 이 분석 기록 삭제", type="secondary"):
delete_analysis(selected_id)
st.success(f"ID {selected_id} 삭제 완료.")
# 삭제 후 첫 번째 보고서로 세션 초기화 및 리런
st.session_state["selected_analysis_id"] = records[0]["id"] if len(records) > 1 else 1
st.rerun()
# 상세 내용 렌더링 시작
render_status_badge(detail["overall_status"])
dc1, dc2, dc3, dc4 = st.columns(4)
dc1.metric("파일명", detail["filename"][:30])
dc2.metric("크기", f"{detail['file_size_mb']} MB")
dc3.metric("드라이버", detail["driver_version"])
dc4.metric("CUDA", detail["cuda_version"])
# GPU 상세
st.markdown("---")
st.subheader("🎮 GPU 상태")
render_gpu_details_from_dict(detail["gpus"])
# RMA 판정
if detail.get("rma_verdicts"):
st.markdown("---")
render_rma_section(detail["rma_verdicts"], source="db")
# XID 이벤트
if detail["xid_events"]:
st.markdown("---")
st.subheader(f"⚠️ XID 이벤트 ({len(detail['xid_events'])}건)")
xid_df = pd.DataFrame([{
"XID": x["xid_code"], "이름": x["xid_name"], "심각도": x["severity"],
"GPU": x.get("gpu_name") or "N/A",
"S/N": x.get("gpu_serial") or "N/A",
"PCI": x["pci_address"], "시각": x["timestamp"],
} for x in detail["xid_events"]])
st.dataframe(xid_df, use_container_width=True, hide_index=True)
# 커널 이벤트
if detail["kernel_events"]:
st.markdown("---")
st.subheader(f"🐧 커널 이벤트 ({len(detail['kernel_events'])}건)")
ke_df = pd.DataFrame([{
"유형": k["level"].upper(), "키워드": k["message"],
"시각": k["timestamp"], "내용": k["raw_line"][:150],
} for k in detail["kernel_events"][:100]])
st.dataframe(ke_df, use_container_width=True, hide_index=True)
# =============================================
# 페이지 3: XID 통계
# =============================================
def page_xid_stats():
st.markdown('<p class="main-header">📊 XID 에러 통계</p>', unsafe_allow_html=True)
st.markdown('<p class="sub-header">전체 분석 이력에서 XID 에러 코드별 발생 빈도와 트렌드를 확인합니다.</p>',
unsafe_allow_html=True)
stats = get_xid_statistics()
if not stats:
st.info("아직 XID 에러 데이터가 없습니다. 로그를 업로드하면 자동으로 축적됩니다.")
return
# 전체 통계 테이블
sev_emoji = {"critical": "🔴", "warning": "🟡", "info": "🟢"}
stat_data = [{
"XID": s["xid_code"],
"이름": s["xid_name"],
"심각도": f"{sev_emoji.get(s['severity'], '')} {s['severity']}",
"총 발생": s["total_count"],
"관련 보고서": s["affected_reports"],
"최초 발견": s["first_seen"],
"최근 발견": s["last_seen"],
} for s in stats]
st.dataframe(pd.DataFrame(stat_data), use_container_width=True, hide_index=True)
# XID 코드별 상세 조회
st.markdown("---")
st.subheader("🔍 XID 코드 상세 조회")
xid_codes = [s["xid_code"] for s in stats]
if xid_codes:
selected_xid = st.selectbox("XID 코드 선택", xid_codes,
format_func=lambda x: f"XID {x}: {XID_DB['xid_errors'].get(str(x), {}).get('name', 'Unknown')}")
xid_info = XID_DB["xid_errors"].get(str(selected_xid))
if xid_info:
sev = XID_DB["severity_levels"].get(xid_info["severity"], {})
st.markdown(f"### {sev.get('emoji', '')} XID {selected_xid}: {xid_info['name']}")
st.markdown(f"**설명:** {xid_info['description']}")
st.markdown("**권장 조치:**")
st.markdown(xid_info["action"])
# 전체 XID DB 조회
st.markdown("---")
st.subheader("📖 XID 코드 사전")
xid_lookup = st.number_input("XID 코드 입력", min_value=1, max_value=999, value=61, step=1)
xid_info = XID_DB["xid_errors"].get(str(xid_lookup))
if xid_info:
sev = XID_DB["severity_levels"].get(xid_info["severity"], {})
st.markdown(f"**{sev.get('emoji', '')} XID {xid_lookup}: {xid_info['name']}**")
st.markdown(f"심각도: {sev.get('label', xid_info['severity'])}")
st.markdown(xid_info['description'])
st.markdown("**권장 조치:**")
st.markdown(xid_info["action"])
else:
st.warning(f"XID {xid_lookup}에 대한 정보가 데이터베이스에 없습니다.")
# =============================================
# 페이지 4: GPU 시리얼 추적
# =============================================
def page_gpu_tracking():
st.markdown('<p class="main-header">🔧 GPU 시리얼 추적</p>', unsafe_allow_html=True)
st.markdown('<p class="sub-header">특정 GPU의 장애 이력을 시리얼 번호로 추적합니다. 동일 GPU의 반복 장애 패턴을 파악할 수 있습니다.</p>',
unsafe_allow_html=True)
serials = get_all_gpu_serials()
if not serials:
st.info("아직 GPU 시리얼 데이터가 없습니다. 로그를 업로드하면 자동으로 축적됩니다.")
return
# 전체 GPU 목록
st.subheader("📋 등록된 GPU 목록")
gpu_table = [{
"시리얼 번호": s["serial_number"],
"GPU 모델": s["name"],
"UUID": s["gpu_uuid"],
"분석 보고서 수": s["report_count"],
} for s in serials]
st.dataframe(pd.DataFrame(gpu_table), use_container_width=True, hide_index=True)
# 시리얼 선택
st.markdown("---")
st.subheader("🔎 시리얼 번호별 이력 조회")
serial_options = [s["serial_number"] for s in serials]
selected_serial = st.selectbox(
"시리얼 번호 선택",
serial_options,
format_func=lambda x: f"{x} ({next((s['name'] for s in serials if s['serial_number']==x), 'N/A')})"
)
if selected_serial:
history = get_gpu_serial_history(selected_serial)
if history:
st.write(f"**{selected_serial}** 의 분석 이력: **{len(history)}건**")
hist_data = [{
"ID": h["id"],
"분석일시": h["created_at"],
"파일명": h["filename"][:30],
"상태": f"{'🔴' if h['overall_status']=='critical' else '🟡' if h['overall_status']=='warning' else '🟢'} {h['overall_status']}",
"이슈": h["total_issues"],
"심각": h["critical_count"],
"온도": f"{h['temperature']}°C",
"전력": f"{h['power_draw']}W",
"메모리": f"{h['memory_used']}/{h['memory_total']} MiB",
"메모": (h.get("memo") or "")[:40],
} for h in history]
st.dataframe(pd.DataFrame(hist_data), use_container_width=True, hide_index=True)
# 온도 추이 차트
if len(history) > 1:
st.markdown("**온도 추이**")
chart_data = pd.DataFrame({
"분석일시": [h["created_at"] for h in history],
"온도(°C)": [h["temperature"] for h in history],
})
st.line_chart(chart_data.set_index("분석일시"))
else:
st.info("해당 시리얼의 이력이 없습니다.")
# =============================================
# 메인 - 사이드바 네비게이션
# =============================================
def page_my_account():
"""내 계정 정보 및 비밀번호 변경"""
st.markdown('<p class="main-header">🔐 내 계정</p>', unsafe_allow_html=True)
user = get_current_user()
if not user:
return
role_info = ROLES.get(user["role"], ROLES["viewer"])
st.write(f"**아이디:** `{user['username']}`")
st.write(f"**이름:** {user['display_name']}")
st.write(f"**역할:** {role_info['label']} ({user['role']})")
st.write(f"**가입일:** {user['created_at']}")
st.write(f"**최근 로그인:** {user.get('last_login') or '-'}")
st.markdown("---")
st.subheader("비밀번호 변경")
with st.form("change_pw"):
cur_pw = st.text_input("현재 비밀번호", type="password")
new_pw = st.text_input("새 비밀번호 (6자 이상)", type="password")
new_pw2 = st.text_input("새 비밀번호 확인", type="password")
if st.form_submit_button("변경"):
auth_result = authenticate(user["username"], cur_pw)
if auth_result is None or "error" in auth_result:
st.error("현재 비밀번호가 올바르지 않습니다.")
elif new_pw != new_pw2:
st.error("새 비밀번호가 일치하지 않습니다.")
elif update_password(user["id"], new_pw):
st.success("비밀번호가 변경되었습니다.")
else:
st.error("비밀번호는 6자 이상이어야 합니다.")
def main():
# 로그인 안 되었으면 로그인 페이지만 표시
if not get_current_user():
page_login()
return
user = get_current_user()
perms = get_perms()
role_info = ROLES.get(user["role"], ROLES["viewer"])
with st.sidebar:
st.markdown("### 🖥️ NV-Log Analyzer")
st.markdown(f"**{user['display_name']}** ({role_info['label']})")
# 메뉴 구성 (역할별)
menu_items = []
if perms["can_upload"]:
menu_items.append("🔍 로그 분석")
menu_items.extend(["📚 분석 이력", "📊 XID 통계", "🔧 GPU 추적"])
if perms["can_manage_users"]:
menu_items.append("👤 사용자 관리")
menu_items.append("🔐 내 계정")
page = st.radio("메뉴", menu_items, label_visibility="collapsed")
st.markdown("---")
if st.button("🚪 로그아웃", use_container_width=True):
del st.session_state["user"]
st.rerun()
st.markdown(
"<small>NV-Log Analyzer v3.0<br>"
"로그인 + 역할 기반 접근제어<br>"
"nvidia-bug-report.log 전용</small>",
unsafe_allow_html=True,
)
if page == "🔍 로그 분석":
page_analyze()
elif page == "📚 분석 이력":
page_history()
elif page == "📊 XID 통계":
page_xid_stats()
elif page == "🔧 GPU 추적":
page_gpu_tracking()
elif page == "👤 사용자 관리":
page_user_management()
elif page == "🔐 내 계정":
page_my_account()
if __name__ == "__main__":
main()