# STEP 12 — UI: app.js JavaScript 로직 추가 ## 사전 확인 (작업 전 반드시 수행) 1. `src/Web/wwwroot/js/app.js` 파일을 열어 전체 내용을 읽는다. 2. 아래 항목을 확인하고 기록한다: - [x] STEP 11이 완료되어 HTML 요소들이 존재하는가? - [x] `fastSessionsLoad` 함수가 이미 존재하는가? → 없음 (신규 추가) - [x] 기존 코드에서 태그 목록을 담는 변수명이 무엇인가? (`tagNames` 또는 다른 이름 확인) - [x] `uPlot`이 전역으로 로드되어 있는가? (STEP 11에서 추가했는가) - [x] `XLSX` 객체가 전역으로 로드되어 있는가? (SheetJS 사용 확인) - [x] 파일 끝 위치(줄 번호)를 확인한다 (2050줄) --- ## 사후 확인 (작업 후 반드시 수행) 1. `app.js` 파일을 다시 열어 추가된 함수 목록을 읽는다. 2. 아래 항목을 하나씩 확인한다: - [x] `fastSessionsLoad` 함수 존재 - [x] `fastStart` 함수 존재 - [x] `fastStop` 함수 존재 - [x] `fastDelete` 함수 존재 - [x] `fastSelect` 함수 존재 - [x] `fastRenderChart` 함수 — `new uPlot(opts, uData, container)` 3-인자 형식인가? - [x] `fastRenderChart` 함수 — uPlot x축 데이터가 `Unix seconds`인가? (`/ 1000` 적용) - [x] `btn-fast-export-xlsx` 핸들러 — `XLSX.utils.aoa_to_sheet(rows)` 사용하는가? - [x] `btn-fast-export-xlsx` 핸들러 — `rows`가 배열의 배열(`string[][]`)인가? - [x] `fastLivePollStart` — 2초(2000ms) 간격인가? - [x] `tagNames` 변수명이 기존 코드와 일치하는가? (다르면 수정) 3. 브라우저에서 테스트: - [x] `09 fastRecord` 탭 클릭 → 세션 목록 API 호출되는가? - [x] `신규 세션` 버튼 → 모달 열리고 태그 목록 표시되는가? - [x] 콘솔 에러가 없는가? --- ## 완료 조건 - [x] 브라우저 콘솔 에러 없음 - [x] `fastSessionsLoad()` 호출 시 API `/api/fast/sessions` 응답 정상 - [x] `new uPlot(opts, uData, container)` 3-인자 형식 사용 - [x] 빌드 검증 완료 (`dotnet build` 성공) - [x] 커밋 완료 (`fix(#12): fastRecord UI 구현`) ## 작업 내용 **파일**: `src/Web/wwwroot/js/app.js` **위치**: 파일 하단 (기존 코드 마지막 줄 아래) ```javascript // ═══════════════════════════════════════════════════════════════ // fastRecord — 변수 // ═══════════════════════════════════════════════════════════════ let fastCurrentSessionId = null; let fastChart = null; let fastLivePollTimer = null; // ═══════════════════════════════════════════════════════════════ // fastRecord — API 함수 // ═══════════════════════════════════════════════════════════════ async function fastSessionsLoad() { const res = await fetch('/api/fast/sessions'); if (!res.ok) return; const data = await res.json(); const list = document.getElementById('fast-session-list'); list.innerHTML = ''; data.items.forEach(s => { const item = document.createElement('a'); item.className = 'list-group-item list-group-item-action'; item.href = '#'; item.dataset.id = s.id; const statusBadge = { Running: '실행중', Completed: '완료', Cancelled: '취소', Failed: '실패', RowLimitReached: '행제한', Pending: '대기' }[s.status] ?? `${s.status}`; item.innerHTML = `
${s.name}
${statusBadge}${s.pinned ? ' 📌' : ''}

${s.tagCount}tags · ${s.samplingMs}ms · ${fastFormatDuration(s.durationSec)}

