Files
ai_platform/views/admin-thumbnail-events.ejs
dsyoon 9f647cd783 feat(ui): 파비콘을 xavis-logo.png로 통일
- partials/favicon.ejs로 icon·apple-touch-icon 링크
- 전 페이지 head에 include, /favicon.ico는 동일 PNG 제공
- 인라인 403 HTML에도 favicon 링크

Made-with: Cursor
2026-04-05 22:33:36 +09:00

240 lines
10 KiB
Plaintext

<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<%- include('partials/favicon') %>
<title>썸네일 이벤트 로그</title>
<link rel="stylesheet" href="/public/styles.css" />
</head>
<body>
<div class="app-shell">
<%- include('partials/nav', { activeMenu: 'learning' }) %>
<div class="content-area">
<header class="topbar">
<h1>썸네일 이벤트 로그</h1>
<a class="top-action-link" href="/admin?token=<%= token %>">대시보드로</a>
</header>
<main class="container">
<section class="panel">
<div class="section-head">
<h2>실시간 요약</h2>
<div class="admin-inline">
<label class="inline">
<input type="checkbox" id="pollToggle" checked />
자동 새로고침
</label>
<label class="inline">
간격(초)
<select id="pollIntervalSec">
<option value="5">5</option>
<option value="10" selected>10</option>
<option value="20">20</option>
<option value="30">30</option>
</select>
</label>
</div>
</div>
<p class="api-hint">요약 API: <code>/api/queue/events-summary?token=***&hours=24</code></p>
<div class="kpi-grid">
<article class="kpi-card">
<small>처리 건수(24h)</small>
<b id="kpiProcessed">-</b>
</article>
<article class="kpi-card">
<small>실패율(24h)</small>
<b id="kpiFailureRate">-</b>
</article>
<article class="kpi-card">
<small>평균 소요(ms)</small>
<b id="kpiAvgDuration">-</b>
</article>
<article class="kpi-card">
<small>현재 큐</small>
<b id="kpiQueue">-</b>
</article>
</div>
<div class="chart-grid">
<section class="mini-panel">
<h3>시간대별 처리량(24h)</h3>
<div id="processedChart" class="bar-chart"></div>
</section>
<section class="mini-panel">
<h3>시간대별 실패량(24h)</h3>
<div id="failedChart" class="bar-chart"></div>
</section>
</div>
</section>
<section class="panel">
<h2>필터</h2>
<% if (typeof eventsCleared !== 'undefined' && eventsCleared) { %>
<p class="admin-ok">썸네일 이벤트 로그가 초기화되었습니다.</p>
<% } %>
<form action="/admin/thumbnail-events" method="get" class="filter-grid">
<input type="hidden" name="token" value="<%= token %>" />
<label>
이벤트 타입
<select name="eventType">
<option value="all" <%= filters.eventType === "all" ? "selected" : "" %>>전체</option>
<option value="enqueue" <%= filters.eventType === "enqueue" ? "selected" : "" %>>enqueue</option>
<option value="start" <%= filters.eventType === "start" ? "selected" : "" %>>start</option>
<option value="success" <%= filters.eventType === "success" ? "selected" : "" %>>success</option>
<option value="failed" <%= filters.eventType === "failed" ? "selected" : "" %>>failed</option>
<option value="worker-error" <%= filters.eventType === "worker-error" ? "selected" : "" %>>worker-error</option>
</select>
</label>
<label>
Lecture ID
<input type="text" name="lectureId" value="<%= filters.lectureId %>" />
</label>
<label>
Reason
<input type="text" name="reason" value="<%= filters.reason %>" />
</label>
<label>
From
<input type="date" name="from" value="<%= filters.from %>" />
</label>
<label>
To
<input type="date" name="to" value="<%= filters.to %>" />
</label>
<label>
페이지 크기
<input type="number" min="10" max="200" name="limit" value="<%= filters.limit %>" />
</label>
<div class="filter-actions">
<button type="submit">적용</button>
<a class="link-muted" href="/admin/thumbnail-events?token=<%= token %>">필터 초기화</a>
<a class="link-muted" href="/admin/thumbnail-events.csv?<%= csvQuery %>">CSV 다운로드</a>
</div>
</form>
<form action="/thumbnails/events/clear" method="post" class="admin-inline" style="margin-top:8px" onsubmit="return confirm('썸네일 이벤트 로그를 모두 삭제하시겠습니까?');">
<input type="hidden" name="token" value="<%= token %>" />
<input type="hidden" name="returnTo" value="/admin/thumbnail-events?token=<%= token %>" />
<button type="submit" class="ghost">로그 초기화</button>
</form>
</section>
<section class="panel">
<div class="section-head">
<h2>이벤트 목록</h2>
<span class="count-chip">총 <%= pagination.totalCount %>건</span>
</div>
<% if (!events.length) { %>
<p class="empty">조회 결과가 없습니다.</p>
<% } else { %>
<div class="event-table-wrap">
<table class="event-table">
<thead>
<tr>
<th>시간</th>
<th>타입</th>
<th>강의</th>
<th>사유</th>
<th>재시도</th>
<th>소요(ms)</th>
<th>에러</th>
</tr>
</thead>
<tbody>
<% events.forEach((evt) => { %>
<tr>
<td><%= new Date(evt.at).toLocaleString("ko-KR") %></td>
<td><span class="evt-type <%= evt.type %>"><%= evt.type %></span></td>
<td>
<div><%= evt.lectureTitle || "-" %></div>
<small><%= evt.lectureId || "-" %></small>
</td>
<td><%= evt.reason || "-" %></td>
<td><%= evt.retryCount ?? "-" %></td>
<td><%= evt.durationMs ?? "-" %></td>
<td><%= evt.error || "-" %></td>
</tr>
<% }) %>
</tbody>
</table>
</div>
<% } %>
<% if (pagination.totalPages > 1) { %>
<nav class="pagination">
<% if (pagination.hasPrev) { %>
<a href="/admin/thumbnail-events?<%= pagination.prevQuery %>">이전</a>
<% } %>
<% pagination.pages.forEach((p) => { %>
<a href="/admin/thumbnail-events?<%= p.query %>" class="<%= p.active ? "active" : "" %>"><%= p.page %></a>
<% }) %>
<% if (pagination.hasNext) { %>
<a href="/admin/thumbnail-events?<%= pagination.nextQuery %>">다음</a>
<% } %>
</nav>
<% } %>
</section>
</main>
</div>
</div>
<script>
const ADMIN_TOKEN = <%- JSON.stringify(token) %>;
const POLL_TOGGLE = document.getElementById("pollToggle");
const POLL_INTERVAL = document.getElementById("pollIntervalSec");
let timerId = null;
const fmtPercent = (value) => `${(Number(value || 0) * 100).toFixed(1)}%`;
const renderBars = (targetId, buckets, key, cls) => {
const el = document.getElementById(targetId);
if (!el) return;
const max = Math.max(1, ...buckets.map((b) => Number(b[key] || 0)));
el.innerHTML = "";
buckets.forEach((bucket) => {
const value = Number(bucket[key] || 0);
const item = document.createElement("div");
item.className = "bar-item";
const bar = document.createElement("div");
bar.className = `bar ${cls}`;
bar.style.height = `${Math.max((value / max) * 100, 2)}%`;
bar.title = `${bucket.label}: ${value}`;
const label = document.createElement("small");
label.textContent = bucket.label;
item.appendChild(bar);
item.appendChild(label);
el.appendChild(item);
});
};
const loadSummary = async () => {
try {
const res = await fetch(`/api/queue/events-summary?token=${encodeURIComponent(ADMIN_TOKEN)}&hours=24`);
if (!res.ok) return;
const data = await res.json();
document.getElementById("kpiProcessed").textContent = String(data.kpi.processedCount ?? 0);
document.getElementById("kpiFailureRate").textContent = fmtPercent(data.kpi.failureRate);
document.getElementById("kpiAvgDuration").textContent = String(data.kpi.avgDurationMs ?? 0);
document.getElementById("kpiQueue").textContent = `${data.queue.pending}${data.queue.working ? " (작동중)" : ""}`;
renderBars("processedChart", data.buckets || [], "processed", "processed");
renderBars("failedChart", data.buckets || [], "failed", "failed");
} catch {
// no-op
}
};
const refreshPolling = () => {
if (timerId) clearInterval(timerId);
if (!POLL_TOGGLE.checked) return;
const sec = Number(POLL_INTERVAL.value || 10);
timerId = setInterval(loadSummary, Math.max(sec, 3) * 1000);
};
POLL_TOGGLE.addEventListener("change", refreshPolling);
POLL_INTERVAL.addEventListener("change", refreshPolling);
loadSummary();
refreshPolling();
</script>
</body>
</html>