- partials/favicon.ejs로 icon·apple-touch-icon 링크 - 전 페이지 head에 include, /favicon.ico는 동일 PNG 제공 - 인라인 403 HTML에도 favicon 링크 Made-with: Cursor
240 lines
10 KiB
Plaintext
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>
|