177 lines
5.9 KiB
JavaScript
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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
}
|
|
|
|
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);
|
|
}
|