(function () { "use strict"; function numeric(value) { if (value === null || value === undefined || value === "") { return null; } const result = Number(value); return Number.isFinite(result) ? result : null; } function percentage(value) { const result = numeric(value); return result === null || result < 0 || result > 100 ? null : result; } function metricText(value, suffix) { const result = numeric(value); return result === null ? "—" : String(Math.round(result * 10) / 10) + (suffix || ""); } function storageSummary(input) { if (!input) { return null; } const total = numeric(input.total_bytes), used = numeric(input.used_bytes), available = numeric(input.available_bytes); const percent = percentage(input.usage_percent); if (!(total > 0) || used === null || used < 0 || used > total || available === null || available < 0 || available > total - used || percent === null) { return null; } return { total: total, used: used, available: available, percent: percent, path: input.path || "—" }; } function summarizePorts(ports, total) { const counts = { all: total, up: 0, session: 0, down: 0, unknown: total }; let rx = 0; let tx = 0; const seen = new Set(); const ranking = []; ports.forEach(function (port) { const number = Number.parseInt(String(port.channel || "").replace(/\D/g, ""), 10); if (!Number.isInteger(number) || number < 1 || number > total || seen.has(number)) { return; } seen.add(number); const state = port.__linkView?.visualKey || "unknown"; if (["up", "session", "down"].includes(state)) { counts[state] += 1; counts.unknown -= 1; } const runtime = port.runtime || {}; const received = Math.max(0, numeric(runtime.rx_bytes) || 0); const sent = Math.max(0, numeric(runtime.tx_bytes) || 0); rx += received; tx += sent; if (received + sent > 0) { ranking.push({ channel: port.channel, number: number, bytes: received + sent }); } }); ranking.sort(function (a, b) { return b.bytes - a.bytes || a.number - b.number; }); return { counts: counts, active: counts.up + counts.session, rx: rx, tx: tx, ranking: ranking.slice(0, 5) }; } // Pure formatting/aggregation helpers are also exercised by the Node test suite. if (typeof module !== "undefined" && module.exports) { module.exports = { storageSummary: storageSummary, numeric: numeric, percentage: percentage, metricText: metricText, summarizePorts: summarizePorts }; } if (typeof window === "undefined" || typeof document === "undefined") { return; } function root() { return document.querySelector("[data-dashboard]"); } function text(element, value) { const next = value === null || value === undefined || value === "" ? "—" : String(value); if (element && element.textContent !== next) { element.textContent = next; } } function value(container, key, content) { text(container.querySelector('[data-dashboard-value="' + key + '"]'), content); } function node(tag, className, content) { const element = document.createElement(tag); if (className) { element.className = className; } if (content !== undefined) { text(element, content); } return element; } function syncState(kind, state) { const container = root(); if (!container) { return; } container.dataset[kind + "State"] = state; const states = [container.dataset.systemState, container.dataset.portState]; const overall = states.includes("error") ? "error" : states.every(function (item) { return item === "ready"; }) ? "ready" : "loading"; container.querySelector("[data-dashboard-sync]").dataset.state = overall; value(container, "sync-label", overall === "error" ? "갱신 실패 · 마지막 수신값 표시" : overall === "ready" ? "자동 갱신 중 · 시스템 5초" : "데이터 불러오는 중"); if (kind === "port") { value(container, "port-sync-label", state === "error" ? "포트 상태 갱신 실패" : state === "ready" ? "포트 상태 자동 갱신 중" : "상태 확인 중"); } } function updateMeter(container, name, input) { const metric = container.querySelector('[data-metric="' + name + '"]'); const meter = metric.querySelector("meter"); const percent = percentage(input); meter.hidden = percent === null; // Values are actual samples, not a fabricated trend or health threshold. if (percent !== null) { meter.value = percent; } value(container, name, metricText(percent, "%")); } function renderStatus(status, network, format) { const container = root(); if (!container) { return; } updateMeter(container, "cpu", status.cpu_usage_percent); const storage = storageSummary(status.storage); updateMeter(container, "storage", storage?.percent); const memoryTotal = numeric(status.memory_total_bytes); const memoryUsed = numeric(status.memory_used_bytes); const memoryAvailable = numeric(status.memory_available_bytes); const sessions = numeric(status.active_sessions); const alarms = numeric(status.active_alarms); const entries = { "storage-detail": storage ? format.bytes(storage.used) + " / " + format.bytes(storage.total) : "—", "storage-available": storage ? format.bytes(storage.available) : "—", "storage-path": storage?.path || "—", memory: metricText(percentage(status.memory_usage_percent), "%"), temperature: metricText(status.temperature_celsius, "°C"), "memory-detail": memoryTotal > 0 && memoryUsed !== null ? format.bytes(memoryUsed) + " / " + format.bytes(memoryTotal) : "—", "memory-available": memoryTotal > 0 && memoryAvailable !== null ? format.bytes(memoryAvailable) : "—", sessions: sessions === null || sessions < 0 ? "—" : sessions, alarms: alarms === null || alarms < 0 ? "—" : alarms, "alarm-description": alarms === null || alarms < 0 ? "데이터 확인 불가" : alarms > 0 ? "확인이 필요한 알람이 있습니다." : "활성 알람 없음", model: status.model, hardware: status.hardware_profile, firmware: status.firmware_version, build: status.build_number, "device-id": status.device_id, uptime: numeric(status.uptime_seconds) === null ? "—" : format.duration(status.uptime_seconds), "system-time": status.system_time ? format.time(status.system_time) : "—", load: (status.load_averages || []).map(function (item) { return metricText(item); }).join(" / ") || "—", host: network.host_name, "network-mode": network.operating_mode }; Object.keys(entries).forEach(function (key) { value(container, key, entries[key]); }); container.querySelector('[data-metric="alarms"]').dataset.attention = alarms > 0 ? "true" : "false"; const updated = container.querySelector('[data-dashboard-value="updated"]'); const now = new Date(); updated.dateTime = now.toISOString(); text(updated, now.toLocaleTimeString("ko-KR", { hour12: false })); renderNetwork(container, network.interfaces || []); syncState("system", "ready"); } function renderNetwork(container, interfaces) { const signature = JSON.stringify(interfaces); if (container._dashboardNetworkSignature === signature) { return; } container._dashboardNetworkSignature = signature; const list = container.querySelector("[data-dashboard-network]"); list.replaceChildren(); if (!interfaces.length) { list.appendChild(node("p", "dashboard-placeholder", "인터페이스 정보가 없습니다.")); } interfaces.forEach(function (item) { const card = node("div", "dashboard-interface"); const heading = node("div", "dashboard-interface-heading"); const name = node("strong", "", item.device || item.name); name.setAttribute("data-i18n-skip", ""); const state = String(item.link || "").toLowerCase(); const badge = node("span", "dashboard-network-badge", state === "up" ? "연결됨" : state === "down" ? "연결 끊김" : "미확인"); badge.dataset.state = state; heading.append(name, badge); const address = node("div", "dashboard-interface-address", item.address || "주소 없음"); const meta = node("div", "dashboard-interface-meta"); meta.append(node("span", "", item.name || "—"), node("span", "", item.speed || "속도 미확인")); card.append(heading, address, meta); list.appendChild(card); }); } function applyFilter(container) { const selected = container.querySelector('[data-port-filter][aria-pressed="true"]')?.dataset.portFilter || "all"; let visible = 0; container.querySelectorAll("[data-dashboard-port]").forEach(function (tile) { tile.hidden = selected !== "all" && tile.dataset.portState !== selected; if (!tile.hidden) { visible += 1; } }); container.querySelector(".dashboard-port-empty").hidden = visible !== 0; } function renderPorts(ports, formatBytes) { const container = root(); if (!container) { return; } const tiles = Array.from(container.querySelectorAll("[data-dashboard-port]")); const byNumber = new Map(ports.map(function (port) { return [Number.parseInt(String(port.channel).replace(/\D/g, ""), 10), port]; })); tiles.forEach(function (tile) { const port = byNumber.get(Number(tile.dataset.dashboardPort)); const view = port?.__linkView; const state = ["up", "down", "session"].includes(view?.visualKey) ? view.visualKey : "unknown"; const label = state === "unknown" ? "미확인" : view.label; tile.dataset.portState = state; text(tile.querySelector(".port-link-state"), label); const description = "포트 " + tile.dataset.dashboardPort + ": " + label; if (tile.getAttribute("aria-label") !== description) { tile.setAttribute("aria-label", description); } const detail = view?.summary || "RX 또는 CR 응답 대기"; if (tile.title !== detail) { tile.title = detail; } }); const summary = summarizePorts(ports, tiles.length); value(container, "active-ports", summary.active); value(container, "total-ports", summary.counts.all); value(container, "rx", formatBytes(summary.rx)); value(container, "tx", formatBytes(summary.tx)); container.querySelectorAll("[data-port-count]").forEach(function (element) { text(element, summary.counts[element.dataset.portCount]); }); const signature = JSON.stringify(summary.ranking); if (container._dashboardRankingSignature !== signature) { container._dashboardRankingSignature = signature; const list = container.querySelector("[data-dashboard-ranking]"); list.replaceChildren(); if (!summary.ranking.length) { list.appendChild(node("li", "dashboard-placeholder", "아직 포트 트래픽이 없습니다.")); } summary.ranking.forEach(function (item, index) { const row = node("li", "dashboard-rank"); const heading = node("div", "dashboard-rank-heading"); const link = node("a", "", item.channel); link.href = "/ports/" + item.number + "/parameters"; heading.append(node("span", "dashboard-rank-number", index + 1), link, node("strong", "", formatBytes(item.bytes))); const track = node("div", "dashboard-rank-track"); track.setAttribute("aria-hidden", "true"); const bar = node("span"); bar.style.width = (item.bytes / summary.ranking[0].bytes * 100) + "%"; track.appendChild(bar); row.append(heading, track); list.appendChild(row); }); } applyFilter(container); syncState("port", "ready"); } // Delegate once so filters continue to work after in-app navigation. document.addEventListener("click", function (event) { const button = event.target.closest?.("[data-port-filter]"); const container = button?.closest("[data-dashboard]"); if (!container) { return; } container.querySelectorAll("[data-port-filter]").forEach(function (item) { item.setAttribute("aria-pressed", String(item === button)); }); applyFilter(container); }); window.teraDashboard = { renderStatus: renderStatus, renderPorts: renderPorts, syncState: syncState }; }());