mirror of
https://github.com/Laxilef/OTGateway.git
synced 2026-11-11 08:54:51 +05:00
feat: add authenticated WebSerial log viewer
This commit is contained in:
1 parent
734615e7b5
commit
959a15c11f
9 files changed
+821
-2
No files matched your search
@@ -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
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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": "固件升级",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Обновление",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in new issue
Block a user