Initial commit: ssessesse ki battle
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
import type { Ratio } from '../shared/analytics-types.js';
|
||||
import './styles.css';
|
||||
import './admin.css';
|
||||
const form = document.getElementById('filters')! as HTMLFormElement, content = document.getElementById('report')!;
|
||||
let report: any, tab = 'overview', detailId = '', detailPage = 1, reportLoadError = '';
|
||||
const number = (value: unknown) => typeof value === 'number' ? value.toLocaleString('ko-KR', { maximumFractionDigits: 1 }) : '데이터 없음';
|
||||
const rate = (r: Ratio | null | undefined) => r?.percent === null || !r ? '데이터 없음' : `${number(r.percent)}% (${number(r.numerator)}/${number(r.denominator)})`;
|
||||
const time = (value: unknown) => value ? new Date(String(value)).toLocaleString('ko-KR', { timeZone: 'Asia/Seoul', hour12: false }) : '데이터 없음';
|
||||
const short = (value: string) => value ? value.slice(0, 8) : '—';
|
||||
function node(tag: string, text?: string, className?: string) {
|
||||
const el = document.createElement(tag); if (text !== undefined) el.textContent = text; if (className) el.className = className; return el;
|
||||
}
|
||||
function section(title: string, note?: string) {
|
||||
const el = node('section', undefined, 'report-section'); el.append(node('h2', title)); if (note) el.append(node('p', note, 'admin-meta')); content.append(el); return el;
|
||||
}
|
||||
function table(parent: HTMLElement, headers: string[], rows: unknown[][]) {
|
||||
const wrap = node('div', undefined, 'table-scroll'), t = node('table'), head = node('tr');
|
||||
headers.forEach(h => { const cell = node('th', h); cell.setAttribute('scope', 'col'); head.append(cell); });
|
||||
const thead = node('thead'); thead.append(head); t.append(thead);
|
||||
const body = node('tbody');
|
||||
for (const row of rows) { const tr = node('tr'); for (const value of row) { const cell = node('td'); if (value instanceof Node) cell.append(value); else cell.textContent = String(value ?? '—'); tr.append(cell); } body.append(tr); }
|
||||
t.append(body); wrap.append(t); parent.append(wrap); if (!rows.length) parent.append(node('p', '데이터 없음', 'admin-meta'));
|
||||
}
|
||||
function cards(parent: HTMLElement, values: [string, unknown][]) {
|
||||
const grid = node('div', undefined, 'metric-grid');
|
||||
for (const [label, value] of values) { const card = node('div', undefined, 'metric'); card.append(node('span', label), node('strong', typeof value === 'number' ? number(value) : String(value ?? '데이터 없음'))); grid.append(card); } parent.append(grid);
|
||||
}
|
||||
function chart(parent: HTMLElement, values: any[], key: string, labelKey: string, color: string) {
|
||||
if (!values.length) return;
|
||||
const ns = 'http://www.w3.org/2000/svg', svg = document.createElementNS(ns, 'svg'); svg.setAttribute('viewBox', '0 0 800 180'); svg.setAttribute('role', 'img'); svg.setAttribute('aria-label', `${key} 추이`); svg.classList.add('chart');
|
||||
const max = Math.max(1, ...values.map(v => v[key]));
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const y = 140 - i * 60, line = document.createElementNS(ns, 'line'); line.setAttribute('x1', '35'); line.setAttribute('x2', '780'); line.setAttribute('y1', String(y)); line.setAttribute('y2', String(y)); line.setAttribute('stroke', '#e5e9f0'); svg.append(line);
|
||||
const text = document.createElementNS(ns, 'text'); text.setAttribute('x', '0'); text.setAttribute('y', String(y + 4)); text.textContent = number(max * i / 2); svg.append(text);
|
||||
}
|
||||
const step = 730 / Math.max(1, values.length);
|
||||
values.forEach((v, i) => {
|
||||
const rect = document.createElementNS(ns, 'rect'), height = v[key] / max * 120;
|
||||
rect.setAttribute('x', String(40 + i * step)); rect.setAttribute('y', String(140 - height)); rect.setAttribute('width', String(Math.max(1, step - 4))); rect.setAttribute('height', String(height)); rect.setAttribute('rx', '3'); rect.setAttribute('fill', color);
|
||||
const title = document.createElementNS(ns, 'title'); title.textContent = `${v[labelKey]}: ${v[key]}`; rect.append(title); svg.append(rect);
|
||||
if (i === 0 || i === values.length - 1 || (values.length < 10)) { const label = document.createElementNS(ns, 'text'); label.setAttribute('x', String(40 + i * step)); label.setAttribute('y', '165'); label.textContent = String(v[labelKey]).slice(labelKey === 'day' ? 5 : 5); svg.append(label); }
|
||||
}); parent.append(svg);
|
||||
}
|
||||
function distributions(parent: HTMLElement, title: string, rows: any[], unit = '') {
|
||||
parent.append(node('h3', title)); table(parent, ['구간', '표본 수'], rows.map(r => [`${number(r.lower)}${unit} ~ ${r.upper === null ? '' : `${number(r.upper)}${unit} 미만`}`, number(r.count)]));
|
||||
}
|
||||
function render() {
|
||||
content.replaceChildren(); if (!report) return;
|
||||
if (tab === 'overview' || tab === 'gameplay') {
|
||||
const g = report.clientGameplay, current = section('대전 요약 · 클라이언트 보고', '친구 대전 결과는 참가자별 보고 수입니다.');
|
||||
cards(current, [['플레이어', g.players], ['시작 대전', g.startedMatches], ['결과 보고', g.resultReports]]);
|
||||
table(current, ['결과', '보고 수'], g.outcomes.map((r: any) => [({ win: '승', loss: '패', draw: '무', aborted: '중단' } as any)[r.name] ?? r.name, r.count]));
|
||||
}
|
||||
if (tab === 'overview') {
|
||||
const o = report.overview, s = section('방문·플레이', '순방문자·플레이어는 전체 구간 중복 제거. 방문 지표에는 모드 필터 미적용.');
|
||||
cards(s, [['순방문자', o.visitors], ['방문 세션', o.visitSessions], ['페이지 조회', o.pageViews]]);
|
||||
table(s, ['지표', '값'], [['신규 방문자', rate(o.newVisitors)], ['재방문자', rate(o.returningVisitors)]]);
|
||||
const live = section('현재 현황', '과거 기간·분류 필터 미적용 · 활성 방문자: 보이는 화면의 최근 90초 활동 · 10초 갱신'); live.id = 'live'; renderLive();
|
||||
cards(section('HTTP 요청', '서버 시작 이후 · 사람 집계와 별도 · 관리자 조회 제외'), [['요청 수', report.collection.httpRequests]]);
|
||||
const active = section('DAU · WAU · MAU', '조회 종료일을 포함한 최근 1/7/30일의 ID 중복 제거'); table(active, ['기간', '방문자', '플레이어'], [['DAU', number(o.dau.visitors), number(o.dau.players)], ['WAU', number(o.wau.visitors), number(o.wau.players)], ['MAU', number(o.mau.visitors), number(o.mau.players)]]);
|
||||
const days = section('일별 추이', 'KST 날짜 · 방문자/플레이어는 일별 중복 제거'); chart(days, report.daily, 'visitors', 'day', '#326dde'); table(days, ['날짜', '방문자', '플레이어', '생성 대전', '페이지 조회'], report.daily.map((d: any) => [d.day, d.visitors, d.players, d.matches, d.pageViews]));
|
||||
const hours = section('시간별 추이', 'KST 시각 · 파란 막대: 순방문자'); chart(hours, report.hourly, 'visitors', 'hour', '#326dde'); table(hours, ['시간', '순방문자', '생성 대전'], report.hourly.map((d: any) => [d.hour.replace('T', ' '), d.visitors, d.matches]));
|
||||
const peak = section('최대 동시 이용 표본', '10초 간격 표본 최대값 · 순간 최대값 미측정'); cards(peak, [['표본 수', report.sampledPeak.count], ['활성 방문자 추정', report.sampledPeak.active]]);
|
||||
} else if (tab === 'acquisition') {
|
||||
for (const [key, label] of [['source', '유입 채널'], ['referrer', '외부 유입 도메인'], ['device', '기기'], ['browser', '브라우저']]) {
|
||||
table(section(label), ['분류', '방문자', '페이지 조회'], report.acquisition[key].map((g: any) => [g.name, g.visitors, g.pageViews]));
|
||||
}
|
||||
table(section('초대 링크', '페이지 조회 기준'), ['초대 링크 조회', '전체 조회'], [[report.acquisition.invitation.invited, report.acquisition.invitation.total]]);
|
||||
table(section('조작'), ['조작', '횟수'], report.flow.actions.map((r: any) => [({ bot_start: '봇 시작', room_create: '방 생성', room_join: '방 참가', rematch: '재대전', room_leave: '나가기' } as any)[r.name] ?? r.name, r.count]));
|
||||
table(section('화면 전환'), ['화면', '전환 수'], report.flow.screens.map((r: any) => [r.name, r.count]));
|
||||
} else if (tab === 'revisit') {
|
||||
table(section('방문 재방문', '첫 방문 KST 날짜 기준 · 관찰일이 끝나지 않은 코호트는 관찰 중'), ['첫 날짜', '코호트 인원', 'D1', 'D7'], report.revisit.visitCohorts.map((c: any) => [c.day, c.size, c.d1 === null ? '관찰 중' : rate(c.d1), c.d7 === null ? '관찰 중' : rate(c.d7)]));
|
||||
const r = report.revisit;
|
||||
const active = section('활성 이용 시간', '보이는 화면의 연속 신호 간격만 합산 · 90초 초과 공백 제외 · 세션 전체 누적값, 기간 경계 분할 미적용'); cards(active, [['중앙값', r.activeMs.median === null ? '데이터 없음' : `${number(r.activeMs.median / 1000)}초`]]); distributions(active, '활성 시간(ms)', r.activeMs.distribution);
|
||||
} else if (tab === 'gameplay') {
|
||||
table(section('클라이언트 오류'), ['오류 코드', '횟수'], report.errors.client.map((e: any) => [e.name, e.count]));
|
||||
} else {
|
||||
const users = section('익명 사용자', '조회 구간 방문자 · 최대 50명 · 표시 ID는 내부 익명 ID의 앞 8자리');
|
||||
table(users, ['ID', '첫 방문', '최근 방문', '보고 대전', '조회'], report.users.map((u: any) => {
|
||||
const b = node('button', '상세', 'quiet'); b.onclick = () => { detailId = u.playerId; detailPage = 1; void loadDetail(); }; return [short(u.playerId), time(u.firstVisitAt), time(u.lastVisitAt), u.clientMatches, b];
|
||||
}));
|
||||
content.append(node('div', undefined, 'user-detail')); if (detailId) void loadDetail();
|
||||
}
|
||||
}
|
||||
function renderLive() {
|
||||
const el = document.getElementById('live'); if (!el || !report) return;
|
||||
el.querySelector('.metric-grid')?.remove(); el.querySelector('.live-time')?.remove(); const l = report.live;
|
||||
cards(el, [['활성 방문자 추정', l.active], ['대전 중 사람', '미수집'], ['대전 수', '미수집']]);
|
||||
el.append(node('p', `조회 ${time(l.observedAt)} · 마지막 활동 ${time(l.lastActivityAt)}${l.lastActivityAt && Date.now() - +new Date(l.lastActivityAt) > 90000 ? ' · 최근 활동 없음' : ''}`, 'admin-meta live-time'));
|
||||
}
|
||||
function collection() {
|
||||
if (!report) return;
|
||||
const c = report.collection;
|
||||
document.getElementById('collection')!.textContent = `조회 ${time(c.now)} · 최근 수집 성공 ${time(c.lastSuccessAt)} · 실패 ${c.failures} · 버림 ${c.dropped} · 제한 ${c.rateLimited} · 대기 ${c.queued} · 기록 저장 실패 ${c.resultSaveFailures} · 서버 시작 ${time(c.restartedAt)}`;
|
||||
document.getElementById('error')!.textContent = reportLoadError || report.integrityIssues?.join(' · ') || '';
|
||||
}
|
||||
async function load() {
|
||||
const params = new URLSearchParams(); const values = new FormData(form);
|
||||
for (const key of ['start', 'end', 'mode', 'audience', 'device', 'browser', 'source']) {
|
||||
const value = String(values.get(key) ?? ''); if (!value) continue;
|
||||
params.set(key, ['start', 'end'].includes(key) ? new Date(`${value}:00+09:00`).toISOString() : value);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(`/api/admin/analytics/report?${params}`, { cache: 'no-store' }); const data = await response.json();
|
||||
if (!response.ok) throw new Error(data.error ?? '조회하지 못했습니다.'); report = data; reportLoadError = '';
|
||||
document.getElementById('range')!.textContent = `${time(report.range.start)} 이상 ~ ${time(report.range.end)} 미만 · 상세 보관 시작 ${time(report.range.retentionStart)} · 최초 수집 ${time(report.range.collectionStart)}`;
|
||||
collection(); render();
|
||||
} catch (error) { reportLoadError = error instanceof Error ? error.message : '조회하지 못했습니다.'; document.getElementById('error')!.textContent = reportLoadError; }
|
||||
}
|
||||
async function loadDetail() {
|
||||
const holder = content.querySelector<HTMLElement>('.user-detail'); if (!holder || !detailId) return;
|
||||
const id = detailId, page = detailPage;
|
||||
try {
|
||||
const response = await fetch(`/api/admin/analytics/users/${encodeURIComponent(id)}?page=${page}&pageSize=20`); const d = await response.json();
|
||||
if (!response.ok) throw new Error(d.error); if (detailId !== id || detailPage !== page) return;
|
||||
holder.replaceChildren(node('h2', `사용자 ${short(d.playerId)}`));
|
||||
cards(holder, [['첫 방문', time(d.firstVisitAt)], ['최근 방문', time(d.lastVisitAt)]]);
|
||||
holder.append(node('h3', '접속 세션별 전적 · 클라이언트 보고'));
|
||||
table(holder, ['게임 세션', '모드', '승', '패', '무', '현재 연승', '최고 연승'], (d.gameSessions ?? []).flatMap((s: any) => ['bot', 'friend'].map(m => [short(s.gameSessionId), m === 'bot' ? '봇' : '친구', s.stats[m].wins, s.stats[m].losses, s.stats[m].draws, s.stats[m].current, s.stats[m].best])));
|
||||
holder.append(node('h3', '방문 세션')); table(holder, ['시작', '최근 활동', '활성 시간', '기기', '브라우저', '유입'], d.sessions.map((s: any) => [time(s.startedAt), time(s.lastAt), `${number(s.activeMs / 1000)}초`, s.metadata.device, s.metadata.browser, s.metadata.source]));
|
||||
holder.append(node('h3', '이벤트 흐름')); table(holder, ['시각', '유형', '조작·코드', '화면', '대전'], d.events.map((e: any) => [time(e.occurredAt), e.eventType, e.action ?? e.code ?? '—', e.screen, short(e.matchId)]));
|
||||
const pager = node('div', undefined, 'pager'), prev = node('button', '이전'), next = node('button', '다음'); (prev as HTMLButtonElement).disabled = page <= 1; (next as HTMLButtonElement).disabled = !d.hasMore;
|
||||
prev.onclick = () => { detailPage--; void loadDetail(); }; next.onclick = () => { detailPage++; void loadDetail(); }; pager.append(prev, node('span', `${page} 페이지`), next); holder.append(pager);
|
||||
} catch (error) { holder.textContent = error instanceof Error ? error.message : '상세 조회 실패'; }
|
||||
}
|
||||
const localDate = (value: number) => new Date(value + 9 * 3600000).toISOString().slice(0, 16);
|
||||
(form.elements.namedItem('start') as HTMLInputElement).value = localDate(Date.now() - 7 * 86400000);
|
||||
(form.elements.namedItem('end') as HTMLInputElement).value = localDate(Date.now());
|
||||
form.onsubmit = e => { e.preventDefault(); void load(); };
|
||||
document.querySelectorAll<HTMLButtonElement>('[data-tab]').forEach(b => { b.onclick = () => { tab = b.dataset.tab!; document.querySelectorAll('[data-tab]').forEach(n => n.setAttribute('aria-pressed', String(n === b))); render(); }; });
|
||||
setInterval(async () => {
|
||||
if (!report || document.hidden) return;
|
||||
try { const response = await fetch('/api/admin/analytics/live', { cache: 'no-store' }); if (!response.ok) return; const data = await response.json(); report.live = data.live; report.collection = data.collection; renderLive(); collection(); } catch {}
|
||||
}, 10000);
|
||||
void load();
|
||||
Reference in New Issue
Block a user