feat: add authenticated WebSerial log viewer

This commit is contained in:
Yurii committed 2026-10-20 07:05:00 +03:00
1 parent 734615e7b5
commit 959a15c11f
9 files changed
+821 -2

No files matched your search

+1
View File
@@ -28,6 +28,7 @@ build_flags = ;-mtext-section-literals
-O2 -O2
-Wno-deprecated-declarations -Wno-deprecated-declarations
-D MQTT_CLIENT_STD_FUNCTION_CALLBACK=1 -D MQTT_CLIENT_STD_FUNCTION_CALLBACK=1
-D WSL_CUSTOM_PAGE
;-D DEBUG_ESP_CORE -D DEBUG_ESP_WIFI -D DEBUG_ESP_HTTP_SERVER -D DEBUG_ESP_PORT=Serial ;-D DEBUG_ESP_CORE -D DEBUG_ESP_WIFI -D DEBUG_ESP_HTTP_SERVER -D DEBUG_ESP_PORT=Serial
-D BUILD_VERSION='"${this.version}"' -D BUILD_VERSION='"${this.version}"'
-D BUILD_ENV='"$PIOENV"' -D BUILD_ENV='"$PIOENV"'
+23 -2
View File
@@ -66,7 +66,7 @@ protected:
// auth middleware // auth middleware
static AsyncMiddlewareFunction authMiddleware([](AsyncWebServerRequest *request, ArMiddlewareNext next) { static AsyncMiddlewareFunction authMiddleware([](AsyncWebServerRequest *request, ArMiddlewareNext next) {
if (network->isApEnabled() || !settings.portal.auth || !strlen(settings.portal.password)) { if (!isAuthenticationRequired()) {
return next(); return next();
} }
@@ -79,7 +79,20 @@ protected:
// web serial // web serial
if (webSerial != nullptr) { if (webSerial != nullptr) {
webSerial->begin(this->webServer); webSerial->begin(this->webServer, nullptr, "/webserial");
// Refresh credentials before each handshake to use the current portal settings.
this->webServer->addMiddleware([](AsyncWebServerRequest *request, ArMiddlewareNext next) {
if (request->url().equals("/webserial")) {
if (isAuthenticationRequired()) {
webSerial->setAuthentication(settings.portal.login, settings.portal.password);
} else {
webSerial->setAuthentication("", "");
}
}
return next();
});
} }
// index page // index page
@@ -114,6 +127,10 @@ protected:
this->webServer->serveStatic("/upgrade.html", LittleFS, "/pages/upgrade.html", PORTAL_CACHE) this->webServer->serveStatic("/upgrade.html", LittleFS, "/pages/upgrade.html", PORTAL_CACHE)
.addMiddleware(&authMiddleware); .addMiddleware(&authMiddleware);
// logs page
this->webServer->serveStatic("/logs.html", LittleFS, "/pages/logs.html", PORTAL_CACHE)
.addMiddleware(&authMiddleware);
// OTA // OTA
auto upgradeHandler = (new UpgradeHandler("/api/upgrade")) auto upgradeHandler = (new UpgradeHandler("/api/upgrade"))
->setBeforeUpgradeCallback([](AsyncWebServerRequest *request, UpgradeHandler::UpgradeType type) -> bool { ->setBeforeUpgradeCallback([](AsyncWebServerRequest *request, UpgradeHandler::UpgradeType type) -> bool {
@@ -796,6 +813,10 @@ protected:
this->dnsServerEnabled = false; this->dnsServerEnabled = false;
} }
static bool isAuthenticationRequired() {
return !network->isApEnabled() && settings.portal.auth && strlen(settings.portal.password);
}
static void getFilename(char* filename, size_t maxSizeFilename, const char* type) { static void getFilename(char* filename, size_t maxSizeFilename, const char* type) {
const time_t now = time(nullptr); const time_t now = time(nullptr);
+26
View File
@@ -505,6 +505,32 @@
} }
} }
}, },
"logs": {
"title": "日志 - OpenTherm Gateway",
"name": "日志",
"desc": "实时设备日志。",
"status": {
"connecting": "正在连接...",
"connected": "已连接",
"disconnected": "已断开连接"
},
"autoscroll": "自动滚动",
"limitHistory": "最多保留 1000 条日志",
"historyNote": "不限制历史记录会占用更多浏览器内存。下载包含所有保留的日志,包括被筛选器隐藏的日志。",
"filter": "筛选",
"search": {
"title": "搜索",
"previous": "上一个",
"next": "下一个"
},
"level": {
"note": "此级别适用于设备日志(Serial 和 WebSerial)。下方的筛选器仅影响显示。",
"loadError": "无法加载日志级别,请重试。",
"saveError": "无法保存日志级别,请检查连接后重试。"
},
"clear": "清空",
"download": "下载日志"
},
"upgrade": { "upgrade": {
"title": "固件升级 - OpenTherm Gateway", "title": "固件升级 - OpenTherm Gateway",
"name": "固件升级", "name": "固件升级",
+26
View File
@@ -505,6 +505,32 @@
} }
} }
}, },
"logs": {
"title": "Logs - OpenTherm Gateway",
"name": "Logs",
"desc": "Device logs in real time.",
"status": {
"connecting": "Connecting...",
"connected": "Connected",
"disconnected": "Disconnected"
},
"autoscroll": "Autoscroll",
"limitHistory": "Keep at most 1000 entries",
"historyNote": "Unlimited history uses more browser memory. Downloads include all retained entries, including those hidden by the filter.",
"filter": "Filter",
"search": {
"title": "Search",
"previous": "Previous",
"next": "Next"
},
"level": {
"note": "The level applies to device logging (Serial and WebSerial). The filter below only changes the display.",
"loadError": "Could not load the log level. Retry the request.",
"saveError": "Could not save the log level. Check the connection and try again."
},
"clear": "Clear",
"download": "Download logs"
},
"upgrade": { "upgrade": {
"title": "Upgrade - OpenTherm Gateway", "title": "Upgrade - OpenTherm Gateway",
"name": "Upgrade", "name": "Upgrade",
+26
View File
@@ -505,6 +505,32 @@
} }
} }
}, },
"logs": {
"title": "Registro - OpenTherm Gateway",
"name": "Registro",
"desc": "Registro del dispositivo in tempo reale.",
"status": {
"connecting": "Connessione in corso...",
"connected": "Connesso",
"disconnected": "Disconnesso"
},
"autoscroll": "Scorrimento automatico",
"limitHistory": "Conserva al massimo 1000 voci",
"historyNote": "La cronologia illimitata usa più memoria del browser. Il download include tutte le voci conservate, anche quelle nascoste dal filtro.",
"filter": "Filtro",
"search": {
"title": "Cerca",
"previous": "Precedente",
"next": "Successivo"
},
"level": {
"note": "Il livello si applica al registro del dispositivo (Serial e WebSerial). Il filtro sottostante cambia solo la visualizzazione.",
"loadError": "Impossibile caricare il livello del registro. Riprovare la richiesta.",
"saveError": "Impossibile salvare il livello del registro. Controllare la connessione e riprovare."
},
"clear": "Cancella",
"download": "Scarica il registro"
},
"upgrade": { "upgrade": {
"title": "Aggiornamenti - OpenTherm Gateway", "title": "Aggiornamenti - OpenTherm Gateway",
"name": "Aggiornamenti", "name": "Aggiornamenti",
+26
View File
@@ -505,6 +505,32 @@
} }
} }
}, },
"logs": {
"title": "Logboek - OpenTherm Gateway",
"name": "Logboek",
"desc": "Apparaatlogboek in realtime.",
"status": {
"connecting": "Verbinden...",
"connected": "Verbonden",
"disconnected": "Verbinding verbroken"
},
"autoscroll": "Automatisch scrollen",
"limitHistory": "Bewaar maximaal 1000 regels",
"historyNote": "Onbeperkte geschiedenis gebruikt meer browsergeheugen. Downloads bevatten alle bewaarde regels, ook de regels die door het filter zijn verborgen.",
"filter": "Filter",
"search": {
"title": "Zoeken",
"previous": "Vorige",
"next": "Volgende"
},
"level": {
"note": "Het niveau geldt voor het apparaatlogboek (Serial en WebSerial). Het onderstaande filter wijzigt alleen de weergave.",
"loadError": "Het logniveau kon niet worden geladen. Probeer opnieuw.",
"saveError": "Het logniveau kon niet worden opgeslagen. Controleer de verbinding en probeer opnieuw."
},
"clear": "Wissen",
"download": "Logboek downloaden"
},
"upgrade": { "upgrade": {
"title": "Upgrade - OpenTherm Gateway", "title": "Upgrade - OpenTherm Gateway",
"name": "Upgrade", "name": "Upgrade",
+26
View File
@@ -505,6 +505,32 @@
} }
} }
}, },
"logs": {
"title": "Журнал - OpenTherm Gateway",
"name": "Журнал",
"desc": "Журнал устройства в реальном времени.",
"status": {
"connecting": "Подключение...",
"connected": "Подключено",
"disconnected": "Нет подключения"
},
"autoscroll": "Автопрокрутка",
"limitHistory": "Хранить не более 1000 записей",
"historyNote": "Без ограничения журнал использует больше памяти браузера. Скачиваются все сохранённые записи, включая скрытые фильтром.",
"filter": "Фильтр",
"search": {
"title": "Поиск",
"previous": "Назад",
"next": "Вперёд"
},
"level": {
"note": "Уровень применяется к журналу устройства (Serial и WebSerial). Фильтр ниже меняет только отображение.",
"loadError": "Не удалось загрузить уровень журнала. Повторите запрос.",
"saveError": "Не удалось сохранить уровень журнала. Проверьте соединение и повторите попытку."
},
"clear": "Очистить",
"download": "Скачать журнал"
},
"upgrade": { "upgrade": {
"title": "Обновление - OpenTherm Gateway", "title": "Обновление - OpenTherm Gateway",
"name": "Обновление", "name": "Обновление",
+1
View File
@@ -155,6 +155,7 @@
<a href="/dashboard.html" role="button" data-i18n>dashboard.name</a> <a href="/dashboard.html" role="button" data-i18n>dashboard.name</a>
<a href="/settings.html" role="button" data-i18n>settings.name</a> <a href="/settings.html" role="button" data-i18n>settings.name</a>
<a href="/sensors.html" role="button" data-i18n>sensors.name</a> <a href="/sensors.html" role="button" data-i18n>sensors.name</a>
<a href="/logs.html" role="button" data-i18n>logs.name</a>
<a href="/upgrade.html" role="button" data-i18n>upgrade.name</a> <a href="/upgrade.html" role="button" data-i18n>upgrade.name</a>
<a href="/restart.html" role="button" class="secondary restart" data-i18n>button.restart</a> <a href="/restart.html" role="button" class="secondary restart" data-i18n>button.restart</a>
</div> </div>
+666
View File
@@ -0,0 +1,666 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title data-i18n>logs.title</title>
<link rel="stylesheet" href="/static/app.css?{BUILD_TIME}" />
<style>
#log-console {
background: #15191e;
color: #e5e7eb;
border-radius: var(--pico-border-radius);
margin-bottom: 1rem;
}
#log-console-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem;
border-bottom: 1px solid var(--pico-muted-border-color);
}
#connection-status {
display: inline-block;
width: 0.65rem;
height: 0.65rem;
border: 0;
border-radius: 50%;
background: var(--pico-form-element-invalid-border-color);
}
#connection-status[data-state="connected"] {
background: var(--pico-form-element-valid-border-color);
}
#log-output {
position: relative;
height: 55vh;
min-height: 10rem;
resize: vertical;
overflow: auto;
padding: 0.5rem;
font: 0.6rem/1.4 monospace;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
#log-output > .search-match {
background: var(--pico-primary-focus);
outline: 1px solid var(--pico-primary);
outline-offset: -1px;
}
#log-output > div {
min-height: 1.4em;
}
#search-count {
white-space: nowrap;
}
#search-logs {
min-width: 0;
}
#log-controls label {
margin-bottom: 0.5rem;
}
#log-search-controls > button {
flex: 0 0 auto;
width: auto;
padding-inline: 0.75rem;
}
</style>
</head>
<body>
<header class="container">
<nav>
<ul>
<li>
<a href="/">
<div class="logo" data-i18n>logo</div>
</a>
</li>
</ul>
<ul>
<li>
<select id="lang" aria-label="Lang">
<option value="en" selected>EN</option>
<option value="cn">CN</option>
<option value="it">IT</option>
<option value="nl">NL</option>
<option value="ru">RU</option>
</select>
</li>
</ul>
</nav>
</header>
<main class="container">
<article>
<hgroup>
<h2 data-i18n>logs.name</h2>
<p data-i18n>logs.desc</p>
</hgroup>
<form action="/api/settings" method="post" id="log-level-form">
<label for="log-level" data-i18n>settings.system.logLevel</label>
<div role="group">
<select name="system[logLevel]" id="log-level" disabled>
<option value="0">SILENT</option>
<option value="1">FATAL</option>
<option value="2">ERROR</option>
<option value="3">WARNING</option>
<option value="4">INFO</option>
<option value="5">NOTICE</option>
<option value="6">TRACE</option>
<option value="7">VERBOSE</option>
</select>
<button type="submit" disabled data-i18n>button.save</button>
</div>
<small data-i18n>logs.level.note</small>
<div id="log-level-load-error" hidden>
<small id="log-level-error" data-i18n="logs.level.loadError"></small>
<button type="button" id="retry-log-level" class="secondary" data-i18n>button.refresh</button>
</div>
</form>
<hr />
<div id="log-controls" class="grid">
<label>
<input type="checkbox" id="autoscroll" checked />
<span data-i18n>logs.autoscroll</span>
</label>
<label>
<input type="checkbox" id="limit-history" checked />
<span data-i18n>logs.limitHistory</span>
</label>
</div>
<small data-i18n>logs.historyNote</small>
<div class="grid">
<label for="filter-logs">
<span data-i18n>logs.filter</span>
<input type="search" id="filter-logs" />
</label>
<div>
<label for="search-logs" data-i18n>logs.search.title</label>
<div role="group" id="log-search-controls">
<input type="search" id="search-logs" />
<button type="button" id="search-prev" class="secondary" disabled>
<span aria-hidden="true">↑</span>
<span hidden data-i18n>logs.search.previous</span>
</button>
<button type="button" id="search-next" class="secondary" disabled>
<span aria-hidden="true">↓</span>
<span hidden data-i18n>logs.search.next</span>
</button>
</div>
</div>
</div>
<div id="log-console">
<div id="log-console-header">
<small id="search-count" aria-live="polite">0 / 0</small>
<span id="connection-status" role="img" tabindex="0" data-state="connecting" data-placement="left">
<span id="connection-text" hidden data-i18n="logs.status.connecting"></span>
</span>
</div>
<div id="log-output" role="log" aria-live="off" tabindex="0"></div>
</div>
<div role="group" id="log-actions">
<button type="button" id="clear-logs" class="secondary" data-i18n>logs.clear</button>
<button type="button" id="download-logs" data-i18n>logs.download</button>
</div>
</article>
</main>
<footer class="container">
<small>
<b>Made by Laxilef</b>
• <a href="https://github.com/Laxilef/OTGateway/blob/master/LICENSE" target="_blank" class="secondary" data-i18n>nav.license</a>
• <a href="https://github.com/Laxilef/OTGateway/blob/master/" target="_blank" class="secondary" data-i18n>nav.source</a>
• <a href="https://github.com/Laxilef/OTGateway/wiki" target="_blank" class="secondary" data-i18n>nav.help</a>
• <a href="https://github.com/Laxilef/OTGateway/issues" target="_blank" class="secondary" data-i18n>nav.issues</a>
• <a href="https://github.com/Laxilef/OTGateway/releases" target="_blank" class="secondary" data-i18n>nav.releases</a>
</small>
</footer>
<script src="/static/app.js?{BUILD_TIME}"></script>
<script>
document.addEventListener('DOMContentLoaded', async () => {
const lang = new Lang(document.getElementById('lang'));
await lang.build();
const output = document.getElementById('log-output');
const autoscroll = document.getElementById('autoscroll');
const limitHistory = document.getElementById('limit-history');
const filterInput = document.getElementById('filter-logs');
const searchInput = document.getElementById('search-logs');
const searchPrevious = document.getElementById('search-prev');
const searchNext = document.getElementById('search-next');
const searchCount = document.getElementById('search-count');
const status = document.getElementById('connection-status');
const statusText = document.getElementById('connection-text');
const colors = [
'#000000', '#cd3131', '#0dbc79', '#e5e510',
'#2472c8', '#bc3fbc', '#11a8cd', '#e5e5e5',
'#666666', '#f14c4c', '#23d18b', '#f5f543',
'#3b8eea', '#d670d6', '#29b8db', '#ffffff'
];
let escapeState = 'text';
let escapeParams = '';
let foreground = null;
let background = null;
let bold = false;
let socket = null;
let reconnectTimer = null;
let heartbeatTimer = null;
let waitingForPong = false;
let active = true;
let viewTimer = null;
let filterText = '';
let searchText = '';
let matches = [];
let selectedIndex = -1;
let selectedRecord = null;
const updateLabels = () => {
status.setAttribute('data-tooltip', statusText.textContent);
status.setAttribute('aria-label', statusText.textContent);
for (const button of [searchPrevious, searchNext]) {
const text = button.querySelector('[data-i18n]').textContent;
button.setAttribute('data-tooltip', text);
button.setAttribute('aria-label', text);
}
};
const labelObserver = new MutationObserver(updateLabels);
for (const label of [statusText, ...document.querySelectorAll('#log-search-controls [data-i18n]')]) {
labelObserver.observe(label, {childList: true, subtree: true, characterData: true});
}
const setConnectionStatus = (state) => {
status.dataset.state = state;
statusText.setAttribute('data-i18n', `logs.status.${state}`);
lang.translateElement(statusText);
updateLabels();
};
const updateSelection = (scroll = false) => {
const nextRecord = matches[selectedIndex] || null;
if (selectedRecord !== nextRecord) {
if (selectedRecord) {
selectedRecord.classList.remove('search-match');
}
selectedRecord = nextRecord;
if (selectedRecord) {
selectedRecord.classList.add('search-match');
}
}
searchCount.textContent = `${selectedRecord ? selectedIndex + 1 : 0} / ${matches.length}`;
searchPrevious.disabled = matches.length === 0;
searchNext.disabled = matches.length === 0;
if (scroll && selectedRecord) {
autoscroll.checked = false;
output.scrollTop = selectedRecord.offsetTop - (output.clientHeight - selectedRecord.offsetHeight) / 2;
}
};
const pruneHistory = () => {
let removedMatches = 0;
while (limitHistory.checked && output.childElementCount > 1000) {
const record = output.firstElementChild;
if (matches[removedMatches] === record) {
removedMatches++;
}
record.remove();
}
if (removedMatches) {
matches.splice(0, removedMatches);
selectedIndex = matches.length ? Math.max(0, selectedIndex - removedMatches) : -1;
}
};
const applyView = (scrollToSelection = false) => {
clearTimeout(viewTimer);
viewTimer = null;
filterText = filterInput.value.toLowerCase();
searchText = searchInput.value.toLowerCase();
matches = [];
for (const record of output.children) {
const text = record.textContent.toLowerCase();
record.hidden = !text.includes(filterText);
if (!record.hidden && searchText && text.includes(searchText)) {
matches.push(record);
}
}
selectedIndex = matches.indexOf(selectedRecord);
if (selectedIndex < 0 && matches.length) {
selectedIndex = 0;
}
updateSelection(scrollToSelection);
if (autoscroll.checked) {
output.scrollTop = output.scrollHeight;
}
};
const scheduleView = (event) => {
clearTimeout(viewTimer);
const scrollToSelection = event.target === searchInput;
viewTimer = setTimeout(() => applyView(scrollToSelection), 150);
};
filterInput.addEventListener('input', scheduleView);
searchInput.addEventListener('input', scheduleView);
const moveSearch = (direction) => {
if (viewTimer !== null) {
applyView(true);
return;
}
if (!matches.length) {
return;
}
selectedIndex = (selectedIndex + direction + matches.length) % matches.length;
updateSelection(true);
};
searchPrevious.addEventListener('click', () => moveSearch(-1));
searchNext.addEventListener('click', () => moveSearch(1));
searchInput.addEventListener('keydown', (event) => {
if (event.key === 'Enter') {
event.preventDefault();
moveSearch(event.shiftKey ? -1 : 1);
}
});
limitHistory.addEventListener('change', () => {
pruneHistory();
updateSelection();
});
const levelForm = document.getElementById('log-level-form');
const levelSelect = document.getElementById('log-level');
const levelSave = levelForm.querySelector('button[type="submit"]');
const levelError = document.getElementById('log-level-load-error');
const levelErrorText = document.getElementById('log-level-error');
const levelRetry = document.getElementById('retry-log-level');
let confirmedLevel = null;
let levelBusy = false;
const setSaveLabel = (key) => {
levelSave.setAttribute('data-i18n', key);
lang.translateElement(levelSave);
};
const updateLevelControls = () => {
levelSelect.disabled = levelBusy || confirmedLevel === null;
levelSave.disabled = levelSelect.disabled;
levelRetry.disabled = levelBusy;
if (levelBusy) {
levelSave.setAttribute('aria-busy', 'true');
} else {
levelSave.removeAttribute('aria-busy');
}
};
const updateLogLevel = async (save = false) => {
if (levelBusy) {
return;
}
const level = Number(levelSelect.value);
if (save && (!Number.isInteger(level) || level < 0 || level > 7)) {
return;
}
levelBusy = true;
levelError.hidden = true;
setSaveLabel(save ? 'button.wait' : 'button.save');
updateLevelControls();
try {
const options = {cache: 'no-cache', credentials: 'include'};
if (save) {
options.method = 'POST';
options.headers = {'Content-Type': 'application/json'};
options.body = JSON.stringify({system: {logLevel: level}});
}
const response = await fetch(levelForm.action, options);
if (!response.ok) {
throw new Error('Response not valid');
}
const result = await response.json();
const value = result.system && result.system.logLevel;
if (!Number.isInteger(value) || value < 0 || value > 7) {
throw new Error('Invalid log level');
}
confirmedLevel = value;
levelSelect.value = String(value);
setSaveLabel(save ? 'button.saved' : 'button.save');
} catch (err) {
if (confirmedLevel !== null) {
levelSelect.value = String(confirmedLevel);
}
levelErrorText.setAttribute('data-i18n', save ? 'logs.level.saveError' : 'logs.level.loadError');
lang.translateElement(levelErrorText);
levelError.hidden = false;
setSaveLabel('button.save');
} finally {
levelBusy = false;
updateLevelControls();
}
};
levelForm.addEventListener('submit', (event) => {
event.preventDefault();
updateLogLevel(true);
});
levelRetry.addEventListener('click', () => updateLogLevel());
levelSelect.addEventListener('change', () => setSaveLabel('button.save'));
updateLogLevel();
const resetFormatting = () => {
escapeState = 'text';
escapeParams = '';
foreground = null;
background = null;
bold = false;
};
const applyFormatting = () => {
const codes = escapeParams.split(';').map(Number);
for (let i = 0; i < codes.length; i++) {
const code = codes[i];
if (code === 0) {
foreground = null;
background = null;
bold = false;
} else if (code === 1) {
bold = true;
} else if (code === 22) {
bold = false;
} else if (code === 39) {
foreground = null;
} else if (code === 49) {
background = null;
} else if (code >= 30 && code <= 37) {
foreground = colors[code - 30];
} else if (code >= 90 && code <= 97) {
foreground = colors[code - 90 + 8];
} else if (code >= 40 && code <= 47) {
background = colors[code - 40];
} else if (code >= 100 && code <= 107) {
background = colors[code - 100 + 8];
} else if (code === 38 || code === 48) {
// Skip unsupported extended colors, including their parameters.
if (codes[i + 1] === 5) {
i += 2;
} else if (codes[i + 1] === 2) {
i += 4;
}
}
}
};
const appendLog = (message) => {
const record = document.createElement('div');
let text = '';
const flushText = () => {
if (!text) {
return;
}
if (bold || foreground || background) {
const span = document.createElement('span');
span.textContent = text;
if (bold) {
span.style.fontWeight = 'bold';
}
if (foreground) {
span.style.color = foreground;
}
if (background) {
span.style.backgroundColor = background;
}
record.appendChild(span);
} else {
record.appendChild(document.createTextNode(text));
}
text = '';
};
// Retain parser state when an escape sequence crosses frame boundaries.
for (const symbol of message) {
const code = symbol.charCodeAt(0);
if (escapeState === 'string' || escapeState === 'stringEscape') {
if (symbol === '\x07' || (escapeState === 'stringEscape' && symbol === '\\')) {
escapeState = 'text';
} else {
escapeState = symbol === '\x1b' ? 'stringEscape' : 'string';
}
continue;
}
if (symbol === '\x1b') {
flushText();
escapeState = 'escape';
continue;
}
if (escapeState === 'escape') {
if (symbol === '[') {
escapeState = 'csi';
escapeParams = '';
} else if (']P^_'.includes(symbol)) {
escapeState = 'string';
} else {
escapeState = code >= 0x20 && code <= 0x2f ? 'intermediate' : 'text';
}
continue;
}
if (escapeState === 'intermediate') {
if (code >= 0x30 && code <= 0x7e) {
escapeState = 'text';
}
continue;
}
if (escapeState === 'csi') {
if (code >= 0x40 && code <= 0x7e) {
if (symbol === 'm') {
applyFormatting();
}
escapeState = 'text';
} else if (escapeParams.length < 128) {
escapeParams += symbol;
}
continue;
}
if (symbol === '\n' || symbol === '\t' || (code >= 0x20 && (code < 0x7f || code > 0x9f))) {
text += symbol;
}
}
flushText();
const plainText = record.textContent.toLowerCase();
record.hidden = !plainText.includes(filterText);
output.appendChild(record);
if (!record.hidden && searchText && plainText.includes(searchText)) {
matches.push(record);
if (selectedIndex < 0) {
selectedIndex = 0;
}
}
pruneHistory();
updateSelection();
if (autoscroll.checked) {
output.scrollTop = output.scrollHeight;
}
};
const scheduleReconnect = () => {
setConnectionStatus('disconnected');
if (active && reconnectTimer === null) {
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connect();
}, 3000);
}
};
const connect = () => {
if (!active || socket) {
return;
}
setConnectionStatus('connecting');
resetFormatting();
try {
socket = new WebSocket(`${location.protocol === 'https:' ? 'wss:' : 'ws:'}//${location.host}/webserial`);
} catch (err) {
scheduleReconnect();
return;
}
socket.onopen = () => {
setConnectionStatus('connected');
waitingForPong = false;
heartbeatTimer = setInterval(() => {
if (socket.readyState !== WebSocket.OPEN) {
return;
}
if (waitingForPong) {
socket.close();
return;
}
waitingForPong = true;
socket.send('ping');
}, 10000);
};
socket.onmessage = (event) => {
if (event.data === 'pong') {
waitingForPong = false;
} else if (typeof event.data === 'string') {
appendLog(event.data);
}
};
socket.onerror = () => socket.close();
socket.onclose = () => {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
socket = null;
scheduleReconnect();
};
};
document.getElementById('clear-logs').addEventListener('click', () => {
output.replaceChildren();
applyView();
resetFormatting();
});
document.getElementById('download-logs').addEventListener('click', () => {
const text = Array.from(output.children, (record) => record.textContent).join('\n');
const url = URL.createObjectURL(new Blob([text], {type: 'text/plain;charset=utf-8'}));
const link = document.createElement('a');
link.href = url;
link.download = `otgateway-logs-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
});
autoscroll.addEventListener('change', () => {
if (autoscroll.checked) {
output.scrollTop = output.scrollHeight;
}
});
window.addEventListener('pagehide', () => {
active = false;
clearTimeout(reconnectTimer);
clearInterval(heartbeatTimer);
clearTimeout(viewTimer);
reconnectTimer = null;
heartbeatTimer = null;
viewTimer = null;
if (socket) {
socket.onopen = null;
socket.onmessage = null;
socket.onerror = null;
socket.onclose = null;
socket.close();
socket = null;
}
});
window.addEventListener('pageshow', (event) => {
if (event.persisted) {
active = true;
applyView();
connect();
}
});
connect();
});
</script>
</body>
</html>