feat: KIMS 상호작용 로그 뷰어 페이지 추가 (/admin/kims-logs)

This commit is contained in:
thug0bin 2026-02-28 13:38:47 +09:00
parent fbe7dde4ce
commit 16adca3646
2 changed files with 626 additions and 0 deletions

View File

@ -4120,6 +4120,69 @@ def kill_process_on_port(port: int) -> bool:
# KIMS 약물 상호작용 API
# ═══════════════════════════════════════════════════════════
@app.route('/admin/kims-logs')
def admin_kims_logs():
"""KIMS 상호작용 로그 뷰어 페이지"""
return render_template('admin_kims_logs.html')
@app.route('/api/kims/logs')
def api_kims_logs():
"""KIMS 로그 목록 조회"""
from db.kims_logger import get_recent_logs
limit = int(request.args.get('limit', 100))
status = request.args.get('status', '')
interaction = request.args.get('interaction', '')
date = request.args.get('date', '')
try:
logs = get_recent_logs(limit=limit)
# 필터링
if status:
logs = [l for l in logs if l['api_status'] == status]
if interaction == 'has':
logs = [l for l in logs if l['interaction_count'] > 0]
elif interaction == 'severe':
logs = [l for l in logs if l['has_severe_interaction'] == 1]
elif interaction == 'none':
logs = [l for l in logs if l['interaction_count'] == 0]
if date:
logs = [l for l in logs if l['created_at'] and l['created_at'].startswith(date)]
return jsonify({'success': True, 'logs': logs})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@app.route('/api/kims/logs/stats')
def api_kims_logs_stats():
"""KIMS 로그 통계"""
from db.kims_logger import get_stats
try:
stats = get_stats()
return jsonify({'success': True, 'stats': stats})
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@app.route('/api/kims/logs/<int:log_id>')
def api_kims_log_detail(log_id):
"""KIMS 로그 상세 조회"""
from db.kims_logger import get_log_detail
try:
log = get_log_detail(log_id)
if log:
return jsonify({'success': True, 'log': log})
else:
return jsonify({'success': False, 'error': '로그를 찾을 수 없습니다'}), 404
except Exception as e:
return jsonify({'success': False, 'error': str(e)})
@app.route('/api/kims/interaction-check', methods=['POST'])
def api_kims_interaction_check():
"""

View File

@ -0,0 +1,563 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>KIMS 상호작용 로그 - 청춘약국</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
background: #f8fafc;
-webkit-font-smoothing: antialiased;
color: #1e293b;
}
/* ── 헤더 ── */
.header {
background: linear-gradient(135deg, #dc2626 0%, #f59e0b 50%, #16a34a 100%);
padding: 28px 32px 24px;
color: #fff;
}
.header-nav {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.header-nav a {
color: rgba(255,255,255,0.8);
text-decoration: none;
font-size: 14px;
font-weight: 500;
}
.header-nav a:hover { color: #fff; }
.header h1 {
font-size: 24px;
font-weight: 700;
letter-spacing: -0.5px;
margin-bottom: 6px;
display: flex;
align-items: center;
gap: 10px;
}
.header p {
font-size: 14px;
opacity: 0.9;
}
/* ── 컨텐츠 ── */
.content {
max-width: 1200px;
margin: 0 auto;
padding: 24px 20px 60px;
}
/* ── 통계 카드 ── */
.stats-grid {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 14px;
margin-bottom: 28px;
}
.stat-card {
background: #fff;
border-radius: 14px;
padding: 20px;
border: 1px solid #e2e8f0;
}
.stat-label {
font-size: 12px;
font-weight: 600;
color: #94a3b8;
margin-bottom: 8px;
}
.stat-value {
font-size: 28px;
font-weight: 700;
letter-spacing: -1px;
}
.stat-value.default { color: #1e293b; }
.stat-value.green { color: #16a34a; }
.stat-value.orange { color: #f59e0b; }
.stat-value.red { color: #dc2626; }
.stat-value.blue { color: #3b82f6; }
.stat-sub {
font-size: 11px;
color: #94a3b8;
margin-top: 4px;
}
/* ── 필터 ── */
.filter-bar {
display: flex;
gap: 12px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.filter-bar select, .filter-bar input {
padding: 10px 14px;
border: 1px solid #e2e8f0;
border-radius: 10px;
font-size: 13px;
font-family: inherit;
background: #fff;
}
.filter-bar button {
padding: 10px 20px;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: #fff;
border: none;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
/* ── 테이블 ── */
.table-wrap {
background: #fff;
border-radius: 14px;
border: 1px solid #e2e8f0;
overflow: hidden;
}
table {
width: 100%;
border-collapse: collapse;
}
thead th {
background: #f8fafc;
padding: 12px 14px;
font-size: 12px;
font-weight: 600;
color: #64748b;
text-align: left;
border-bottom: 1px solid #e2e8f0;
white-space: nowrap;
}
tbody td {
padding: 14px;
font-size: 13px;
font-weight: 500;
color: #334155;
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
tbody tr { cursor: pointer; transition: background .15s; }
tbody tr:hover { background: #f8fafc; }
tbody tr:last-child td { border-bottom: none; }
/* ── 배지 ── */
.badge {
display: inline-block;
padding: 4px 10px;
border-radius: 100px;
font-size: 11px;
font-weight: 600;
}
.badge-success { background: #dcfce7; color: #16a34a; }
.badge-error { background: #fee2e2; color: #dc2626; }
.badge-timeout { background: #fef3c7; color: #d97706; }
.badge-severe { background: #dc2626; color: #fff; }
.badge-moderate { background: #f59e0b; color: #fff; }
.badge-mild { background: #3b82f6; color: #fff; }
.badge-drug {
background: #f1f5f9;
color: #475569;
margin: 2px;
font-size: 10px;
padding: 3px 8px;
}
.badge-drug.warning {
background: #fef2f2;
border: 1px solid #fca5a5;
color: #dc2626;
}
/* ── 상호작용 카운트 ── */
.interaction-count {
font-weight: 700;
font-size: 16px;
}
.interaction-count.zero { color: #16a34a; }
.interaction-count.has { color: #dc2626; }
.interaction-count.severe {
color: #fff;
background: #dc2626;
padding: 4px 10px;
border-radius: 8px;
}
/* ── 아코디언 상세 ── */
.detail-row { display: none; }
.detail-row.open { display: table-row; }
.detail-row td {
padding: 0;
border-bottom: 1px solid #e2e8f0;
background: #fafbfd;
}
.detail-content {
padding: 20px 24px;
}
.detail-section {
margin-bottom: 20px;
}
.detail-section-title {
font-size: 13px;
font-weight: 700;
color: #64748b;
margin-bottom: 12px;
display: flex;
align-items: center;
gap: 6px;
}
.drug-pills {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
/* ── 상호작용 카드 ── */
.interaction-card {
background: #fff;
border-radius: 12px;
padding: 16px;
margin-bottom: 12px;
border-left: 4px solid #e2e8f0;
}
.interaction-card.severe { border-left-color: #dc2626; background: #fef2f2; }
.interaction-card.moderate { border-left-color: #f59e0b; background: #fffbeb; }
.interaction-card.mild { border-left-color: #3b82f6; background: #eff6ff; }
.interaction-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.interaction-drugs {
font-weight: 600;
color: #1e293b;
font-size: 14px;
}
.interaction-desc {
font-size: 13px;
color: #475569;
line-height: 1.6;
margin-bottom: 10px;
}
.interaction-mgmt {
font-size: 12px;
color: #059669;
background: #ecfdf5;
padding: 10px 12px;
border-radius: 8px;
line-height: 1.5;
}
/* ── 빈 상태 ── */
.empty-state {
text-align: center;
padding: 60px 20px;
color: #94a3b8;
}
.empty-icon { font-size: 48px; margin-bottom: 12px; opacity: 0.5; }
/* ── 로딩 ── */
.loading {
text-align: center;
padding: 40px;
color: #64748b;
}
/* ── 반응형 ── */
@media (max-width: 900px) {
.stats-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 600px) {
.stats-grid { grid-template-columns: 1fr; }
.filter-bar { flex-direction: column; }
}
</style>
</head>
<body>
<div class="header">
<div class="header-nav">
<a href="/admin">← 관리자 홈</a>
<a href="/admin/members">회원 관리</a>
</div>
<h1>🔬 KIMS 상호작용 로그</h1>
<p>약물 상호작용 체크 API 호출 기록 · AI 학습용 데이터</p>
</div>
<div class="content">
<!-- 통계 -->
<div class="stats-grid" id="statsGrid">
<div class="stat-card">
<div class="stat-label">총 호출</div>
<div class="stat-value default" id="statTotal">-</div>
</div>
<div class="stat-card">
<div class="stat-label">성공</div>
<div class="stat-value green" id="statSuccess">-</div>
</div>
<div class="stat-card">
<div class="stat-label">상호작용 발견</div>
<div class="stat-value orange" id="statInteraction">-</div>
</div>
<div class="stat-card">
<div class="stat-label">심각 경고</div>
<div class="stat-value red" id="statSevere">-</div>
</div>
<div class="stat-card">
<div class="stat-label">평균 응답</div>
<div class="stat-value blue" id="statAvgMs">-</div>
<div class="stat-sub">밀리초</div>
</div>
</div>
<!-- 필터 -->
<div class="filter-bar">
<select id="filterStatus">
<option value="">모든 상태</option>
<option value="SUCCESS">성공</option>
<option value="ERROR">에러</option>
<option value="TIMEOUT">타임아웃</option>
</select>
<select id="filterInteraction">
<option value="">모든 결과</option>
<option value="has">상호작용 있음</option>
<option value="severe">심각 상호작용</option>
<option value="none">상호작용 없음</option>
</select>
<input type="date" id="filterDate" />
<button onclick="loadLogs()">🔍 조회</button>
</div>
<!-- 테이블 -->
<div class="table-wrap">
<table>
<thead>
<tr>
<th>시간</th>
<th>처방번호</th>
<th>약품</th>
<th>상호작용</th>
<th>상태</th>
<th>응답</th>
</tr>
</thead>
<tbody id="logsBody">
<tr>
<td colspan="6" class="loading">로딩 중...</td>
</tr>
</tbody>
</table>
</div>
</div>
<script>
let logsData = [];
let openRowId = null;
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
function formatDateTime(dt) {
if (!dt) return '-';
const d = new Date(dt);
return `${d.getMonth()+1}/${d.getDate()} ${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}`;
}
async function loadStats() {
try {
const res = await fetch('/api/kims/logs/stats');
const data = await res.json();
if (data.success) {
document.getElementById('statTotal').textContent = data.stats.total_calls || 0;
document.getElementById('statSuccess').textContent = data.stats.success_count || 0;
document.getElementById('statInteraction').textContent = data.stats.with_interaction || 0;
document.getElementById('statSevere').textContent = data.stats.with_severe || 0;
document.getElementById('statAvgMs').textContent = Math.round(data.stats.avg_response_ms || 0);
}
} catch (e) {
console.error('통계 로드 실패:', e);
}
}
async function loadLogs() {
const tbody = document.getElementById('logsBody');
tbody.innerHTML = '<tr><td colspan="6" class="loading">로딩 중...</td></tr>';
const status = document.getElementById('filterStatus').value;
const interaction = document.getElementById('filterInteraction').value;
const date = document.getElementById('filterDate').value;
try {
let url = '/api/kims/logs?limit=100';
if (status) url += `&status=${status}`;
if (interaction) url += `&interaction=${interaction}`;
if (date) url += `&date=${date}`;
const res = await fetch(url);
const data = await res.json();
if (!data.success || !data.logs || data.logs.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-state"><div class="empty-icon">📭</div><div>로그가 없습니다</div></td></tr>';
return;
}
logsData = data.logs;
renderLogs();
} catch (e) {
tbody.innerHTML = '<tr><td colspan="6" class="empty-state">로드 실패</td></tr>';
}
}
function renderLogs() {
const tbody = document.getElementById('logsBody');
let html = '';
logsData.forEach((log, idx) => {
// 약품 배지
let drugs = [];
try { drugs = JSON.parse(log.request_drug_names || '[]'); } catch(e) {}
const drugBadges = drugs.slice(0, 3).map(d =>
`<span class="badge badge-drug">${escapeHtml(d.slice(0, 12))}</span>`
).join('') + (drugs.length > 3 ? `<span class="badge badge-drug">+${drugs.length - 3}</span>` : '');
// 상호작용 표시
let interactionHtml = '';
if (log.interaction_count > 0) {
if (log.has_severe_interaction) {
interactionHtml = `<span class="interaction-count severe">⚠️ ${log.interaction_count}</span>`;
} else {
interactionHtml = `<span class="interaction-count has">${log.interaction_count}건</span>`;
}
} else {
interactionHtml = `<span class="interaction-count zero">✓ 없음</span>`;
}
// 상태 배지
let statusBadge = '';
if (log.api_status === 'SUCCESS') {
statusBadge = '<span class="badge badge-success">성공</span>';
} else if (log.api_status === 'TIMEOUT') {
statusBadge = '<span class="badge badge-timeout">타임아웃</span>';
} else {
statusBadge = '<span class="badge badge-error">에러</span>';
}
html += `
<tr onclick="toggleDetail(${log.id}, ${idx})">
<td>${formatDateTime(log.created_at)}</td>
<td>${escapeHtml(log.pre_serial) || '-'}</td>
<td>${drugBadges}</td>
<td>${interactionHtml}</td>
<td>${statusBadge}</td>
<td>${log.response_time_ms || 0}ms</td>
</tr>
<tr class="detail-row" id="detail-${log.id}">
<td colspan="6">
<div class="detail-content" id="detail-content-${log.id}">
로딩 중...
</div>
</td>
</tr>
`;
});
tbody.innerHTML = html;
}
async function toggleDetail(logId, idx) {
const detailRow = document.getElementById(`detail-${logId}`);
if (openRowId === logId) {
detailRow.classList.remove('open');
openRowId = null;
return;
}
// 기존 열린 행 닫기
if (openRowId) {
document.getElementById(`detail-${openRowId}`)?.classList.remove('open');
}
openRowId = logId;
detailRow.classList.add('open');
// 상세 데이터 로드
const contentDiv = document.getElementById(`detail-content-${logId}`);
try {
const res = await fetch(`/api/kims/logs/${logId}`);
const data = await res.json();
if (!data.success) {
contentDiv.innerHTML = '<p>상세 정보 로드 실패</p>';
return;
}
const log = data.log;
let drugs = [];
try { drugs = JSON.parse(log.request_drug_names || '[]'); } catch(e) {}
// 상호작용 카드
let interactionsHtml = '';
const interactions = log.interactions_detail || [];
if (interactions.length === 0) {
interactionsHtml = '<p style="color:#16a34a;font-weight:600;">✅ 상호작용 없음</p>';
} else {
interactions.forEach(inter => {
const sevLevel = inter.severity_level || 5;
const sevClass = sevLevel == 1 ? 'severe' : sevLevel == 2 ? 'moderate' : 'mild';
interactionsHtml += `
<div class="interaction-card ${sevClass}">
<div class="interaction-header">
<span class="interaction-drugs">${escapeHtml(inter.drug1_name)} ↔ ${escapeHtml(inter.drug2_name)}</span>
<span class="badge badge-${sevClass}">${escapeHtml(inter.severity_desc) || '알 수 없음'}</span>
</div>
${inter.observation ? `<div class="interaction-desc">${escapeHtml(inter.observation)}</div>` : ''}
${inter.clinical_management ? `<div class="interaction-mgmt">💡 ${escapeHtml(inter.clinical_management).slice(0, 200)}...</div>` : ''}
</div>
`;
});
}
contentDiv.innerHTML = `
<div class="detail-section">
<div class="detail-section-title">💊 분석 약품 (${drugs.length}개)</div>
<div class="drug-pills">
${drugs.map(d => `<span class="badge badge-drug">${escapeHtml(d)}</span>`).join('')}
</div>
</div>
<div class="detail-section">
<div class="detail-section-title">⚠️ 상호작용 (${interactions.length}건)</div>
${interactionsHtml}
</div>
<div class="detail-section" style="font-size:12px;color:#94a3b8;">
응답시간: ${log.response_time_ms}ms ·
호출시간: ${log.created_at} ·
처방번호: ${escapeHtml(log.pre_serial) || '-'}
</div>
`;
} catch (e) {
contentDiv.innerHTML = '<p>로드 실패</p>';
}
}
// 초기 로드
loadStats();
loadLogs();
</script>
</body>
</html>