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

+
@@ -31,6 +32,9 @@

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"), }; });