1100 lines
48 KiB
Python
1100 lines
48 KiB
Python
"""
|
||
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
|
||
|
||
# 이력 테이블
|
||
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.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"]
|
||
else:
|
||
# 선택 해제되었거나 최초 로딩 시, 기본적으로 가장 최근 분석(첫 번째 행)을 기본값으로 세팅
|
||
valid_ids = [r["id"] for r in records]
|
||
if "selected_analysis_id" not in st.session_state or st.session_state["selected_analysis_id"] not in valid_ids:
|
||
st.session_state["selected_analysis_id"] = records[0]["id"] if records else 1
|
||
|
||
# 상세 조회
|
||
st.markdown("---")
|
||
selected_id = st.session_state["selected_analysis_id"]
|
||
st.subheader(f"🔎 상세 조회 (보고서 ID: {selected_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} 삭제 완료. 페이지를 새로고침하세요.")
|
||
return
|
||
|
||
# 상세 내용
|
||
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()
|