fix(#12): fastRecord UI 구현 - app.js JavaScript 로직 추가
This commit is contained in:
401
fastTable/step12.md
Normal file
401
fastTable/step12.md
Normal file
@@ -0,0 +1,401 @@
|
||||
# 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줄)
|
||||
|
||||
---
|
||||
|
||||
## 작업 내용
|
||||
|
||||
**파일**: `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-인자 형식 사용
|
||||
@@ -2047,3 +2047,354 @@ function fmtVal(v) {
|
||||
if (Number.isInteger(n)) return v; // 정수는 그대로
|
||||
return n.toFixed(2);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 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());
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user