Files
HC900-Crawler/src/Hc900Crawler/wwwroot/js/hist.js
windpacer 0057bd3a44 fix(ui): history query - match PascalCase JSON property names
API 응답이 PascalCase(RecordedAt, Values)인데 프론트엔드가
camelCase(recordedAt, values)로 접근해 데이터 미표시 현상 수정.
- hist.js: recordedAt→RecordedAt, values→Values
- trend.js: consistency fix
- index.html: cache busting 버전 갱신 (20260604→20260611)
2026-06-11 04:39:19 +09:00

384 lines
15 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ─────────────────────────────────────────────────────────────
07 이력 조회
───────────────────────────────────────────────────────────── */
const HIST_TAG_IDS = ['hf-t1','hf-t2','hf-t3','hf-t4','hf-t5','hf-t6','hf-t7','hf-t8'];
// 상태 표시기 업데이트 함수
function histUpdateStatus(state, message) {
const el = document.getElementById('hist-load-status');
if (!el) return;
// 상태 클래스 제거 후 재설정
el.classList.remove('loading', 'success', 'error');
if (state) {
el.classList.add(state);
}
// 아이콘/텍스트 업데이트 - .status-dot span은 유지
const icons = { loading: '⏳', success: '✅', error: '❌' };
const icon = icons[state] || '';
// .status-dot span 찾기
const dot = el.querySelector('.status-dot');
// 텍스트 노드 업데이트 (첫 번째 텍스트 노드)
const textNode = Array.from(el.childNodes).find(n => n.nodeType === Node.TEXT_NODE);
if (textNode) {
textNode.textContent = icon ? `${icon} ${message}` : message;
}
}
// "▼ 옵션 불러오기" 버튼 클릭 시에만 호출 — 탭 진입 시 자동 호출 없음
async function histLoad() {
const el = document.getElementById('hist-load-status');
if (!el) return;
// 로딩 상태 표시
histUpdateStatus('loading', '조회 중...');
try {
const startTime = Date.now();
const d = await api('GET', '/api/history/tags');
const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
const tagNames = d || [];
if (tagNames.length === 0) {
histUpdateStatus('error', '조회 데이터 없음 (0개)');
} else {
const opts = '<option value="">— 선택 안 함 —</option>' +
tagNames.map(t => `<option value="${esc(t)}">${esc(t)}</option>`).join('');
// 기존 선택값 보존
const selectedValues = HIST_TAG_IDS.map(id => document.getElementById(id).value);
// DOM 업데이트를 requestAnimationFrame으로 지연하여 레이아웃 충돌 방지
requestAnimationFrame(() => {
HIST_TAG_IDS.forEach((id, index) => {
const sel = document.getElementById(id);
sel.innerHTML = opts;
if (selectedValues[index]) sel.value = selectedValues[index];
});
});
histUpdateStatus('success', `옵션 조회 완료 (${tagNames.length}개, ${elapsed}초)`);
}
} catch (e) {
console.error('histLoad:', e);
histUpdateStatus('error', `조회 실패: ${e.message}`);
}
}
async function histQuery() {
const tags = HIST_TAG_IDS.map(id => document.getElementById(id).value).filter(Boolean);
const from = document.getElementById('hf-from').value;
const to = document.getElementById('hf-to').value;
const interval = document.getElementById('hf-interval').value || '5 minutes';
const limit = parseInt(document.getElementById('hf-limit').value) || 500;
if (!tags.length) {
histShowStatus('err', '❌', '태그를 최소 1개 선택하세요', ' 태그 선택란에서 태그를 선택해주세요.');
return;
}
// 간격이 기본값(60초)과 다른 경우 시간 간격 조회 API 사용
if (interval === '1 minute') {
// 기본 간격(1분) 이하인 경우 기존 API 사용
histShowStatus('busy', '⏳', '조회 중...', ` ${tags.length}개 태그, 제한: ${limit}`);
try {
const d = await api('POST', '/api/history/query', {
tagNames: tags,
from: from ? new Date(from).toISOString() : null,
to: to ? new Date(to).toISOString() : null,
limit: limit
});
const rows = d.Rows || [];
const tNames = d.TagNames || [];
renderHistoryTable(rows, tNames, interval);
} catch (e) {
histShowStatus('err', '❌', '조회 실패', ` ${e.message}\n\n컨솔에서 상세 오류를 확인하세요.`);
setGlobal('err', '조회 실패');
console.error('histQuery 오류:', e);
}
} else {
// 사용자 지정 간격인 경우 시간 간격 조회 API 사용
const body = {
tagNames: tags,
from: from ? new Date(from).toISOString() : null,
to: to ? new Date(to).toISOString() : null,
interval: interval,
limit: limit
};
// 상태 표시: 조회 시작
histShowStatus('busy', '⏳', '조회 중...', ` ${tags.length}개 태그, 간격: ${interval}, 제한: ${limit}`);
try {
const d = await api('POST', '/api/text-to-sql/query-history-interval', body);
if (!d.success) {
throw new Error(d.error || '조회 실패');
}
const rows = d.rows || [];
const tNames = d.tagNames || [];
renderHistoryTable(rows, tNames, interval, d.baseIntervalSeconds, d.queryInterval);
} catch (e) {
histShowStatus('err', '❌', '조회 실패', ` ${e.message}\n\n컨솔에서 상세 오류를 확인하세요.`);
setGlobal('err', '조회 실패');
console.error('histQuery 오류:', e);
}
}
}
/**
* 이력 조회 결과 테이블 렌더링
*/
function renderHistoryTable(rows, tNames, interval, baseIntervalSeconds, queryInterval) {
const tbl = document.getElementById('hist-table');
tbl.classList.remove('hidden');
const info = document.getElementById('hist-result-info');
info.classList.remove('hidden');
let infoText = `${rows.length.toLocaleString()}× ${tNames.length}개 태그`;
if (queryInterval) {
infoText += ` | 집계 간격: ${queryInterval}`;
}
info.textContent = infoText;
if (!rows.length) {
histShowStatus('ok', '✅', '조회 완료 (0건)', ` 조건에 맞는 데이터가 없습니다. | 태그: ${tNames.join(', ') || '전체'}`);
tbl.innerHTML = '<div style="padding:20px;color:var(--t2)">조건에 맞는 이력이 없습니다.</div>';
setGlobal('ok', '0건');
return;
}
// 시간 간격 조회인 경우 TimeBucket 열 사용
const timeColumn = rows[0].timeBucket ? 'timeBucket' : 'RecordedAt';
tbl.innerHTML = `
<table>
<thead>
<tr>
<th>${queryInterval ? '집계 시각' : '시각'}</th>
${tNames.map(t => `<th>${esc(t.toUpperCase())}</th>`).join('')}
</tr>
</thead>
<tbody>
${rows.map(r => `
<tr>
<td class="mut" style="white-space:nowrap">${fmtTs(r[timeColumn])}</td>
${tNames.map(t => {
const raw = r.Values?.[t] ?? null;
const display = raw != null ? esc(String(fmtVal(raw))) : '<span style="color:var(--t3)">—</span>';
return `<td class="val">${display}</td>`;
}).join('')}
</tr>
`).join('')}
</tbody>
</table>
`;
let detailText = ` 태그: ${tNames.join(', ')}`;
if (queryInterval) {
detailText += ` | 집계 간격: ${queryInterval}`;
}
detailText += ` | 시각 범위: ${document.getElementById('hf-from').value || '전체'} ~ ${document.getElementById('hf-to').value || '전체'}`;
histShowStatus('ok', '✅', `조회 완료 (${rows.length}건)`, detailText);
setGlobal('ok', `${rows.length}`);
}
/**
* 이력 조회 상태 표시 창 업데이트
* @param {string} state - 'pending' | 'busy' | 'ok' | 'err'
* @param {string} icon - 표시할 아이콘
* @param {string} text - 메인 상태 텍스트
* @param {string} detail - 상세 설명
*/
function histShowStatus(state, icon, text, detail) {
const box = document.getElementById('hist-status-box');
const iconEl = document.getElementById('hist-status-icon');
const textEl = document.getElementById('hist-status-text');
const detEl = document.getElementById('hist-status-detail');
box.classList.remove('hidden', 'pending', 'busy', 'ok', 'err');
box.classList.add(state);
iconEl.textContent = icon;
textEl.textContent = text;
detEl.textContent = detail || '';
}
function histReset() {
HIST_TAG_IDS.forEach(id => { document.getElementById(id).value = ''; });
dtClearField('from');
dtClearField('to');
document.getElementById('hf-interval').value = '1 minute';
document.getElementById('hf-limit').value = '500';
document.getElementById('hist-result-info').classList.add('hidden');
document.getElementById('hist-table').classList.add('hidden');
// 상태 표시 창 초기화
histShowStatus('pending', '⏸', '대기 중', '조회 조건을 설정하고 조회 버튼을 눌러주세요.');
}
/* ─────────────────────────────────────────────────────────────
07-2 하이퍼테이블 관리
───────────────────────────────────────────────────────────── */
/**
* 하이퍼테이블 상태 불러오기
*/
async function htLoadStatus() {
try {
const d = await api('GET', '/api/experion/hypertable/status');
// 상태 표시 창 업데이트
const box = document.getElementById('ht-status-box');
const iconEl = document.getElementById('ht-status-icon');
const textEl = document.getElementById('ht-status-text');
const detEl = document.getElementById('ht-status-detail');
box.classList.remove('hidden', 'pending', 'busy', 'ok', 'err');
// 정보 패널 업데이트
const infoPanel = document.getElementById('ht-info-panel');
document.getElementById('ht-info-table').textContent = d.tableName || '-';
document.getElementById('ht-info-records').textContent = d.recordCount != null ? d.recordCount.toLocaleString() : '-';
document.getElementById('ht-info-retention').textContent = d.hasRetentionPolicy ? '설정됨' : '미설정';
document.getElementById('ht-info-compression').textContent = d.hasCompression ? '활성화됨' : '비활성화됨';
if (d.isHypertable) {
box.classList.add('ok');
iconEl.textContent = '✅';
textEl.textContent = '하이퍼테이블 활성화됨';
detEl.textContent = d.statusMessage || 'history_table이 하이퍼테이블로 변환되었습니다.';
infoPanel.classList.remove('hidden');
} else {
box.classList.add('pending');
iconEl.textContent = '⚠️';
textEl.textContent = '일반 테이블';
detEl.textContent = d.statusMessage || 'history_table이 아직 하이퍼테이블로 변환되지 않았습니다.';
infoPanel.classList.add('hidden');
}
} catch (e) {
console.error('htLoadStatus 오류:', e);
const box = document.getElementById('ht-status-box');
const iconEl = document.getElementById('ht-status-icon');
const textEl = document.getElementById('ht-status-text');
const detEl = document.getElementById('ht-status-detail');
box.classList.remove('hidden', 'pending', 'busy', 'ok', 'err');
box.classList.add('err');
iconEl.textContent = '❌';
textEl.textContent = '상태 조회 실패';
detEl.textContent = e.message || '알 수 없는 오류';
document.getElementById('ht-info-panel').classList.add('hidden');
}
}
/**
* 하이퍼테이블 생성
*/
async function htCreate() {
const tableName = document.getElementById('ht-table-name').value || 'history_table';
const retentionEnabled = document.getElementById('ht-auto-retention').checked;
const compressionEnabled = document.getElementById('ht-auto-compression').checked;
const aggregateEnabled = document.getElementById('ht-auto-aggregate').checked;
// 생성 전 확인
const confirmMsg = retentionEnabled || compressionEnabled
? `다음 옵션으로 하이퍼테이블을 생성합니다:\n\n` +
`- 테이블명: ${tableName}\n` +
`- 보관 기간: ${retentionEnabled ? document.getElementById('ht-retention-period').value : '설정 안함'}\n` +
`- 압축: ${compressionEnabled ? '활성화 (' + document.getElementById('ht-compression-period').value + ')' : '비활성화'}\n` +
`- 연속 집계: ${aggregateEnabled ? '생성' : '생성 안함'}\n\n` +
`계속 진행하시겠습니까?`
: `기본 설정으로 하이퍼테이블을 생성합니다:\n\n` +
`- 테이블명: ${tableName}\n` +
`- 보관 기간: 설정 안함\n` +
`- 압축: 설정 안함\n\n` +
`계속 진행하시겠습니까?`;
if (!confirm(confirmMsg)) return;
try {
// 상태 표시 창을 로딩 모드로
const box = document.getElementById('ht-status-box');
const iconEl = document.getElementById('ht-status-icon');
const textEl = document.getElementById('ht-status-text');
const detEl = document.getElementById('ht-status-detail');
box.classList.remove('hidden', 'pending', 'ok', 'err');
box.classList.add('busy');
iconEl.textContent = '⏳';
textEl.textContent = '생성 중...';
detEl.textContent = '하이퍼테이블을 생성하고 있습니다. 잠시 기다려주세요.';
document.getElementById('ht-info-panel').classList.add('hidden');
const body = {
tableName: tableName,
timeColumn: 'recorded_at',
timeInterval: '1 day',
migrateData: true,
setRetentionPolicy: retentionEnabled,
retentionPeriod: retentionEnabled ? document.getElementById('ht-retention-period').value : undefined,
enableCompression: compressionEnabled,
compressionPeriod: compressionEnabled ? document.getElementById('ht-compression-period').value : undefined,
createContinuousAggregate: aggregateEnabled
};
// 불필요한 필드는 제거
Object.keys(body).forEach(key => {
if (body[key] === undefined) delete body[key];
});
await api('POST', '/api/experion/hypertable/create', body);
// 상태 표시: 성공
box.classList.remove('busy');
box.classList.add('ok');
iconEl.textContent = '✅';
textEl.textContent = '생성 완료';
detEl.textContent = '하이퍼테이블이 성공적으로 생성되었습니다. 상태 새로고침 버튼을 눌러주세요.';
// 상태 새로고침
setTimeout(() => htLoadStatus(), 500);
} catch (e) {
console.error('htCreate 오류:', e);
const box = document.getElementById('ht-status-box');
const iconEl = document.getElementById('ht-status-icon');
const textEl = document.getElementById('ht-status-text');
const detEl = document.getElementById('ht-status-detail');
box.classList.remove('busy', 'pending', 'ok', 'err');
box.classList.add('err');
iconEl.textContent = '❌';
textEl.textContent = '생성 실패';
detEl.textContent = e.message || '알 수 없는 오류';
}
}
/**
* 보관 기간 설정 토글
*/
function htToggleRetention() {
const checked = document.getElementById('ht-auto-retention').checked;
const panel = document.getElementById('ht-retention-panel');
panel.classList.toggle('ht-hidden', !checked);
}
/**
* 압축 설정 토글
*/
function htToggleCompression() {
const checked = document.getElementById('ht-auto-compression').checked;
const panel = document.getElementById('ht-compression-panel');
panel.classList.toggle('ht-hidden', !checked);
}