Files
ExperionCrawler/fastTable/step12.md

18 KiB

STEP 12 — UI: app.js JavaScript 로직 추가

사전 확인 (작업 전 반드시 수행)

  1. src/Web/wwwroot/js/app.js 파일을 열어 전체 내용을 읽는다.
  2. 아래 항목을 확인하고 기록한다:
    • STEP 11이 완료되어 HTML 요소들이 존재하는가?
    • fastSessionsLoad 함수가 이미 존재하는가? → 없음 (신규 추가)
    • 기존 코드에서 태그 목록을 담는 변수명이 무엇인가? (tagNames 또는 다른 이름 확인)
    • uPlot이 전역으로 로드되어 있는가? (STEP 11에서 추가했는가)
    • XLSX 객체가 전역으로 로드되어 있는가? (SheetJS 사용 확인)
    • 파일 끝 위치(줄 번호)를 확인한다 (2050줄)

사후 확인 (작업 후 반드시 수행)

  1. app.js 파일을 다시 열어 추가된 함수 목록을 읽는다.
  2. 아래 항목을 하나씩 확인한다:
    • fastSessionsLoad 함수 존재
    • fastStart 함수 존재
    • fastStop 함수 존재
    • fastDelete 함수 존재
    • fastSelect 함수 존재
    • fastRenderChart 함수 — new uPlot(opts, uData, container) 3-인자 형식인가?
    • fastRenderChart 함수 — uPlot x축 데이터가 Unix seconds인가? (/ 1000 적용)
    • btn-fast-export-xlsx 핸들러 — XLSX.utils.aoa_to_sheet(rows) 사용하는가?
    • btn-fast-export-xlsx 핸들러 — rows가 배열의 배열(string[][])인가?
    • fastLivePollStart — 2초(2000ms) 간격인가?
    • tagNames 변수명이 기존 코드와 일치하는가? (다르면 수정)
  3. 브라우저에서 테스트:
    • 09 fastRecord 탭 클릭 → 세션 목록 API 호출되는가?
    • 신규 세션 버튼 → 모달 열리고 태그 목록 표시되는가?
    • 콘솔 에러가 없는가?

완료 조건

  • 브라우저 콘솔 에러 없음
  • fastSessionsLoad() 호출 시 API /api/fast/sessions 응답 정상
  • new uPlot(opts, uData, container) 3-인자 형식 사용
  • 빌드 검증 완료 (dotnet build 성공)
  • 커밋 완료 (fix(#12): fastRecord UI 구현)

작업 내용

파일: src/Web/wwwroot/js/app.js
위치: 파일 하단 (기존 코드 마지막 줄 아래)

// ═══════════════════════════════════════════════════════════════
// 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. 아래 항목을 하나씩 확인한다:
    • fastSessionsLoad 함수 존재
    • fastStart 함수 존재
    • fastStop 함수 존재
    • fastDelete 함수 존재
    • fastSelect 함수 존재
    • fastRenderChart 함수 — new uPlot(opts, uData, container) 3-인자 형식인가?
    • fastRenderChart 함수 — uPlot x축 데이터가 Unix seconds인가? (/ 1000 적용)
    • btn-fast-export-xlsx 핸들러 — XLSX.utils.aoa_to_sheet(rows) 사용하는가?
    • btn-fast-export-xlsx 핸들러 — rows가 배열의 배열(string[][])인가?
    • fastLivePollStart — 2초(2000ms) 간격인가?
    • tagNames 변수명이 기존 코드와 일치하는가? (다르면 수정)
  3. 브라우저에서 테스트:
    • 09 fastRecord 탭 클릭 → 세션 목록 API 호출되는가?
    • 신규 세션 버튼 → 모달 열리고 태그 목록 표시되는가?
    • 콘솔 에러가 없는가?

완료 조건

  • 브라우저 콘솔 에러 없음
  • fastSessionsLoad() 호출 시 API /api/fast/sessions 응답 정상
  • new uPlot(opts, uData, container) 3-인자 형식 사용