diff --git a/webui/app.js b/webui/app.js index cc3937e..b18f67b 100644 --- a/webui/app.js +++ b/webui/app.js @@ -17,10 +17,38 @@ async function api(path, options = {}) { } function formatDate(value) { - if (!value) return "—"; + if (!value) return "-"; return value.replace("T", " ").replace("+00:00", " UTC"); } +function relativeTime(value) { + if (!value) return "-"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return "-"; + const seconds = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000)); + if (seconds < 10) return "now"; + if (seconds < 60) return `${seconds}s ago`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m ago`; + const hours = Math.floor(minutes / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +function displayTime(value) { + if (!value) return "-"; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return formatDate(value); + return date.toLocaleString(undefined, { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + function setBusy(button, busy) { if (!button) return; button.disabled = busy; @@ -42,7 +70,7 @@ function renderAuthPanel(authData) { const apiId = authData.saved_credentials?.api_id ?? ""; document.getElementById("api-id-input").value = apiId || ""; document.getElementById("api-hash-input").placeholder = authData.saved_credentials?.api_hash_present - ? "API Hash сохранён" + ? "API Hash saved" : "API Hash"; const showQr = Boolean(authData.qr_image); @@ -74,7 +102,7 @@ function renderChannels(channels) { node.querySelector(".channel-id").textContent = channel.channel_id; node.querySelector(".message-count").textContent = String(channel.message_count); node.querySelector(".media-count").textContent = String(channel.media_count); - node.querySelector(".last-date").textContent = channel.last_date || "—"; + node.querySelector(".last-date").textContent = channel.last_date || "-"; node.querySelector(".scrape-btn").addEventListener("click", async () => { await api("/api/jobs/scrape", { @@ -122,7 +150,7 @@ function renderJobs(jobs) { const template = document.getElementById("job-template"); if (!jobs.length) { - root.innerHTML = '
Пока задач нет.
'; + root.innerHTML = '
No jobs yet.
'; return; } @@ -130,12 +158,114 @@ function renderJobs(jobs) { const node = template.content.firstElementChild.cloneNode(true); node.querySelector(".job-title").textContent = job.title; node.querySelector(".job-status").textContent = job.status; - node.querySelector(".job-time").textContent = [job.started_at, job.finished_at].filter(Boolean).join(" -> "); - node.querySelector(".job-logs").textContent = job.logs || job.error || "Без логов"; + node.querySelector(".job-time").textContent = [displayTime(job.started_at), displayTime(job.finished_at)] + .filter((item) => item !== "-") + .join(" -> "); + node.querySelector(".job-logs").textContent = job.logs || job.error || "No logs."; root.appendChild(node); }); } +function renderContinuousChannelPicker(channels, config) { + const root = document.getElementById("continuous-channel-list"); + const runAll = Boolean(config.run_all_tracked); + const selected = new Set(config.channels || []); + root.innerHTML = ""; + + if (!channels.length) { + root.innerHTML = '
No tracked channels.
'; + return; + } + + channels.forEach((channel) => { + const label = document.createElement("label"); + label.className = "checkbox-row"; + label.classList.toggle("disabled", runAll); + + const checkbox = document.createElement("input"); + checkbox.type = "checkbox"; + checkbox.className = "continuous-channel-checkbox"; + checkbox.value = channel.channel_id; + checkbox.checked = runAll || selected.has(channel.channel_id); + checkbox.disabled = runAll; + + const text = document.createElement("span"); + text.textContent = `${channel.name} (${channel.channel_id})`; + + label.append(checkbox, text); + root.appendChild(label); + }); +} + +function classifyLog(message, level) { + if (level) return level; + const normalized = message.toLowerCase(); + if (normalized.includes("failed") || normalized.includes("error") || normalized.includes("traceback")) return "error"; + if (normalized.includes("sleep") || normalized.includes("no channels")) return "warn"; + if (normalized.includes("finished") || normalized.includes("completed")) return "success"; + if (normalized.includes("starting") || normalized.includes("started")) return "info"; + return "debug"; +} + +function normalizeLogEntry(entry) { + if (typeof entry === "object" && entry !== null) { + return { + timestamp: entry.timestamp || null, + level: classifyLog(entry.message || "", entry.level), + message: entry.message || "", + }; + } + const line = String(entry || ""); + const match = line.match(/^\[(\d{2}:\d{2}:\d{2})\]\s?(.*)$/); + return { + timestamp: null, + legacyTime: match?.[1] || "", + level: classifyLog(match?.[2] || line), + message: match?.[2] || line, + }; +} + +function renderContinuousLogs(status) { + const root = document.getElementById("continuous-logs"); + const entries = (status.log_entries || status.logs || []).map(normalizeLogEntry); + root.innerHTML = ""; + + if (!entries.length) { + const empty = document.createElement("div"); + empty.className = "log-line"; + empty.innerHTML = '--INFONo logs yet.'; + root.appendChild(empty); + return; + } + + entries.slice().reverse().forEach((entry) => { + const row = document.createElement("div"); + row.className = `log-line ${entry.level}`; + const time = entry.timestamp ? displayTime(entry.timestamp).split(", ").pop() : entry.legacyTime || "-"; + const absolute = entry.timestamp ? displayTime(entry.timestamp) : entry.legacyTime || "-"; + row.title = absolute; + + const timeNode = document.createElement("span"); + timeNode.className = "log-time"; + timeNode.textContent = time; + + const ageNode = document.createElement("span"); + ageNode.className = "log-age"; + ageNode.textContent = entry.timestamp ? relativeTime(entry.timestamp) : "-"; + + const levelNode = document.createElement("span"); + levelNode.className = "log-level"; + levelNode.textContent = entry.level.toUpperCase(); + + const messageNode = document.createElement("span"); + messageNode.className = "log-message"; + messageNode.textContent = entry.message; + + row.append(timeNode, ageNode, levelNode, messageNode); + root.appendChild(row); + }); +} + function renderContinuous(data) { state.continuous = data; const config = data.config || {}; @@ -144,12 +274,14 @@ function renderContinuous(data) { document.getElementById("continuous-enabled").checked = Boolean(config.enabled); document.getElementById("continuous-interval").value = config.interval_minutes || 1; document.getElementById("continuous-all").checked = Boolean(config.run_all_tracked); - document.getElementById("continuous-channels").value = (config.channels || []).join(", "); + renderContinuousChannelPicker(state.dashboard?.channels || [], config); document.getElementById("continuous-status").textContent = status.running ? "running" : "stopped"; - document.getElementById("continuous-last-iteration").textContent = status.last_iteration_at || "—"; - document.getElementById("continuous-last-error").textContent = status.last_error || "—"; - document.getElementById("continuous-logs").textContent = (status.logs || []).join("\n") || "Логов пока нет."; + document.getElementById("continuous-last-iteration").textContent = status.last_iteration_at + ? `${relativeTime(status.last_iteration_at)} (${displayTime(status.last_iteration_at)})` + : "-"; + document.getElementById("continuous-last-error").textContent = status.last_error || "-"; + renderContinuousLogs(status); } function setupTabs() { @@ -180,6 +312,14 @@ async function refreshDashboard() { async function main() { setupTabs(); + const settingsDialog = document.getElementById("settings-dialog"); + document.getElementById("open-settings-btn").addEventListener("click", () => { + settingsDialog.showModal(); + }); + document.getElementById("close-settings-btn").addEventListener("click", () => { + settingsDialog.close(); + }); + document.getElementById("scrape-all-btn").addEventListener("click", async (event) => { setBusy(event.currentTarget, true); try { @@ -296,8 +436,9 @@ async function main() { const enabled = document.getElementById("continuous-enabled").checked; const intervalMinutes = document.getElementById("continuous-interval").value; const runAllTracked = document.getElementById("continuous-all").checked; - const channelsRaw = document.getElementById("continuous-channels").value.trim(); - const channels = channelsRaw ? channelsRaw.split(",").map((item) => item.trim()).filter(Boolean) : []; + const channels = Array.from(document.querySelectorAll(".continuous-channel-checkbox:checked")).map( + (item) => item.value, + ); await api("/api/continuous", { method: "POST", body: JSON.stringify({ @@ -310,6 +451,13 @@ async function main() { await refreshDashboard(); }); + document.getElementById("continuous-all").addEventListener("change", () => { + renderContinuousChannelPicker(state.dashboard?.channels || [], { + ...(state.continuous?.config || {}), + run_all_tracked: document.getElementById("continuous-all").checked, + }); + }); + await refreshDashboard(); window.setInterval(refreshDashboard, 5000); } diff --git a/webui/index.html b/webui/index.html index 42d679c..9385b14 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1,5 +1,5 @@ - + @@ -11,59 +11,28 @@ @@ -76,18 +45,12 @@
-
Каналы
+
Channels
0
Media scraping
- +
OFF
Forwarding rules
@@ -98,13 +61,13 @@
-

Отслеживаемые каналы

-

Берутся из data/state.json, статистика подтягивается из SQLite.

+

Tracked Channels

+

Stored in data/state.json. Message stats come from SQLite.

- - - + + +
@@ -112,11 +75,11 @@ - - - - - + + + + + @@ -127,10 +90,10 @@
-

Фоновые задачи

-

Очередь простая: задачи выполняются по одной, чтобы не драться за Telegram session.

+

Jobs

+

Queued work runs one job at a time to keep the Telegram session stable.

- +
@@ -141,48 +104,104 @@

Continuous Scraping

-

Основной long-running режим: периодически перескрапливает каналы и сохраняет лог цикла.

+

Runs on a timer and keeps a compact, highlighted run log.

- - +
+
Continuous channel set
+
+
+
-
Статус:
-
Последний цикл:
-
Последняя ошибка:
+
Status: -
+
Last run: -
+
Last error: -
-

+            
+ +
+
+
+
Settings
+

Telegram & Scraping

+
+ +
+ +
+
Telegram Login
+
+ + + + + +
+ +
+ + + +
+ + + + + + + + + + + + + +
+ +
+
Scraping
+ +
+
+
+
КаналСообщенийМедиаПоследнее сообщениеДействияChannelMessagesMediaLast messageActions