plc_control/web/feeder/js/logs.js

177 lines
5.9 KiB
JavaScript

import { appendChartPoint } from "./chart.js";
import { dom } from "./dom.js";
import { prependEvent } from "./events.js";
import { formatValue } from "./points.js";
import { state } from "./state.js";
import { loadUnits, renderUnits } from "./units.js";
import { loadEquipments } from "./equipment.js";
import { showToast } from "./api.js";
function escapeHtml(text) {
return text.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
function parseLogLine(line) {
const trimmed = line.trim();
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return null;
try { return JSON.parse(trimmed); } catch { return null; }
}
function appendLog(line) {
if (!dom.logView) return;
const atBottom = dom.logView.scrollTop + dom.logView.clientHeight >= dom.logView.scrollHeight - 10;
const div = document.createElement("div");
const parsed = parseLogLine(line);
if (!parsed) {
div.className = "log-line";
div.textContent = line;
} else {
const levelRaw = (parsed.level || "").toString();
const level = levelRaw.toLowerCase();
div.className = `log-line${level ? ` level-${level}` : ""}`;
div.innerHTML = [
`<span class="level">${escapeHtml(levelRaw || "LOG")}</span>`,
parsed.timestamp ? `<span class="muted"> ${escapeHtml(parsed.timestamp)}</span>` : "",
parsed.target ? `<span class="muted"> ${escapeHtml(parsed.target)}</span>` : "",
`<span class="message">${escapeHtml(parsed.fields?.message || parsed.message || parsed.msg || line)}</span>`,
].join("");
}
dom.logView.appendChild(div);
if (atBottom) dom.logView.scrollTop = dom.logView.scrollHeight;
}
function appendLogDivider(text) {
if (!dom.logView) return;
const atBottom = dom.logView.scrollTop + dom.logView.clientHeight >= dom.logView.scrollHeight - 10;
const div = document.createElement("div");
div.className = "log-line muted";
div.textContent = text;
dom.logView.appendChild(div);
if (atBottom) dom.logView.scrollTop = dom.logView.scrollHeight;
}
export function startLogs() {
if (state.logSource) return;
let currentLogFile = null;
state.logSource = new EventSource("/api/logs/stream");
state.logSource.addEventListener("log", (event) => {
const data = JSON.parse(event.data);
if (data.reset && data.file && data.file !== currentLogFile) {
appendLogDivider(`[log switched to ${data.file}]`);
}
currentLogFile = data.file || currentLogFile;
(data.lines || []).forEach(appendLog);
});
state.logSource.addEventListener("error", () => appendLog("[log stream error]"));
}
export function stopLogs() {
if (state.logSource) {
state.logSource.close();
state.logSource = null;
}
}
let _disconnectToast = null;
function setWsStatus(connected) {
if (dom.wsDot) {
dom.wsDot.className = `ws-dot ${connected ? "connected" : "disconnected"}`;
}
if (dom.wsLabel) {
dom.wsLabel.textContent = connected ? "已连接" : "连接断开,重连中…";
}
if (!connected && !_disconnectToast) {
_disconnectToast = showToast("后端连接断开", {
message: "正在重连,请稍候…",
level: "error",
duration: 0,
shake: true,
});
} else if (connected && _disconnectToast) {
_disconnectToast.dismiss();
_disconnectToast = null;
showToast("连接已恢复", { level: "success", duration: 3000 });
}
}
let _reconnectDelay = 1000;
let _connectedOnce = false;
export function startPointSocket() {
const protocol = location.protocol === "https:" ? "wss" : "ws";
const ws = new WebSocket(`${protocol}://${location.host}/ws/public`);
state.pointSocket = ws;
ws.onopen = () => {
setWsStatus(true);
_reconnectDelay = 1000;
if (_connectedOnce) {
loadUnits().catch(() => {});
if (state.activeView === "config") loadEquipments().catch(() => {});
}
_connectedOnce = true;
};
ws.onmessage = (event) => {
try {
const payload = JSON.parse(event.data);
if (payload.type === "PointNewValue" || payload.type === "point_new_value") {
const data = payload.data;
// config view point table
const entry = state.pointEls.get(data.point_id);
if (entry) {
entry.value.textContent = formatValue(data);
entry.quality.className = `badge quality-${(data.quality || "unknown").toLowerCase()}`;
entry.quality.textContent = (data.quality || "unknown").toUpperCase();
entry.time.textContent = data.timestamp || "--";
}
// ops view signal pill
const opsEntry = state.opsPointEls.get(data.point_id);
if (opsEntry) {
const { pillEl, syncBtns } = opsEntry;
state.opsSignalCache.set(data.point_id, { quality: data.quality, value_text: data.value_text });
const role = pillEl.dataset.opsRole;
import("./ops.js").then(({ sigPillClass }) => {
pillEl.className = sigPillClass(role, data.quality, data.value_text);
syncBtns?.();
});
}
if (state.chartPointId === data.point_id) {
appendChartPoint(data);
}
return;
}
if (payload.type === "EventCreated" || payload.type === "event_created") {
prependEvent(payload.data);
}
if (payload.type === "UnitRuntimeChanged") {
const runtime = payload.data;
state.runtimes.set(runtime.unit_id, runtime);
renderUnits();
// lazy import to avoid circular dep (ops.js -> logs.js -> ops.js)
import("./ops.js").then(({ renderOpsUnits, syncEquipmentButtonsForUnit }) => {
renderOpsUnits();
syncEquipmentButtonsForUnit(runtime.unit_id);
});
return;
}
} catch {
// ignore malformed messages
}
};
ws.onclose = () => {
setWsStatus(false);
window.setTimeout(startPointSocket, _reconnectDelay);
_reconnectDelay = Math.min(_reconnectDelay * 2, 30000);
};
ws.onerror = () => setWsStatus(false);
}