Skip to content

Commit b244d2e

Browse files
authored
feat: Improve desktop log viewer layout and behavior, logging better than in console (#167)
* Improve desktop log viewer layout and behavior * Add optional log message coloring and hide level tag * Improve log autoscroll reliability * Refine desktop log layout and persist preferences * Pin desktop toolbar while scrolling * Colorize info messages by chat hash * Show HTTP logs in desktop viewer * Refine info message color gradients * Alternate info log colors by group
1 parent 187a9f3 commit b244d2e

File tree

6 files changed

+245
-37
lines changed

6 files changed

+245
-37
lines changed

electron/index.html

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ <h1>Telegram Functions Bot</h1>
2323
<section class="toolbar">
2424
<div class="filters">
2525
<label><input type="checkbox" data-source="messages" checked /> Messages</label>
26+
<label><input type="checkbox" data-source="http" checked /> HTTP</label>
2627
<label><input type="checkbox" data-source="desktop" checked /> Desktop</label>
2728
</div>
2829
<div class="actions">
@@ -31,18 +32,20 @@ <h1>Telegram Functions Bot</h1>
3132
<label class="autoscale">
3233
<input type="checkbox" id="autoscroll" checked /> Auto-scroll
3334
</label>
35+
<label class="color-toggle">
36+
<input type="checkbox" id="color-messages" /> Color messages
37+
</label>
3438
</div>
3539
</section>
3640

3741
<main class="log-view" id="log-container"></main>
3842

3943
<template id="log-line-template">
4044
<article class="log-line">
41-
<div class="log-meta">
42-
<span class="log-source"></span>
45+
<header class="log-meta">
4346
<span class="log-timestamp"></span>
4447
<span class="log-level"></span>
45-
</div>
48+
</header>
4649
<pre class="log-message"></pre>
4750
</article>
4851
</template>

electron/logTail.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { EventEmitter } from "node:events";
22
import { promises as fsPromises, watch, FSWatcher } from "node:fs";
33
import path from "node:path";
44

5-
export type LogSource = "messages";
5+
export type LogSource = "messages" | "http";
66
export type LogLevel = "debug" | "verbose" | "info" | "warn" | "error";
77

88
export interface LogEntry {
@@ -188,6 +188,7 @@ export function parseLogLine(source: LogSource, raw: string): LogEntry {
188188
export function createDefaultLogTailer(baseDir = "data") {
189189
const files: Record<LogSource, string> = {
190190
messages: path.join(baseDir, "messages.log"),
191+
http: path.join(baseDir, "http.log"),
191192
};
192193
return new LogTailer(files, { followExisting: false });
193194
}

electron/main.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import {
1010
} from "electron";
1111
import path from "node:path";
1212
import fs from "node:fs";
13-
import { LogEntry, LogLevel, parseLogLine } from "./logTail.ts";
13+
import { LogEntry, LogLevel, parseLogLine, type LogSource } from "./logTail.ts";
1414
import { subscribeToLogs, type LogDispatchPayload } from "../src/helpers.ts";
1515
import { startBot, stopBot } from "../src/index.ts";
1616

@@ -332,15 +332,21 @@ function updateTrayMenu() {
332332
tray.setContextMenu(Menu.buildFromTemplate(template));
333333
}
334334

335+
const RUNTIME_LOG_SOURCES = new Map<string, LogSource>([
336+
["messages.log", "messages"],
337+
["http.log", "http"],
338+
]);
339+
335340
function handleLogEvent(payload: LogDispatchPayload) {
336341
if (!payload.logPath) {
337342
return;
338343
}
339344
const fileName = path.basename(payload.logPath);
340-
if (fileName !== "messages.log") {
345+
const source = RUNTIME_LOG_SOURCES.get(fileName);
346+
if (!source) {
341347
return;
342348
}
343-
const entry = parseLogLine("messages", payload.formatted);
349+
const entry = parseLogLine(source, payload.formatted);
344350
sendLog(entry);
345351
}
346352

electron/renderer.js

Lines changed: 172 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const openLogsButton = document.getElementById("open-logs");
77
const pauseButton = document.getElementById("pause");
88
const clearButton = document.getElementById("clear");
99
const autoScrollToggle = document.getElementById("autoscroll");
10+
const colorMessagesToggle = document.getElementById("color-messages");
1011
const filterCheckboxes = Array.from(document.querySelectorAll(".filters input[type='checkbox']"));
1112

1213
const desktopBridge = window.desktop ?? {
@@ -22,21 +23,147 @@ if (!window.desktop) {
2223
console.error("Desktop preload bridge unavailable. Renderer controls will be no-ops.");
2324
}
2425

26+
const PREFERENCES_STORAGE_KEY = "desktop-log-preferences";
27+
28+
function readStoredPreferences() {
29+
try {
30+
const raw = window.localStorage?.getItem(PREFERENCES_STORAGE_KEY);
31+
if (!raw) {
32+
return {};
33+
}
34+
const parsed = JSON.parse(raw);
35+
return typeof parsed === "object" && parsed ? parsed : {};
36+
} catch (error) {
37+
console.warn("[preferences] Failed to read stored preferences", error);
38+
return {};
39+
}
40+
}
41+
42+
let storedPreferences = readStoredPreferences();
43+
44+
function persistPreferences(patch) {
45+
storedPreferences = { ...storedPreferences, ...patch };
46+
try {
47+
window.localStorage?.setItem(
48+
PREFERENCES_STORAGE_KEY,
49+
JSON.stringify(storedPreferences),
50+
);
51+
} catch (error) {
52+
console.warn("[preferences] Failed to persist preferences", error);
53+
}
54+
}
55+
56+
const INFO_MESSAGE_COLORS = ["#aaa", "#fff"];
57+
58+
const infoMessageColorState = {
59+
lastIdentifier: null,
60+
colorIndex: 0,
61+
};
62+
2563
const state = {
2664
paused: false,
27-
autoScroll: true,
28-
filters: new Set(["messages", "desktop"]),
65+
autoScroll:
66+
typeof storedPreferences.autoScroll === "boolean"
67+
? storedPreferences.autoScroll
68+
: true,
69+
colorMessages:
70+
typeof storedPreferences.colorMessages === "boolean"
71+
? storedPreferences.colorMessages
72+
: false,
73+
filters: new Set(["messages", "http", "desktop"]),
2974
logs: [],
3075
};
3176

32-
function scrollToBottom() {
33-
if (!state.autoScroll) return;
34-
logContainer.scrollTop = logContainer.scrollHeight;
77+
function scrollToBottom(reason = "unknown") {
78+
if (!logContainer) {
79+
console.warn("[scrollToBottom] Missing log container", { reason });
80+
return;
81+
}
82+
83+
if (!state.autoScroll) {
84+
console.debug("[scrollToBottom] Skipped because auto-scroll is disabled", {
85+
reason,
86+
scrollTop: logContainer.scrollTop,
87+
scrollHeight: logContainer.scrollHeight,
88+
clientHeight: logContainer.clientHeight,
89+
});
90+
return;
91+
}
92+
93+
const before = {
94+
scrollTop: logContainer.scrollTop,
95+
scrollHeight: logContainer.scrollHeight,
96+
clientHeight: logContainer.clientHeight,
97+
};
98+
99+
requestAnimationFrame(() => {
100+
const lastEntry = logContainer.lastElementChild;
101+
if (lastEntry instanceof HTMLElement) {
102+
lastEntry.scrollIntoView({ block: "end" });
103+
}
104+
logContainer.scrollTop = logContainer.scrollHeight;
105+
const after = {
106+
scrollTop: logContainer.scrollTop,
107+
scrollHeight: logContainer.scrollHeight,
108+
clientHeight: logContainer.clientHeight,
109+
};
110+
console.debug("[scrollToBottom] Applied", { reason, before, after, hasEntry: Boolean(lastEntry) });
111+
});
35112
}
36113

37114
function formatTimestamp(timestamp) {
38115
if (!timestamp) return "";
39-
return timestamp;
116+
const normalized = timestamp.includes("T") ? timestamp : timestamp.replace(" ", "T");
117+
const date = new Date(normalized);
118+
if (Number.isNaN(date.getTime())) {
119+
return timestamp;
120+
}
121+
const hours = `${date.getHours()}`.padStart(2, "0");
122+
const minutes = `${date.getMinutes()}`.padStart(2, "0");
123+
const seconds = `${date.getSeconds()}`.padStart(2, "0");
124+
return `${hours}:${minutes}:${seconds}`;
125+
}
126+
127+
function resetInfoMessageColorState() {
128+
infoMessageColorState.lastIdentifier = null;
129+
infoMessageColorState.colorIndex = 0;
130+
}
131+
132+
function extractInfoMessageIdentifier(trimmed) {
133+
if (!trimmed) {
134+
return null;
135+
}
136+
const firstWhitespaceIndex = trimmed.search(/\s/);
137+
return firstWhitespaceIndex === -1 ? trimmed : trimmed.slice(0, firstWhitespaceIndex);
138+
}
139+
140+
function enrichLogEntry(entry) {
141+
const enriched = { ...entry };
142+
if (entry.level !== "info" && entry.level !== "verbose") {
143+
return enriched;
144+
}
145+
146+
const trimmed = entry.message?.trim();
147+
if (!trimmed) {
148+
return enriched;
149+
}
150+
151+
const identifier = extractInfoMessageIdentifier(trimmed);
152+
if (!identifier) {
153+
return enriched;
154+
}
155+
156+
if (infoMessageColorState.lastIdentifier === null) {
157+
infoMessageColorState.colorIndex = 0;
158+
} else if (identifier !== infoMessageColorState.lastIdentifier) {
159+
infoMessageColorState.colorIndex =
160+
(infoMessageColorState.colorIndex + 1) % INFO_MESSAGE_COLORS.length;
161+
}
162+
163+
infoMessageColorState.lastIdentifier = identifier;
164+
enriched.infoColor = INFO_MESSAGE_COLORS[infoMessageColorState.colorIndex];
165+
enriched.infoIdentifier = identifier;
166+
return enriched;
40167
}
41168

42169
function renderEntry(entry) {
@@ -45,12 +172,20 @@ function renderEntry(entry) {
45172
}
46173
const node = template.content.firstElementChild.cloneNode(true);
47174
node.dataset.source = entry.source;
48-
node.querySelector(".log-source").textContent = entry.source;
49-
node.querySelector(".log-timestamp").textContent = formatTimestamp(entry.timestamp);
175+
const timestampElement = node.querySelector(".log-timestamp");
176+
timestampElement.textContent = formatTimestamp(entry.timestamp);
177+
timestampElement.dataset.level = entry.level;
50178
const levelElement = node.querySelector(".log-level");
51179
levelElement.textContent = entry.level.toUpperCase();
52180
levelElement.dataset.level = entry.level;
53-
node.querySelector(".log-message").textContent = entry.message;
181+
const messageElement = node.querySelector(".log-message");
182+
messageElement.textContent = entry.message;
183+
messageElement.dataset.level = entry.level;
184+
if ((entry.level === "info" || entry.level === "verbose") && entry.infoColor) {
185+
messageElement.style.setProperty("--message-color", entry.infoColor);
186+
} else {
187+
messageElement.style.removeProperty("--message-color");
188+
}
54189
logContainer.appendChild(node);
55190
}
56191

@@ -63,17 +198,23 @@ function renderAll() {
63198
logContainer.appendChild(empty);
64199
return;
65200
}
201+
resetInfoMessageColorState();
202+
state.logs = state.logs.map((entry) => enrichLogEntry(entry));
66203
state.logs.forEach((entry) => {
67204
renderEntry(entry);
68205
});
69-
scrollToBottom();
206+
scrollToBottom("renderAll");
70207
}
71208

72209
function handleLog(entry) {
73-
state.logs.push(entry);
74-
if (state.paused) return;
75-
renderEntry(entry);
76-
scrollToBottom();
210+
const enriched = enrichLogEntry(entry);
211+
state.logs.push(enriched);
212+
if (state.paused) {
213+
console.debug("[handleLog] Received log while paused", entry);
214+
return;
215+
}
216+
renderEntry(enriched);
217+
scrollToBottom("handleLog");
77218
}
78219

79220
function updateStatus(running) {
@@ -117,11 +258,25 @@ pauseButton.addEventListener("click", () => {
117258

118259
clearButton.addEventListener("click", () => {
119260
state.logs = [];
261+
resetInfoMessageColorState();
120262
renderAll();
121263
});
122264

123265
autoScrollToggle.addEventListener("change", () => {
124266
state.autoScroll = autoScrollToggle.checked;
267+
if (state.autoScroll) {
268+
console.debug("[autoscroll] Enabled");
269+
scrollToBottom("autoScrollToggle");
270+
} else {
271+
console.debug("[autoscroll] Disabled");
272+
}
273+
persistPreferences({ autoScroll: state.autoScroll });
274+
});
275+
276+
colorMessagesToggle.addEventListener("change", () => {
277+
state.colorMessages = colorMessagesToggle.checked;
278+
logContainer.classList.toggle("color-messages", state.colorMessages);
279+
persistPreferences({ colorMessages: state.colorMessages });
125280
});
126281

127282
desktopBridge.onLog(handleLog);
@@ -132,5 +287,8 @@ desktopBridge.onBotState((stateInfo) => {
132287
window.addEventListener("DOMContentLoaded", () => {
133288
updateStatus(false);
134289
renderAll();
290+
autoScrollToggle.checked = state.autoScroll;
291+
colorMessagesToggle.checked = state.colorMessages;
292+
logContainer.classList.toggle("color-messages", state.colorMessages);
135293
desktopBridge.notifyReady();
136294
});

0 commit comments

Comments
 (0)