|
|
|
|
(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 === "up" || state === "session" ? "UP" : state.toUpperCase();
|
|
|
|
|
tile.dataset.portState = state;
|
|
|
|
|
text(tile.querySelector(".port-link-state"), label);
|
|
|
|
|
const alias = String(port?.alias || "");
|
|
|
|
|
text(tile.querySelector(".dashboard-port-alias"), alias || "—");
|
|
|
|
|
const description = "포트 " + tile.dataset.dashboardPort + ": " + label + (alias ? ", " + alias : "");
|
|
|
|
|
if (tile.getAttribute("aria-label") !== description) { tile.setAttribute("aria-label", description); }
|
|
|
|
|
const detail = [alias, view?.summary || "RX 또는 CR 응답 대기"].filter(Boolean).join("\n");
|
|
|
|
|
if (tile.title !== detail) { tile.title = detail; }
|
|
|
|
|
});
|
|
|
|
|
// Channel positions stay fixed when link states change.
|
|
|
|
|
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 };
|
|
|
|
|
}());
|