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.

225 lines
12 KiB

(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 };
}());