(function () { "use strict"; function transitionsOnly(events) { return events.filter(function (event) { return event.event_type === "transition"; }); } function orderedEvents(events) { return events.slice().sort(function (a, b) { const left = Date.parse(a.timestamp); const right = Date.parse(b.timestamp); return (Number.isFinite(right) ? right : -Infinity) - (Number.isFinite(left) ? left : -Infinity); }); } function summaryFor(kind, events) { const groups = kind === "system" ? [["info", "정보"], ["warning", "경고"], ["critical", "치명적"]] : kind === "port" ? [["rx", "수신 RX"], ["tx", "전송 TX"], ["session", "세션"]] : [["up", "UP"], ["down", "DOWN"], ["unknown", "미확인"]]; return groups.map(function (group) { const count = events.filter(function (event) { if (kind === "system") { return String(event.severity || "").toLowerCase() === group[0]; } if (kind === "link") { return String(event.state || "unknown").toLowerCase() === group[0]; } return String(group[0] === "session" ? event.event_type : event.direction).toLowerCase() === group[0]; }).length; return { key: group[0], label: group[1], count: count }; }); } function timestampParts(input, timeZone) { const date = input ? new Date(input) : new Date(NaN); if (Number.isNaN(date.getTime())) { return { date: "—", time: String(input || "—"), unset: false }; } try { const values = {}; new Intl.DateTimeFormat("en-CA", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit", hourCycle: "h23", timeZone: timeZone }).formatToParts(date).forEach(function (part) { values[part.type] = part.value; }); return { date: values.year + "-" + values.month + "-" + values.day, time: values.hour + ":" + values.minute + ":" + values.second, unset: date.getUTCFullYear() < 2000 }; } catch (_) { return { date: "—", time: String(input), unset: date.getUTCFullYear() < 2000 }; } } function highlightParts(input, keyword) { const content = String(input ?? "—"); const needle = String(keyword || "").trim(); if (!needle) { return [{ text: content, match: false }]; } const lower = content.toLowerCase(); const parts = []; let offset = 0; let found; while ((found = lower.indexOf(needle.toLowerCase(), offset)) !== -1) { if (found > offset) { parts.push({ text: content.slice(offset, found), match: false }); } parts.push({ text: content.slice(found, found + needle.length), match: true }); offset = found + needle.length; } if (offset < content.length) { parts.push({ text: content.slice(offset), match: false }); } return parts; } const api = { transitionsOnly: transitionsOnly, orderedEvents: orderedEvents, summaryFor: summaryFor, timestampParts: timestampParts, highlightParts: highlightParts }; if (typeof module !== "undefined" && module.exports) { module.exports = api; } if (typeof window === "undefined" || typeof document === "undefined") { return; } function node(tag, className, text) { const element = document.createElement(tag); if (className) { element.className = className; } if (text !== undefined) { element.textContent = String(text); } return element; } function raw(element) { element.setAttribute("data-i18n-skip", ""); return element; } function highlighted(element, content, keyword) { highlightParts(content, keyword).forEach(function (part) { element.append(part.match ? node("mark", "", part.text) : document.createTextNode(part.text)); }); } function badge(label, tone) { const element = node("span", "log-badge", label); element.dataset.tone = tone; return element; } function mount(kind, form, panel) { const filterPanel = form.closest(".panel"); filterPanel.classList.add("log-filter-panel"); panel.classList.add("log-result-panel"); panel.dataset.logKind = kind; const table = panel.querySelector("table"); table.setAttribute("aria-label", panel.querySelector("h2").textContent); table.querySelectorAll("th").forEach(function (cell) { cell.scope = "col"; }); const tableBody = table.querySelector("tbody"); const mainGrid = form.querySelector(".form-grid"); const actions = form.querySelector(".panel-actions"); const line = node("div", "log-filter-line"); mainGrid.before(line); line.append(mainGrid, actions); const footer = node("div", "log-filter-footer"); const advanced = node("details", "log-advanced"); advanced.appendChild(node("summary", "", "상세 검색 · 기간 / 추가 조건")); const advancedGrid = node("div", "log-advanced-grid"); ["from", "to", "actor"].forEach(function (name) { const row = form.querySelector('[name="' + name + '"]')?.closest(".form-row"); if (row) { advancedGrid.appendChild(row); } }); advanced.append(advancedGrid, node("p", "log-search-note", "기간 입력은 브라우저 시간대 기준입니다.")); const notes = form.querySelector(".note-list"); if (notes) { advanced.appendChild(notes); } footer.appendChild(advanced); const autoRow = form.querySelector('[name="auto_refresh"]')?.closest(".form-row"); if (autoRow) { autoRow.classList.add("log-auto-refresh"); footer.appendChild(autoRow); } form.appendChild(footer); form.querySelectorAll(".form-row").forEach(function (row) { const input = row.querySelector("input, select"); const label = row.querySelector(":scope > label"); if (!input) { return; } input.id = "log-" + kind + "-" + input.name; if (label) { label.htmlFor = input.id; } if (input.name === "keyword") { row.classList.add("log-keyword-field"); } }); const overview = node("section", "log-overview"); overview.setAttribute("aria-label", "현재 조회 결과 요약"); const total = node("div", "log-overview-total"); const totalNumber = raw(node("strong", "", "—")); total.append(node("span", "", "조회 결과"), totalNumber, node("span", "", "건")); const scope = node("span", "log-overview-scope", "현재 조회된 로그 기준"); const breakdown = node("div", "log-overview-breakdown"); overview.append(total, breakdown, scope); filterPanel.before(overview); const feedback = node("p", "log-result-feedback"); feedback.hidden = true; feedback.setAttribute("role", "status"); panel.querySelector(".table-wrap").before(feedback); const bottom = node("div", "log-result-footer"); const rangeNote = node("span", "", "현재 검색 조건에 맞는 최신 로그를 표시합니다."); const updated = node("span", "log-updated"); updated.append(node("span", "", "마지막 조회"), raw(node("time", "", "—"))); bottom.append(rangeNote, updated); panel.appendChild(bottom); let previousRows = new Map(); let previousSummary = ""; let hasResult = false; function makeRow(event, values, timezone, keyword) { const row = node("tr", "log-event-row"); const dateParts = timestampParts(event.timestamp, timezone); const dateCell = node("td", "log-time-cell"); const time = raw(node("time", "", dateParts.time)); if (!Number.isNaN(Date.parse(event.timestamp))) { time.dateTime = event.timestamp; } time.title = String(event.timestamp || ""); dateCell.append(time, raw(node("span", "log-date", dateParts.date))); if (dateParts.unset) { dateCell.appendChild(node("span", "log-time-unset", "시간 미설정")); } row.appendChild(dateCell); values.slice(1, -1).forEach(function (content, index) { const column = index + 1; const cell = node("td"); const tone = String(kind === "system" ? event.severity : kind === "port" ? event.direction : event.state || "unknown").toLowerCase(); if (kind === "system" && column === 1 || kind !== "system" && column === 3) { cell.appendChild(badge(content, tone)); row.dataset.tone = tone; } else if (kind !== "system" && column === 1) { cell.appendChild(raw(node("span", "log-channel", content))); } else { const label = node("span", column === 2 ? "log-category" : "log-source"); if (kind === "port" && column === 4) { raw(label); } highlighted(label, content, keyword); cell.appendChild(label); } row.appendChild(cell); }); const messageCell = node("td", "log-message-cell"); if (kind === "system" && event.audit_protected === true) { messageCell.appendChild(badge("감사 기록 · 보존", "info")); } const details = node("details", "log-message-details"); const summary = node("summary"); const preview = raw(node("span", "log-message-preview")); highlighted(preview, values[values.length - 1], keyword); summary.append(preview, node("span", "log-detail-hint", "상세")); const detail = node("div", "log-event-detail"); const metadata = node("dl", "log-event-metadata"); const fields = [["원본 시각", event.timestamp], ["발생 위치", event.source], ["사용자", event.actor], ["작업", event.action], ["대상", event.target], ["원격 주소", event.remote_address ? event.remote_address + (event.remote_port ? ":" + event.remote_port : "") : null], ["감사 ID", event.audit_id]]; fields.forEach(function (field) { if (field[1] === null || field[1] === undefined || field[1] === "") { return; } const pair = node("div"); pair.append(node("dt", "", field[0]), raw(node("dd", "", field[1]))); metadata.appendChild(pair); }); detail.appendChild(metadata); [["수신/전송 데이터", event.display_text], ["원본 메시지", event.message], ["명령", event.command], ["결과", event.return_value]].forEach(function (field) { if (field[1] === null || field[1] === undefined || field[1] === "") { return; } detail.append(node("h3", "", field[0]), raw(node("pre", "", field[1]))); }); // Never decode or reveal payload_base64; preserve the API's redacted text. if (event.redacted) { detail.appendChild(badge("마스킹 적용", "neutral")); } details.append(summary, detail); messageCell.appendChild(details); row.appendChild(messageCell); return row; } function showEmpty(label, hint) { const row = node("tr"); const cell = node("td", "empty-state log-empty-state"); cell.colSpan = table.querySelectorAll("thead th").length; cell.append(node("strong", "", label), node("p", "", hint)); row.appendChild(cell); tableBody.replaceChildren(row); } showEmpty("로그를 불러오는 중입니다.", "장비에 저장된 로그를 조회하고 있습니다."); return { busyReading: function () { return Boolean(tableBody.querySelector("details[open]")) || panel.querySelector(".table-wrap").scrollTop > 8; }, render: function (events, rows, timezone, filter) { hasResult = true; const breakdownRows = summaryFor(kind, events); const nextSummary = JSON.stringify(breakdownRows); if (previousSummary !== nextSummary) { breakdown.replaceChildren(); breakdownRows.forEach(function (item) { const chip = badge(item.label, item.key); chip.appendChild(raw(node("strong", "", item.count))); breakdown.appendChild(chip); }); previousSummary = nextSummary; } totalNumber.textContent = String(events.length); const limit = Number(filter.limit) || 100; scope.textContent = events.length >= limit ? "최대 " + limit + "건 표시 · 전체 저장 건수가 아닙니다." : "현재 조회된 로그 기준"; const time = updated.querySelector("time"); const now = new Date(); time.dateTime = now.toISOString(); time.textContent = now.toTimeString().slice(0, 8); if (!events.length) { previousRows.clear(); showEmpty("조건에 맞는 로그가 없습니다.", "검색 조건을 바꾸거나 초기화를 눌러 다시 조회하세요."); return; } const nextRows = new Map(); const occurrences = new Map(); events.forEach(function (event, index) { const signature = JSON.stringify([event, timezone, filter.keyword || ""]); const occurrence = occurrences.get(signature) || 0; occurrences.set(signature, occurrence + 1); const key = signature + ":" + occurrence; const row = previousRows.get(key) || makeRow(event, rows[index], timezone, filter.keyword); nextRows.set(key, row); }); // Retain row nodes so an unchanged refresh does not reset open details. const retained = new Set(nextRows.values()); Array.from(tableBody.children).forEach(function (row) { if (!retained.has(row)) { row.remove(); } }); let cursor = tableBody.firstElementChild; nextRows.forEach(function (row) { if (row !== cursor) { tableBody.insertBefore(row, cursor); } cursor = row.nextElementSibling; }); previousRows = nextRows; }, state: function (state) { panel.dataset.loadState = state; table.setAttribute("aria-busy", String(state === "loading")); feedback.hidden = !["error", "reading"].includes(state); feedback.dataset.tone = state; feedback.textContent = state === "error" ? (hasResult ? "조회에 실패했습니다. 마지막으로 받은 결과를 유지합니다. 새로고침으로 다시 시도하세요." : "로그를 조회할 수 없습니다. 새로고침으로 다시 시도하세요.") : state === "reading" ? "로그를 읽는 동안 자동 갱신을 기다립니다. 상세를 닫고 목록 맨 위로 이동하면 다시 갱신됩니다." : ""; if (!hasResult && state === "error") { showEmpty("로그를 조회할 수 없습니다.", "장비 연결 상태를 확인한 후 새로고침을 눌러주세요."); } else if (!hasResult && state === "loading") { showEmpty("로그를 불러오는 중입니다.", "장비에 저장된 로그를 조회하고 있습니다."); } } }; } api.mount = mount; window.teraLogView = api; }());