You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
276 lines
14 KiB
276 lines
14 KiB
(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.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;
|
|
}());
|
|
|