diff --git a/electron/index.html b/electron/index.html
index addb114..c783ebd 100644
--- a/electron/index.html
+++ b/electron/index.html
@@ -23,6 +23,7 @@
Telegram Functions Bot
@@ -38,11 +42,10 @@ Telegram Functions Bot
-
-
+
+
diff --git a/electron/logTail.ts b/electron/logTail.ts
index 572b2f9..810dcac 100644
--- a/electron/logTail.ts
+++ b/electron/logTail.ts
@@ -2,7 +2,7 @@ import { EventEmitter } from "node:events";
import { promises as fsPromises, watch, FSWatcher } from "node:fs";
import path from "node:path";
-export type LogSource = "messages";
+export type LogSource = "messages" | "http";
export type LogLevel = "debug" | "verbose" | "info" | "warn" | "error";
export interface LogEntry {
@@ -188,6 +188,7 @@ export function parseLogLine(source: LogSource, raw: string): LogEntry {
export function createDefaultLogTailer(baseDir = "data") {
const files: Record = {
messages: path.join(baseDir, "messages.log"),
+ http: path.join(baseDir, "http.log"),
};
return new LogTailer(files, { followExisting: false });
}
diff --git a/electron/main.ts b/electron/main.ts
index 7913782..2cc7bd6 100644
--- a/electron/main.ts
+++ b/electron/main.ts
@@ -10,7 +10,7 @@ import {
} from "electron";
import path from "node:path";
import fs from "node:fs";
-import { LogEntry, LogLevel, parseLogLine } from "./logTail.ts";
+import { LogEntry, LogLevel, parseLogLine, type LogSource } from "./logTail.ts";
import { subscribeToLogs, type LogDispatchPayload } from "../src/helpers.ts";
import { startBot, stopBot } from "../src/index.ts";
@@ -329,15 +329,21 @@ function updateTrayMenu() {
tray.setContextMenu(Menu.buildFromTemplate(template));
}
+const RUNTIME_LOG_SOURCES = new Map([
+ ["messages.log", "messages"],
+ ["http.log", "http"],
+]);
+
function handleLogEvent(payload: LogDispatchPayload) {
if (!payload.logPath) {
return;
}
const fileName = path.basename(payload.logPath);
- if (fileName !== "messages.log") {
+ const source = RUNTIME_LOG_SOURCES.get(fileName);
+ if (!source) {
return;
}
- const entry = parseLogLine("messages", payload.formatted);
+ const entry = parseLogLine(source, payload.formatted);
sendLog(entry);
}
diff --git a/electron/renderer.js b/electron/renderer.js
index 6d407a4..3d5c4d6 100644
--- a/electron/renderer.js
+++ b/electron/renderer.js
@@ -7,6 +7,7 @@ const openLogsButton = document.getElementById("open-logs");
const pauseButton = document.getElementById("pause");
const clearButton = document.getElementById("clear");
const autoScrollToggle = document.getElementById("autoscroll");
+const colorMessagesToggle = document.getElementById("color-messages");
const filterCheckboxes = Array.from(document.querySelectorAll(".filters input[type='checkbox']"));
const desktopBridge = window.desktop ?? {
@@ -22,21 +23,147 @@ if (!window.desktop) {
console.error("Desktop preload bridge unavailable. Renderer controls will be no-ops.");
}
+const PREFERENCES_STORAGE_KEY = "desktop-log-preferences";
+
+function readStoredPreferences() {
+ try {
+ const raw = window.localStorage?.getItem(PREFERENCES_STORAGE_KEY);
+ if (!raw) {
+ return {};
+ }
+ const parsed = JSON.parse(raw);
+ return typeof parsed === "object" && parsed ? parsed : {};
+ } catch (error) {
+ console.warn("[preferences] Failed to read stored preferences", error);
+ return {};
+ }
+}
+
+let storedPreferences = readStoredPreferences();
+
+function persistPreferences(patch) {
+ storedPreferences = { ...storedPreferences, ...patch };
+ try {
+ window.localStorage?.setItem(
+ PREFERENCES_STORAGE_KEY,
+ JSON.stringify(storedPreferences),
+ );
+ } catch (error) {
+ console.warn("[preferences] Failed to persist preferences", error);
+ }
+}
+
+const INFO_MESSAGE_COLORS = ["#aaa", "#fff"];
+
+const infoMessageColorState = {
+ lastIdentifier: null,
+ colorIndex: 0,
+};
+
const state = {
paused: false,
- autoScroll: true,
- filters: new Set(["messages", "desktop"]),
+ autoScroll:
+ typeof storedPreferences.autoScroll === "boolean"
+ ? storedPreferences.autoScroll
+ : true,
+ colorMessages:
+ typeof storedPreferences.colorMessages === "boolean"
+ ? storedPreferences.colorMessages
+ : false,
+ filters: new Set(["messages", "http", "desktop"]),
logs: [],
};
-function scrollToBottom() {
- if (!state.autoScroll) return;
- logContainer.scrollTop = logContainer.scrollHeight;
+function scrollToBottom(reason = "unknown") {
+ if (!logContainer) {
+ console.warn("[scrollToBottom] Missing log container", { reason });
+ return;
+ }
+
+ if (!state.autoScroll) {
+ console.debug("[scrollToBottom] Skipped because auto-scroll is disabled", {
+ reason,
+ scrollTop: logContainer.scrollTop,
+ scrollHeight: logContainer.scrollHeight,
+ clientHeight: logContainer.clientHeight,
+ });
+ return;
+ }
+
+ const before = {
+ scrollTop: logContainer.scrollTop,
+ scrollHeight: logContainer.scrollHeight,
+ clientHeight: logContainer.clientHeight,
+ };
+
+ requestAnimationFrame(() => {
+ const lastEntry = logContainer.lastElementChild;
+ if (lastEntry instanceof HTMLElement) {
+ lastEntry.scrollIntoView({ block: "end" });
+ }
+ logContainer.scrollTop = logContainer.scrollHeight;
+ const after = {
+ scrollTop: logContainer.scrollTop,
+ scrollHeight: logContainer.scrollHeight,
+ clientHeight: logContainer.clientHeight,
+ };
+ console.debug("[scrollToBottom] Applied", { reason, before, after, hasEntry: Boolean(lastEntry) });
+ });
}
function formatTimestamp(timestamp) {
if (!timestamp) return "";
- return timestamp;
+ const normalized = timestamp.includes("T") ? timestamp : timestamp.replace(" ", "T");
+ const date = new Date(normalized);
+ if (Number.isNaN(date.getTime())) {
+ return timestamp;
+ }
+ const hours = `${date.getHours()}`.padStart(2, "0");
+ const minutes = `${date.getMinutes()}`.padStart(2, "0");
+ const seconds = `${date.getSeconds()}`.padStart(2, "0");
+ return `${hours}:${minutes}:${seconds}`;
+}
+
+function resetInfoMessageColorState() {
+ infoMessageColorState.lastIdentifier = null;
+ infoMessageColorState.colorIndex = 0;
+}
+
+function extractInfoMessageIdentifier(trimmed) {
+ if (!trimmed) {
+ return null;
+ }
+ const firstWhitespaceIndex = trimmed.search(/\s/);
+ return firstWhitespaceIndex === -1 ? trimmed : trimmed.slice(0, firstWhitespaceIndex);
+}
+
+function enrichLogEntry(entry) {
+ const enriched = { ...entry };
+ if (entry.level !== "info" && entry.level !== "verbose") {
+ return enriched;
+ }
+
+ const trimmed = entry.message?.trim();
+ if (!trimmed) {
+ return enriched;
+ }
+
+ const identifier = extractInfoMessageIdentifier(trimmed);
+ if (!identifier) {
+ return enriched;
+ }
+
+ if (infoMessageColorState.lastIdentifier === null) {
+ infoMessageColorState.colorIndex = 0;
+ } else if (identifier !== infoMessageColorState.lastIdentifier) {
+ infoMessageColorState.colorIndex =
+ (infoMessageColorState.colorIndex + 1) % INFO_MESSAGE_COLORS.length;
+ }
+
+ infoMessageColorState.lastIdentifier = identifier;
+ enriched.infoColor = INFO_MESSAGE_COLORS[infoMessageColorState.colorIndex];
+ enriched.infoIdentifier = identifier;
+ return enriched;
}
function renderEntry(entry) {
@@ -45,12 +172,20 @@ function renderEntry(entry) {
}
const node = template.content.firstElementChild.cloneNode(true);
node.dataset.source = entry.source;
- node.querySelector(".log-source").textContent = entry.source;
- node.querySelector(".log-timestamp").textContent = formatTimestamp(entry.timestamp);
+ const timestampElement = node.querySelector(".log-timestamp");
+ timestampElement.textContent = formatTimestamp(entry.timestamp);
+ timestampElement.dataset.level = entry.level;
const levelElement = node.querySelector(".log-level");
levelElement.textContent = entry.level.toUpperCase();
levelElement.dataset.level = entry.level;
- node.querySelector(".log-message").textContent = entry.message;
+ const messageElement = node.querySelector(".log-message");
+ messageElement.textContent = entry.message;
+ messageElement.dataset.level = entry.level;
+ if ((entry.level === "info" || entry.level === "verbose") && entry.infoColor) {
+ messageElement.style.setProperty("--message-color", entry.infoColor);
+ } else {
+ messageElement.style.removeProperty("--message-color");
+ }
logContainer.appendChild(node);
}
@@ -63,17 +198,23 @@ function renderAll() {
logContainer.appendChild(empty);
return;
}
+ resetInfoMessageColorState();
+ state.logs = state.logs.map((entry) => enrichLogEntry(entry));
state.logs.forEach((entry) => {
renderEntry(entry);
});
- scrollToBottom();
+ scrollToBottom("renderAll");
}
function handleLog(entry) {
- state.logs.push(entry);
- if (state.paused) return;
- renderEntry(entry);
- scrollToBottom();
+ const enriched = enrichLogEntry(entry);
+ state.logs.push(enriched);
+ if (state.paused) {
+ console.debug("[handleLog] Received log while paused", entry);
+ return;
+ }
+ renderEntry(enriched);
+ scrollToBottom("handleLog");
}
function updateStatus(running) {
@@ -117,11 +258,25 @@ pauseButton.addEventListener("click", () => {
clearButton.addEventListener("click", () => {
state.logs = [];
+ resetInfoMessageColorState();
renderAll();
});
autoScrollToggle.addEventListener("change", () => {
state.autoScroll = autoScrollToggle.checked;
+ if (state.autoScroll) {
+ console.debug("[autoscroll] Enabled");
+ scrollToBottom("autoScrollToggle");
+ } else {
+ console.debug("[autoscroll] Disabled");
+ }
+ persistPreferences({ autoScroll: state.autoScroll });
+});
+
+colorMessagesToggle.addEventListener("change", () => {
+ state.colorMessages = colorMessagesToggle.checked;
+ logContainer.classList.toggle("color-messages", state.colorMessages);
+ persistPreferences({ colorMessages: state.colorMessages });
});
desktopBridge.onLog(handleLog);
@@ -132,5 +287,8 @@ desktopBridge.onBotState((stateInfo) => {
window.addEventListener("DOMContentLoaded", () => {
updateStatus(false);
renderAll();
+ autoScrollToggle.checked = state.autoScroll;
+ colorMessagesToggle.checked = state.colorMessages;
+ logContainer.classList.toggle("color-messages", state.colorMessages);
desktopBridge.notifyReady();
});
diff --git a/electron/styles.css b/electron/styles.css
index d1a017c..dff0399 100644
--- a/electron/styles.css
+++ b/electron/styles.css
@@ -68,11 +68,15 @@ select {
gap: 12px;
background: rgba(30, 41, 59, 0.85);
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
+ position: sticky;
+ top: 0;
+ z-index: 5;
}
.filters label,
.actions button,
-.autoscale {
+.autoscale,
+.color-toggle {
margin-right: 12px;
color: #e2e8f0;
}
@@ -90,47 +94,64 @@ select {
background: rgba(148, 163, 184, 0.35);
}
+
.log-view {
flex: 1;
overflow-y: auto;
- padding: 16px 20px 24px;
+ padding: 12px 18px 20px;
display: flex;
flex-direction: column;
- gap: 8px;
+ gap: 1px;
}
.log-line {
background: rgba(15, 23, 42, 0.7);
border: 1px solid rgba(148, 163, 184, 0.12);
- border-radius: 8px;
- padding: 12px 14px;
+ border-radius: 6px;
+ padding: 5px 10px;
display: grid;
- grid-template-columns: minmax(160px, 220px) 1fr;
- gap: 12px;
+ grid-template-columns: 70px 1fr;
+ gap: 1px;
}
.log-meta {
display: flex;
- flex-direction: column;
- gap: 4px;
+ align-items: flex-start;
+ gap: 8px;
font-size: 0.8rem;
color: rgba(203, 213, 225, 0.8);
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
}
-.log-meta .log-source {
+.log-timestamp {
+ font-variant-numeric: tabular-nums;
font-weight: 600;
- text-transform: uppercase;
- letter-spacing: 0.06em;
+ text-transform: none;
+ letter-spacing: normal;
+}
+
+.log-timestamp[data-level="info"],
+.log-timestamp[data-level="verbose"] {
+ color: #ffffff;
+}
+
+.log-timestamp[data-level="debug"] {
+ color: #94a3b8;
}
-.log-meta .log-level[data-level="warn"] {
- color: var(--warn);
+.log-timestamp[data-level="warn"] {
+ color: #facc15;
}
-.log-meta .log-level[data-level="error"] {
+.log-timestamp[data-level="error"] {
color: var(--error);
}
+.log-meta .log-level {
+ display: none;
+}
+
.log-message {
margin: 0;
font-size: 0.85rem;
@@ -140,6 +161,23 @@ select {
word-break: break-word;
}
+.log-view.color-messages .log-message[data-level="info"],
+.log-view.color-messages .log-message[data-level="verbose"] {
+ color: var(--message-color, #ffffff);
+}
+
+.log-view.color-messages .log-message[data-level="debug"] {
+ color: #94a3b8;
+}
+
+.log-view.color-messages .log-message[data-level="warn"] {
+ color: #facc15;
+}
+
+.log-view.color-messages .log-message[data-level="error"] {
+ color: var(--error);
+}
+
.empty-state {
margin: auto;
color: rgba(148, 163, 184, 0.7);
diff --git a/tests/electron/logTail.test.ts b/tests/electron/logTail.test.ts
index 1908525..fd90196 100644
--- a/tests/electron/logTail.test.ts
+++ b/tests/electron/logTail.test.ts
@@ -8,6 +8,7 @@ import {
parseLogLine,
createDefaultLogTailer,
isRelevantChange,
+ type LogSource,
} from "../../electron/logTail.ts";
const TIMESTAMP = "2024-01-01 10:00:00";
@@ -48,13 +49,14 @@ describe("isRelevantChange", () => {
describe("LogTailer", () => {
let tempDir: string;
- let files: Record<"messages", string>;
+ let files: Record;
let tailer: LogTailer;
beforeEach(() => {
tempDir = mkdtempSync(path.join(os.tmpdir(), "tgbot-logs-"));
files = {
messages: path.join(tempDir, "messages.log"),
+ http: path.join(tempDir, "http.log"),
};
});