458 lines
17 KiB
JavaScript
458 lines
17 KiB
JavaScript
(function () {
|
|
'use strict';
|
|
|
|
const page = document.getElementById('issueHistoryPage');
|
|
if (!page) return;
|
|
|
|
const windowDays = parseInt(page.getAttribute('data-window-days'), 10) || 90;
|
|
const base = page.getAttribute('data-base') || '/apistatus';
|
|
const apiDetailBase = page.getAttribute('data-api-detail-base') || '/apis/detail';
|
|
|
|
/** 오픈 API 목록에 노출되는 API (링크 가능 대상) */
|
|
const linkableApiIds = new Set();
|
|
|
|
const KIND_LABEL = { INCIDENT: '장애', DELAY: '지연', MAINTENANCE: '점검' };
|
|
const KIND_CLASS = { INCIDENT: 'is-incident', DELAY: 'is-degraded', MAINTENANCE: 'is-maintenance' };
|
|
const PAGE_SIZE = 10;
|
|
const API_LIST_LIMIT = 50; // 라이브서치 결과 표시 상한
|
|
|
|
const today = page.getAttribute('data-today') || '';
|
|
// My APIs 등에서 넘어올 때 표시용 API 명 (필터 자체는 apiId 로 동작)
|
|
const urlApiName = new URLSearchParams(window.location.search).get('apiName') || '';
|
|
const minDate = page.getAttribute('data-min-date') || '';
|
|
|
|
// 진입 시 날짜 미지정 = 조회 기간 전체
|
|
const state = {
|
|
date: page.getAttribute('data-selected-date') || '',
|
|
apiId: page.getAttribute('data-selected-api') || '',
|
|
kind: page.getAttribute('data-selected-kind') || '',
|
|
page: 0
|
|
};
|
|
|
|
const indexBar = document.getElementById('issueIndexBar');
|
|
const listEl = document.getElementById('issueList');
|
|
const pagerEl = document.getElementById('issuePager');
|
|
const totalCountEl = document.getElementById('issueTotalCount');
|
|
const selectedDateLabel = document.getElementById('selectedDateLabel');
|
|
const dateField = document.getElementById('dateField');
|
|
const dateInput = document.getElementById('filterDateInput');
|
|
const dateClearBtn = document.getElementById('filterDateClear');
|
|
const kindSelect = document.getElementById('filterKindSelect');
|
|
const apiCombo = document.getElementById('apiCombo');
|
|
const apiInput = document.getElementById('filterApiInput');
|
|
const apiClearBtn = document.getElementById('filterApiClear');
|
|
const apiListEl = document.getElementById('filterApiList');
|
|
const unlistedBanner = document.getElementById('unlistedApiBanner');
|
|
const unlistedName = document.getElementById('unlistedApiName');
|
|
|
|
let apiOptions = [];
|
|
|
|
function escapeHtml(text) {
|
|
if (text == null) return '';
|
|
return String(text)
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|
|
|
|
function fetchJson(url) {
|
|
return fetch(url, { credentials: 'same-origin', headers: { Accept: 'application/json' } })
|
|
.then(function (response) {
|
|
if (!response.ok) throw new Error('HTTP ' + response.status);
|
|
return response.json();
|
|
});
|
|
}
|
|
|
|
function parseDateTime(value) {
|
|
if (!value) return null;
|
|
const parsed = new Date(String(value).replace(' ', 'T'));
|
|
return isNaN(parsed.getTime()) ? null : parsed;
|
|
}
|
|
|
|
function pad(value) {
|
|
return value < 10 ? '0' + value : String(value);
|
|
}
|
|
|
|
function formatDate(value) {
|
|
const date = parseDateTime(value);
|
|
if (!date) return '-';
|
|
return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate());
|
|
}
|
|
|
|
function formatDateTime(value) {
|
|
const date = parseDateTime(value);
|
|
if (!date) return '-';
|
|
return formatDate(value) + ' ' + pad(date.getHours()) + ':' + pad(date.getMinutes());
|
|
}
|
|
|
|
function formatDuration(minutes) {
|
|
if (minutes == null) return '';
|
|
if (minutes < 60) return minutes + '분';
|
|
const hours = Math.floor(minutes / 60);
|
|
const rest = minutes % 60;
|
|
return rest === 0 ? hours + '시간' : hours + '시간 ' + rest + '분';
|
|
}
|
|
|
|
function buildQuery(params) {
|
|
const query = [];
|
|
Object.keys(params).forEach(function (key) {
|
|
if (params[key] !== '' && params[key] != null) {
|
|
query.push(key + '=' + encodeURIComponent(params[key]));
|
|
}
|
|
});
|
|
return query.length ? '?' + query.join('&') : '';
|
|
}
|
|
|
|
function syncBrowserUrl() {
|
|
if (!window.history || !window.history.replaceState) return;
|
|
window.history.replaceState(null, '',
|
|
base + '/issues' + buildQuery({ date: state.date, apiId: state.apiId, kind: state.kind }));
|
|
}
|
|
|
|
/** 필터 변경 후 목록/인덱스 재조회 */
|
|
function applyFilters(reloadIndex) {
|
|
state.page = 0;
|
|
syncBrowserUrl();
|
|
renderIndexBarSelection();
|
|
if (reloadIndex) loadIndexBar();
|
|
loadIssues();
|
|
}
|
|
|
|
// ---------------- 90일 인덱스바 ----------------
|
|
function renderIndexBar(entries) {
|
|
if (!entries || !entries.length) {
|
|
indexBar.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
indexBar.innerHTML = entries.map(function (entry) {
|
|
const hasIncident = entry.incCount > 0;
|
|
const hasDelay = entry.dlyCount > 0;
|
|
const hasMaintenance = entry.mntCount > 0;
|
|
|
|
// 유형이 2종 이상 섞인 날은 개별 색 대신 복합 색으로 표시한다
|
|
const kindCount = (hasIncident ? 1 : 0) + (hasDelay ? 1 : 0) + (hasMaintenance ? 1 : 0);
|
|
let cls = '';
|
|
if (kindCount > 1) cls = ' has-mixed';
|
|
else if (hasIncident) cls = ' has-incident';
|
|
else if (hasDelay) cls = ' has-degraded';
|
|
else if (hasMaintenance) cls = ' has-maintenance';
|
|
if (state.date && state.date === entry.date) cls += ' is-selected';
|
|
|
|
const parts = [];
|
|
if (hasIncident) parts.push('장애 ' + entry.incCount + '건');
|
|
if (hasDelay) parts.push('지연 ' + entry.dlyCount + '건');
|
|
if (hasMaintenance) parts.push('점검 ' + entry.mntCount + '건');
|
|
const tooltip = entry.date + (parts.length ? ' · ' + parts.join(' · ') : ' · 이슈 없음');
|
|
|
|
return '<button type="button" class="as-index-cell' + cls + '"'
|
|
+ ' data-date="' + escapeHtml(entry.date) + '"'
|
|
+ ' title="' + escapeHtml(tooltip) + '"></button>';
|
|
}).join('');
|
|
|
|
indexBar.querySelectorAll('.as-index-cell').forEach(function (cell) {
|
|
cell.addEventListener('click', function () {
|
|
const clicked = cell.getAttribute('data-date');
|
|
state.date = state.date === clicked ? '' : clicked;
|
|
applyFilters(false);
|
|
});
|
|
});
|
|
}
|
|
|
|
function renderIndexBarSelection() {
|
|
indexBar.querySelectorAll('.as-index-cell').forEach(function (cell) {
|
|
cell.classList.toggle('is-selected', state.date === cell.getAttribute('data-date'));
|
|
});
|
|
selectedDateLabel.textContent = state.date || '전체 기간';
|
|
if (dateInput.value !== state.date) {
|
|
dateInput.value = state.date;
|
|
}
|
|
dateField.classList.toggle('has-value', !!state.date);
|
|
}
|
|
|
|
// ---------------- 이슈 카드 ----------------
|
|
/**
|
|
* 영향 API 태그. 오픈 API 목록에 있는 API 만 상세 화면으로 링크하고,
|
|
* 목록에 없는 API(비공개·미편성)는 링크 없이 흐리게 표시한다.
|
|
*/
|
|
function apiPillsHtml(apis) {
|
|
if (!apis || !apis.length) return '';
|
|
const pills = apis.map(function (api) {
|
|
const text = escapeHtml(api.apiName || api.apiId);
|
|
if (!linkableApiIds.has(api.apiId)) {
|
|
return '<span class="as-api-pill is-unlinked" title="공개된 API 목록에 없는 API 입니다">'
|
|
+ text + '</span>';
|
|
}
|
|
return '<a class="as-api-pill" href="' + apiDetailBase + '?id=' + encodeURIComponent(api.apiId) + '">'
|
|
+ text + '</a>';
|
|
}).join('');
|
|
return '<div class="as-api-pills"><span>영향 API:</span>' + pills + '</div>';
|
|
}
|
|
|
|
function issueCardHtml(issue) {
|
|
const kindClass = KIND_CLASS[issue.kind] || 'is-incident';
|
|
|
|
let inner;
|
|
if (issue.kind === 'MAINTENANCE') {
|
|
inner = '<p class="as-tl-body">' + escapeHtml(issue.summary || '점검 안내') + '</p>';
|
|
} else {
|
|
const items = (issue.timeline || []).map(function (entry) {
|
|
return '<div class="as-tl-item state-' + escapeHtml(entry.stateAfter || 'NONE') + '">'
|
|
+ '<span class="as-dot"></span>'
|
|
+ '<p class="as-tl-label">' + escapeHtml(entry.labelKo || '진행 상황') + '</p>'
|
|
+ '<p class="as-tl-body">' + escapeHtml(entry.body) + '</p>'
|
|
+ '<p class="as-tl-ts">' + escapeHtml(formatDateTime(entry.eventAt)) + '</p>'
|
|
+ '</div>';
|
|
}).join('');
|
|
inner = items
|
|
? '<div class="as-tl-block">' + items + '</div>'
|
|
: '<p class="as-tl-body">' + escapeHtml(issue.summary || '상세 진행 내역이 등록되지 않았습니다.') + '</p>';
|
|
}
|
|
|
|
const period = formatDateTime(issue.startedAt)
|
|
+ (issue.endAt ? ' ~ ' + formatDateTime(issue.endAt) : ' ~ 진행 중')
|
|
+ (issue.durationMinutes != null ? ' (' + formatDuration(issue.durationMinutes) + ')' : '');
|
|
|
|
return '<article class="as-issue-card ' + kindClass + '">'
|
|
+ '<div class="as-issue-meta-row">'
|
|
+ ' <span class="as-badge ' + kindClass + '">' + escapeHtml(KIND_LABEL[issue.kind] || '') + '</span>'
|
|
+ ' <span>' + escapeHtml(period) + '</span>'
|
|
+ (issue.stateLabel ? '<span>' + escapeHtml(issue.stateLabel) + '</span>' : '')
|
|
+ '</div>'
|
|
+ '<h3 class="as-issue-title">' + escapeHtml(issue.title) + '</h3>'
|
|
+ inner
|
|
+ apiPillsHtml(issue.impactedApis)
|
|
+ '</article>';
|
|
}
|
|
|
|
function renderPager(pageData) {
|
|
const totalPages = pageData.totalPages || 0;
|
|
if (totalPages <= 1) {
|
|
pagerEl.innerHTML = '';
|
|
return;
|
|
}
|
|
let html = '';
|
|
for (let index = 0; index < totalPages; index++) {
|
|
const current = index === pageData.number;
|
|
html += '<button type="button" class="as-btn' + (current ? ' is-current' : '') + '"'
|
|
+ ' data-page="' + index + '"' + (current ? ' disabled' : '') + '>' + (index + 1) + '</button>';
|
|
}
|
|
pagerEl.innerHTML = html;
|
|
pagerEl.querySelectorAll('button[data-page]').forEach(function (button) {
|
|
button.addEventListener('click', function () {
|
|
state.page = parseInt(button.getAttribute('data-page'), 10) || 0;
|
|
loadIssues();
|
|
window.scrollTo({ top: listEl.offsetTop - 80, behavior: 'smooth' });
|
|
});
|
|
});
|
|
}
|
|
|
|
function loadIssues() {
|
|
const query = buildQuery({
|
|
date: state.date,
|
|
apiId: state.apiId,
|
|
kind: state.kind,
|
|
page: state.page,
|
|
size: PAGE_SIZE
|
|
});
|
|
|
|
fetchJson(base + '/issues/list.json' + query)
|
|
.then(function (pageData) {
|
|
const content = pageData.content || [];
|
|
totalCountEl.textContent = pageData.totalElements != null ? pageData.totalElements : content.length;
|
|
|
|
listEl.innerHTML = content.length
|
|
? content.map(issueCardHtml).join('')
|
|
: '<div class="as-empty">조건에 해당하는 이슈가 없습니다.</div>';
|
|
renderPager(pageData);
|
|
})
|
|
.catch(function () {
|
|
listEl.innerHTML = '<div class="as-empty">이슈 목록을 불러올 수 없습니다.</div>';
|
|
pagerEl.innerHTML = '';
|
|
});
|
|
}
|
|
|
|
// ---------------- API 필터 (라이브서치 콤보박스) ----------------
|
|
|
|
/** 옵션 = 오픈 API 목록과 동일 기준(그룹 편성 + 사용자 공개). 표시는 API 명만. */
|
|
function loadApiOptions() {
|
|
return fetchJson(base + '/apis.json')
|
|
.then(function (apis) {
|
|
apiOptions = (apis || []).filter(function (api) { return api.apiId; });
|
|
apiOptions.forEach(function (api) { linkableApiIds.add(api.apiId); });
|
|
// URL 로 들어온 apiId 는 옵션(오픈 API 목록)에 없어도 필터를 유지한다.
|
|
// My APIs(기관 계약 API)처럼 오픈 API 목록 밖의 API 로도 진입하기 때문.
|
|
renderApiInput();
|
|
})
|
|
.catch(function () { /* 옵션 조회 실패 시 전체 API 기준으로 동작 */ });
|
|
}
|
|
|
|
function findApiOption(apiId) {
|
|
if (!apiId) return null;
|
|
for (let index = 0; index < apiOptions.length; index++) {
|
|
if (apiOptions[index].apiId === apiId) return apiOptions[index];
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/** 표시 형태: "[그룹명] API명" (그룹 없으면 API명만) */
|
|
function optionLabel(api) {
|
|
if (!api) return '';
|
|
return api.groupName ? '[' + api.groupName + '] ' + api.apiName : api.apiName;
|
|
}
|
|
|
|
function renderApiInput() {
|
|
const selected = findApiOption(state.apiId);
|
|
// 옵션에 없는 API(오픈 API 목록 밖)는 전달받은 API 명 또는 ID 로 표시해 필터 상태를 보이게 한다
|
|
apiInput.value = selected ? optionLabel(selected) : (state.apiId ? (urlApiName || state.apiId) : '');
|
|
apiCombo.classList.toggle('has-value', !!state.apiId);
|
|
|
|
// 포탈 미게시 API 필터 안내 배너 (옵션 목록을 받은 뒤에만 판단)
|
|
const unlisted = !!state.apiId && apiOptions.length > 0 && !selected;
|
|
if (unlisted) {
|
|
unlistedName.textContent = urlApiName || state.apiId;
|
|
}
|
|
unlistedBanner.style.display = unlisted ? '' : 'none';
|
|
}
|
|
|
|
function closeApiList() {
|
|
apiListEl.hidden = true;
|
|
apiInput.setAttribute('aria-expanded', 'false');
|
|
}
|
|
|
|
function openApiList(keyword) {
|
|
const query = (keyword || '').trim().toLowerCase();
|
|
const matched = apiOptions.filter(function (api) {
|
|
if (!query) return true;
|
|
return optionLabel(api).toLowerCase().indexOf(query) >= 0;
|
|
});
|
|
|
|
if (!matched.length) {
|
|
apiListEl.innerHTML = '<li class="as-combo-empty">검색 결과가 없습니다</li>';
|
|
} else {
|
|
const items = matched.slice(0, API_LIST_LIMIT).map(function (api) {
|
|
return '<li role="option" class="as-combo-item" data-api-id="' + escapeHtml(api.apiId) + '">'
|
|
+ escapeHtml(optionLabel(api)) + '</li>';
|
|
});
|
|
if (matched.length > API_LIST_LIMIT) {
|
|
items.push('<li class="as-combo-empty">이하 '
|
|
+ (matched.length - API_LIST_LIMIT) + '건은 검색어를 입력해 좁혀주세요</li>');
|
|
}
|
|
apiListEl.innerHTML = '<li role="option" class="as-combo-item" data-api-id="">전체 API</li>'
|
|
+ items.join('');
|
|
}
|
|
|
|
apiListEl.hidden = false;
|
|
apiInput.setAttribute('aria-expanded', 'true');
|
|
}
|
|
|
|
function selectApi(apiId) {
|
|
state.apiId = apiId || '';
|
|
renderApiInput();
|
|
closeApiList();
|
|
applyFilters(true);
|
|
}
|
|
|
|
function loadIndexBar() {
|
|
fetchJson(base + '/issues/dates.json'
|
|
+ buildQuery({ days: windowDays, apiId: state.apiId, kind: state.kind }))
|
|
.then(function (entries) {
|
|
renderIndexBar(entries);
|
|
renderIndexBarSelection();
|
|
})
|
|
.catch(function () { indexBar.innerHTML = ''; });
|
|
}
|
|
|
|
// ---------------- 필터 이벤트 ----------------
|
|
|
|
dateInput.addEventListener('change', function () {
|
|
const value = dateInput.value;
|
|
// 조회 기간(90일) 밖 날짜는 되돌린다
|
|
if (value && ((minDate && value < minDate) || (today && value > today))) {
|
|
dateInput.value = state.date;
|
|
return;
|
|
}
|
|
state.date = value;
|
|
applyFilters(false);
|
|
});
|
|
|
|
dateClearBtn.addEventListener('click', function () {
|
|
state.date = '';
|
|
dateInput.value = '';
|
|
applyFilters(false);
|
|
});
|
|
|
|
kindSelect.addEventListener('change', function () {
|
|
state.kind = kindSelect.value;
|
|
applyFilters(true);
|
|
});
|
|
|
|
// readonly 자동완성 차단 트릭: 브라우저 autofill 은 readonly 입력을 후보에서 제외한다.
|
|
// 사용자가 실제로 만지는 순간에만 readonly 를 풀어 입력을 허용한다 (모바일은 touchstart 가 먼저 온다).
|
|
function unlockApiInput() {
|
|
if (apiInput.hasAttribute('readonly')) {
|
|
apiInput.removeAttribute('readonly');
|
|
}
|
|
}
|
|
apiInput.addEventListener('touchstart', unlockApiInput, { passive: true });
|
|
apiInput.addEventListener('mousedown', unlockApiInput);
|
|
|
|
apiInput.addEventListener('focus', function () {
|
|
unlockApiInput();
|
|
openApiList('');
|
|
});
|
|
|
|
apiInput.addEventListener('input', function () { openApiList(apiInput.value); });
|
|
|
|
apiInput.addEventListener('keydown', function (event) {
|
|
if (event.key === 'Escape') {
|
|
renderApiInput();
|
|
closeApiList();
|
|
} else if (event.key === 'Enter') {
|
|
event.preventDefault();
|
|
const first = apiListEl.querySelector('.as-combo-item');
|
|
if (first) selectApi(first.getAttribute('data-api-id'));
|
|
}
|
|
});
|
|
|
|
apiListEl.addEventListener('mousedown', function (event) {
|
|
const item = event.target.closest('.as-combo-item');
|
|
if (!item) return;
|
|
event.preventDefault();
|
|
selectApi(item.getAttribute('data-api-id'));
|
|
});
|
|
|
|
apiClearBtn.addEventListener('click', function () {
|
|
apiInput.value = '';
|
|
selectApi('');
|
|
});
|
|
|
|
document.addEventListener('click', function (event) {
|
|
if (!apiCombo.contains(event.target)) {
|
|
renderApiInput();
|
|
closeApiList();
|
|
}
|
|
});
|
|
|
|
document.getElementById('filterResetBtn').addEventListener('click', function () {
|
|
state.date = '';
|
|
state.apiId = '';
|
|
state.kind = '';
|
|
dateInput.value = '';
|
|
kindSelect.value = '';
|
|
renderApiInput();
|
|
closeApiList();
|
|
applyFilters(true);
|
|
});
|
|
|
|
// ---------------- 초기 로딩 ----------------
|
|
dateInput.value = state.date;
|
|
kindSelect.value = state.kind;
|
|
renderIndexBarSelection();
|
|
loadIndexBar();
|
|
// 영향 API 태그의 링크 여부 판단에 오픈 API 목록이 필요하므로 옵션을 먼저 받는다
|
|
loadApiOptions().then(loadIssues);
|
|
})();
|