API 응답이 PascalCase(RecordedAt, Values)인데 프론트엔드가 camelCase(recordedAt, values)로 접근해 데이터 미표시 현상 수정. - hist.js: recordedAt→RecordedAt, values→Values - trend.js: consistency fix - index.html: cache busting 버전 갱신 (20260604→20260611)
384 lines
15 KiB
JavaScript
384 lines
15 KiB
JavaScript
/* ─────────────────────────────────────────────────────────────
|
||
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);
|
||
}
|