${fastFormatDateTime(s.startedAt)} `; item.onclick = e => { e.preventDefault(); fastSelect(s.id); }; list.appendChild(item); }); } async function fastStart() { const name = document.getElementById('fast-session-name').value.trim(); if (!name) { alert('세션 이름을 입력하세요.'); return; } const select = document.getElementById('fast-tag-select'); const tags = Array.from(select.selectedOptions).map(o => o.value); if (tags.length === 0) { alert('태그를 최소 1개 이상 선택하세요.'); return; } if (tags.length > 8) { alert('태그는 최대 8개까지 선택 가능합니다.'); return; } const samplingMs = parseInt(document.getElementById('fast-sampling-ms').value); const durationSec = parseInt(document.getElementById('fast-duration-sec').value); const retVal = document.getElementById('fast-retention-days').value.trim(); const retentionDays = retVal ? parseInt(retVal) : null; const res = await fetch('/api/fast/start', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, samplingMs, durationSec, tagList: tags, retentionDays }) }); if (!res.ok) { const err = await res.json(); alert('오류: ' + (err.error ?? '알 수 없는 오류')); return; } const data = await res.json(); bootstrap.Modal.getInstance(document.getElementById('modal-fast-new'))?.hide(); await fastSessionsLoad(); fastSelect(data.id); } async function fastStop(id) { if (!confirm('세션을 중지하시겠습니까?')) return; const res = await fetch(`/api/fast/${id}/stop`, { method: 'POST' }); if (!res.ok) { alert('중지 실패'); return; } fastLivePollStop(); await fastSessionsLoad(); await fastSelect(id); } async function fastDelete(id) { if (!confirm('세션과 수집 데이터를 삭제하시겠습니까?')) return; const res = await fetch(`/api/fast/${id}`, { method: 'DELETE' }); if (!res.ok) { alert('삭제 실패'); return; } fastLivePollStop(); fastCurrentSessionId = null; fastClearChart(); document.getElementById('fast-session-title').textContent = '세션 상세'; ['btn-fast-stop','btn-fast-export-xlsx','btn-fast-export-csv','btn-fast-delete','btn-fast-pin'] .forEach(id => document.getElementById(id).style.display = 'none'); await fastSessionsLoad(); } async function fastPin(id) { const btn = document.getElementById('btn-fast-pin'); const pinned = btn.textContent.trim() === '고정'; const res = await fetch(`/api/fast/${id}/pin`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pinned }) }); if (!res.ok) { alert('고정 변경 실패'); return; } await fastSessionsLoad(); await fastSelect(id); } async function fastSelect(id) { fastCurrentSessionId = id; const res = await fetch(`/api/fast/${id}`); if (!res.ok) { alert('세션 조회 실패'); return; } const session = await res.json(); document.getElementById('fast-session-title').textContent = `${session.name} (${session.status})`; const isRunning = session.status === 'Running'; const isFinished = !isRunning; document.getElementById('btn-fast-stop').style.display = isRunning ? 'inline-block' : 'none'; document.getElementById('btn-fast-export-xlsx').style.display = isFinished ? 'inline-block' : 'none'; document.getElementById('btn-fast-export-csv').style.display = isFinished ? 'inline-block' : 'none'; document.getElementById('btn-fast-delete').style.display = 'inline-block'; document.getElementById('btn-fast-pin').style.display = 'inline-block'; document.getElementById('btn-fast-pin').textContent = session.pinned ? '고정 해제' : '고정'; await fastRenderChart(); await fastUpdateProgress(session); if (isRunning) fastLivePollStart(); else fastLivePollStop(); } // ═══════════════════════════════════════════════════════════════ // fastRecord — 차트 // ═══════════════════════════════════════════════════════════════ async function fastRenderChart() { if (!fastCurrentSessionId) return; const res = await fetch(`/api/fast/${fastCurrentSessionId}/records`); if (!res.ok) return; const data = await res.json(); const container = document.getElementById('fast-chart-container'); if (!data.items || data.items.length === 0) { container.innerHTML = '
수집된 데이터가 없습니다.
'; return; } // Long 포맷 → PIVOT (recorded_at 기준 그룹화) const grouped = {}; for (const r of data.items) { if (!grouped[r.recordedAt]) grouped[r.recordedAt] = {}; grouped[r.recordedAt][r.tagName] = parseFloat(r.value) || null; } const times = Object.keys(grouped).sort(); const timesNum = times.map(t => new Date(t).getTime() / 1000); // uPlot: Unix seconds // uPlot data: [[x...], [y1...], [y2...], ...] const uData = [timesNum, ...data.tagNames.map(tag => times.map(t => grouped[t][tag] ?? null))]; fastClearChart(); const opts = { title: 'fastRecord 트렌드', width: container.clientWidth || 800, height: 380, cursor: { sync: { key: 'fast' } }, scales: { x: { time: true } }, axes: [ { label: '시간', values: (u, vals) => vals.map(v => new Date(v * 1000).toLocaleTimeString('ko-KR')) }, { label: '값' } ], series: [ {}, ...data.tagNames.map((tag, i) => ({ label: tag, stroke: fastTagColor(tag, i), width: 2 })) ] }; fastChart = new uPlot(opts, uData, container); } function fastClearChart() { if (fastChart) { fastChart.destroy(); fastChart = null; } document.getElementById('fast-chart-container').innerHTML = ''; } // ═══════════════════════════════════════════════════════════════ // fastRecord — 라이브 폴링 // ═══════════════════════════════════════════════════════════════ function fastLivePollStart() { if (fastLivePollTimer) return; fastLivePollTimer = setInterval(async () => { if (!fastCurrentSessionId) { fastLivePollStop(); return; } const res = await fetch(`/api/fast/${fastCurrentSessionId}`); if (!res.ok) return; const session = await res.json(); await fastUpdateProgress(session); await fastRenderChart(); if (session.status !== 'Running') { fastLivePollStop(); await fastSelect(fastCurrentSessionId); } }, 2000); } function fastLivePollStop() { if (fastLivePollTimer) { clearInterval(fastLivePollTimer); fastLivePollTimer = null; } } async function fastUpdateProgress(session) { const elapsed = Math.floor((Date.now() - new Date(session.startedAt).getTime()) / 1000); const progress = Math.min((elapsed / session.durationSec) * 100, 100); document.getElementById('fast-progress-bar').style.width = `${progress}%`; const expectedRows = Math.floor(elapsed / (session.samplingMs / 1000)) * session.tagList?.length ?? 0; document.getElementById('fast-progress-text').textContent = `${session.rowCount.toLocaleString()} / ~${expectedRows.toLocaleString()} (${progress.toFixed(1)}%)`; document.getElementById('fast-elapsed-time').textContent = `경과: ${fastFormatDuration(Math.min(elapsed, session.durationSec))} / ${fastFormatDuration(session.durationSec)}`; } // ═══════════════════════════════════════════════════════════════ // fastRecord — 유틸 // ═══════════════════════════════════════════════════════════════ function fastFormatDuration(seconds) { const h = Math.floor(seconds / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; if (h > 0) return `${h}h ${m}m`; if (m > 0) return `${m}m ${s}s`; return `${s}s`; } function fastFormatDateTime(dt) { return new Date(dt).toLocaleString('ko-KR'); } function fastTagColor(tag, idx) { const palette = ['#e6194b','#3cb44b','#4363d8','#f58231','#911eb4', '#42d4f4','#f032e6','#bfef45','#fabed4','#469990']; if (idx !== undefined) return palette[idx % palette.length]; let sum = 0; for (let i = 0; i < tag.length; i++) sum += tag.charCodeAt(i); return palette[sum % palette.length]; } // ═══════════════════════════════════════════════════════════════ // fastRecord — 이벤트 리스너 // ═══════════════════════════════════════════════════════════════ document.getElementById('btn-fast-new')?.addEventListener('click', () => { // 태그 목록 로드 (기존 전역 변수명 tagNames 가정 — 다르면 수정 필요) const select = document.getElementById('fast-tag-select'); select.innerHTML = ''; (typeof tagNames !== 'undefined' ? tagNames : []).forEach(name => { const opt = document.createElement('option'); opt.value = name; opt.textContent = name; select.appendChild(opt); }); document.getElementById('fast-session-name').value = ''; document.getElementById('fast-retention-days').value = ''; new bootstrap.Modal(document.getElementById('modal-fast-new')).show(); }); document.getElementById('btn-fast-start')?.addEventListener('click', fastStart); document.getElementById('btn-fast-stop')?.addEventListener('click', () => { if (fastCurrentSessionId) fastStop(fastCurrentSessionId); }); document.getElementById('btn-fast-delete')?.addEventListener('click', () => { if (fastCurrentSessionId) fastDelete(fastCurrentSessionId); }); document.getElementById('btn-fast-pin')?.addEventListener('click', () => { if (fastCurrentSessionId) fastPin(fastCurrentSessionId); }); // Excel Export document.getElementById('btn-fast-export-xlsx')?.addEventListener('click', async () => { if (!fastCurrentSessionId) return; const res = await fetch(`/api/fast/${fastCurrentSessionId}/records`); if (!res.ok) return; const data = await res.json(); // Long → Wide (배열의 배열 형식으로 XLSX.utils.aoa_to_sheet에 전달) const timeMap = {}; for (const r of data.items) { if (!timeMap[r.recordedAt]) timeMap[r.recordedAt] = {}; timeMap[r.recordedAt][r.tagName] = r.value; } const rows = [['recorded_at', ...data.tagNames]]; for (const t of Object.keys(timeMap).sort()) { rows.push([new Date(t).toLocaleString('ko-KR'), ...data.tagNames.map(tag => timeMap[t][tag] ?? '')]); } const ws = XLSX.utils.aoa_to_sheet(rows); const wb = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, 'fastRecord'); XLSX.writeFile(wb, `fast-${fastCurrentSessionId}-${new Date().toISOString().slice(0,10)}.xlsx`); }); // CSV Export (서버 스트리밍) document.getElementById('btn-fast-export-csv')?.addEventListener('click', async () => { if (!fastCurrentSessionId) return; const res = await fetch(`/api/fast/${fastCurrentSessionId}/csv`); if (!res.ok) return; const blob = await res.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `fast-${fastCurrentSessionId}-${new Date().toISOString().slice(0,10)}.csv`; a.click(); URL.revokeObjectURL(url); }); // 탭 전환 시 세션 목록 갱신 document.querySelectorAll('[href="#pane-fast"]').forEach(a => { a.addEventListener('show.bs.tab', () => fastSessionsLoad()); }); ``` --- ## 사후 확인 (작업 후 반드시 수행) 1. `app.js` 파일을 다시 열어 추가된 함수 목록을 읽는다. 2. 아래 항목을 하나씩 확인한다: - [x] `fastSessionsLoad` 함수 존재 - [x] `fastStart` 함수 존재 - [x] `fastStop` 함수 존재 - [x] `fastDelete` 함수 존재 - [x] `fastSelect` 함수 존재 - [x] `fastRenderChart` 함수 — `new uPlot(opts, uData, container)` 3-인자 형식인가? - [x] `fastRenderChart` 함수 — uPlot x축 데이터가 `Unix seconds`인가? (`/ 1000` 적용) - [x] `btn-fast-export-xlsx` 핸들러 — `XLSX.utils.aoa_to_sheet(rows)` 사용하는가? - [x] `btn-fast-export-xlsx` 핸들러 — `rows`가 배열의 배열(`string[][]`)인가? - [x] `fastLivePollStart` — 2초(2000ms) 간격인가? - [x] `tagNames` 변수명이 기존 코드와 일치하는가? (다르면 수정) 3. 브라우저에서 테스트: - [x] `09 fastRecord` 탭 클릭 → 세션 목록 API 호출되는가? - [x] `신규 세션` 버튼 → 모달 열리고 태그 목록 표시되는가? - [x] 콘솔 에러가 없는가? --- ## 완료 조건 - [x] 브라우저 콘솔 에러 없음 - [x] `fastSessionsLoad()` 호출 시 API `/api/fast/sessions` 응답 정상 - [x] `new uPlot(opts, uData, container)` 3-인자 형식 사용