430 lines
18 KiB
Markdown
430 lines
18 KiB
Markdown
# 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: '<span class="badge bg-success">실행중</span>',
|
|
Completed: '<span class="badge bg-primary">완료</span>',
|
|
Cancelled: '<span class="badge bg-secondary">취소</span>',
|
|
Failed: '<span class="badge bg-danger">실패</span>',
|
|
RowLimitReached: '<span class="badge bg-warning text-dark">행제한</span>',
|
|
Pending: '<span class="badge bg-light text-dark">대기</span>'
|
|
}[s.status] ?? `<span class="badge bg-secondary">${s.status}</span>`;
|
|
|
|
item.innerHTML = `
|
|
<div class="d-flex w-100 justify-content-between align-items-center">
|
|
<h6 class="mb-1 text-truncate" style="max-width:130px;" title="${s.name}">${s.name}</h6>
|
|
${statusBadge}${s.pinned ? ' 📌' : ''}
|
|
</div>
|
|
<p class="mb-1 small">${s.tagCount}tags · ${s.samplingMs}ms · ${fastFormatDuration(s.durationSec)}</p>
|
|
<small class="text-muted">${fastFormatDateTime(s.startedAt)}</small>
|
|
`;
|
|
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 = '<div class="text-center text-muted pt-5">수집된 데이터가 없습니다.</div>';
|
|
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-인자 형식 사용 |