Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions electron/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ <h1>Telegram Functions Bot</h1>
<section class="toolbar">
<div class="filters">
<label><input type="checkbox" data-source="messages" checked /> Messages</label>
<label><input type="checkbox" data-source="http" checked /> HTTP</label>
<label><input type="checkbox" data-source="desktop" checked /> Desktop</label>
</div>
<div class="actions">
Expand All @@ -31,18 +32,20 @@ <h1>Telegram Functions Bot</h1>
<label class="autoscale">
<input type="checkbox" id="autoscroll" checked /> Auto-scroll
</label>
<label class="color-toggle">
<input type="checkbox" id="color-messages" /> Color messages
</label>
</div>
</section>

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

<template id="log-line-template">
<article class="log-line">
<div class="log-meta">
<span class="log-source"></span>
<header class="log-meta">
<span class="log-timestamp"></span>
<span class="log-level"></span>
</div>
</header>
<pre class="log-message"></pre>
</article>
</template>
Expand Down
3 changes: 2 additions & 1 deletion electron/logTail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -188,6 +188,7 @@ export function parseLogLine(source: LogSource, raw: string): LogEntry {
export function createDefaultLogTailer(baseDir = "data") {
const files: Record<LogSource, string> = {
messages: path.join(baseDir, "messages.log"),
http: path.join(baseDir, "http.log"),
};
return new LogTailer(files, { followExisting: false });
}
12 changes: 9 additions & 3 deletions electron/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -329,15 +329,21 @@ function updateTrayMenu() {
tray.setContextMenu(Menu.buildFromTemplate(template));
}

const RUNTIME_LOG_SOURCES = new Map<string, LogSource>([
["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);
}

Expand Down
186 changes: 172 additions & 14 deletions electron/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 ?? {
Expand All @@ -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) {
Expand All @@ -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);
}

Expand All @@ -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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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();
});
Loading