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 @@
Media scraping
-
+
OFF
Forwarding rules
@@ -98,13 +61,13 @@
@@ -112,11 +75,11 @@
- | Канал |
- Сообщений |
- Медиа |
- Последнее сообщение |
- Действия |
+ Channel |
+ Messages |
+ Media |
+ Last message |
+ Actions |
@@ -127,10 +90,10 @@
@@ -141,48 +104,104 @@
-
+
+
+
|
@@ -194,9 +213,11 @@
| |
-
+
+
+
-
+
|
diff --git a/webui/style.css b/webui/style.css
index 2df08d0..f1e352e 100644
--- a/webui/style.css
+++ b/webui/style.css
@@ -1,16 +1,17 @@
:root {
color-scheme: dark;
- --bg: #0f1723;
- --panel: #17212b;
- --panel-alt: #101922;
- --line: #253241;
- --soft: #8ea2b5;
- --text: #edf3f9;
- --accent: #53a7ff;
- --accent-strong: #2f8cff;
+ --bg-color: #050505;
+ --panel: #0c0c0c;
+ --panel-alt: #080808;
+ --text-color: #e0e0e0;
+ --accent: #ffffff;
+ --dim: #6f6f6f;
+ --line: #2a2a2a;
--danger: #ff6b6b;
- --bubble: #182533;
- --bubble-self: #1f3a4d;
+ --ok: #7ee787;
+ --warn: #f0c674;
+ --info: #8ab4f8;
+ --font-mono: "Courier New", Courier, monospace;
}
* {
@@ -19,14 +20,23 @@
body {
margin: 0;
- font-family: Inter, Arial, sans-serif;
- background: var(--bg);
- color: var(--text);
+ background: var(--bg-color);
+ color: var(--text-color);
+ font-family: var(--font-mono);
+ font-size: 16px;
+ line-height: 1.55;
}
a {
color: inherit;
text-decoration: none;
+ border-bottom: 1px solid var(--dim);
+}
+
+a:hover,
+button:hover {
+ background: var(--text-color);
+ color: var(--bg-color);
}
code,
@@ -42,42 +52,154 @@ button {
display: grid;
}
-.app-shell {
- grid-template-columns: 320px minmax(0, 1fr);
+.app-shell,
+.viewer-shell {
+ grid-template-columns: 300px minmax(0, 1fr);
}
.sidebar,
.viewer-sidebar {
background: var(--panel-alt);
- border-right: 1px solid var(--line);
- padding: 24px;
+ border-right: 1px dashed var(--line);
+ padding: 28px 24px;
}
.content,
.viewer-main {
- padding: 24px;
+ padding: 28px;
+ max-width: 1320px;
+ width: 100%;
+}
+
+.brand-block h1,
+.viewer-sidebar-head h1,
+.viewer-header h2,
+.panel-header h2,
+.dialog-header h2 {
+ margin: 0;
+ color: var(--accent);
+ font-size: 1.45rem;
+ text-transform: uppercase;
+ letter-spacing: 0;
+}
+
+.eyebrow,
+.section-title {
+ color: var(--dim);
+ font-size: 0.78rem;
+ text-transform: uppercase;
+}
+
+.eyebrow::before,
+.section-title::before {
+ content: "./";
+}
+
+.muted {
+ color: var(--dim);
+}
+
+.small {
+ font-size: 0.82rem;
+}
+
+.nav-links,
+.jobs-list,
+.continuous-form,
+.viewer-channel-list {
+ display: grid;
+ gap: 10px;
+}
+
+.nav-links {
+ margin: 28px 0;
+}
+
+.nav-link,
+.button,
+input,
+.content-tab,
+.viewer-channel-item {
+ border: 1px solid var(--line);
+ background: var(--panel);
+ color: var(--text-color);
+}
+
+.nav-link,
+.button,
+.content-tab {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 42px;
+ padding: 9px 13px;
+ cursor: pointer;
+}
+
+.nav-link.active,
+.button.primary,
+.content-tab.active {
+ border-color: var(--accent);
+ color: var(--accent);
+ box-shadow: inset 3px 0 0 var(--accent);
+}
+
+.button.danger {
+ color: var(--danger);
+}
+
+.button.button-small {
+ min-height: 34px;
+ padding: 6px 10px;
+ font-size: 0.82rem;
+}
+
+.button:disabled {
+ cursor: not-allowed;
+ opacity: 0.45;
+}
+
+.status-card,
+.panel,
+.stat-panel,
+.job-card,
+.message-card,
+.settings-dialog {
+ background: var(--panel);
+ border: 1px dashed var(--line);
+}
+
+.status-card,
+.panel,
+.job-card,
+.message-card {
+ padding: 16px;
+}
+
+.status-card {
+ margin-top: 16px;
+}
+
+.status-card .button {
+ width: 100%;
+ margin-top: 8px;
+}
+
+.status-badge {
+ display: inline-flex;
+ padding: 4px 8px;
+ border: 1px solid var(--line);
+ color: var(--ok);
+ text-transform: uppercase;
}
.content-tabs {
display: flex;
+ flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
-.content-tab {
- border: 1px solid var(--line);
- background: var(--panel);
- color: var(--text);
- padding: 10px 14px;
- border-radius: 8px;
- cursor: pointer;
-}
-
-.content-tab.active {
- background: var(--accent-strong);
- border-color: var(--accent-strong);
-}
-
.tab-panel {
display: none;
}
@@ -86,90 +208,6 @@ button {
display: block;
}
-.brand-block h1,
-.viewer-sidebar-head h1,
-.viewer-header h2,
-.panel-header h2 {
- margin: 0;
- font-size: 24px;
-}
-
-.eyebrow {
- color: var(--accent);
- font-size: 12px;
- text-transform: uppercase;
- margin-bottom: 8px;
-}
-
-.muted {
- color: var(--soft);
-}
-
-.small {
- font-size: 12px;
-}
-
-.nav-links {
- display: grid;
- gap: 8px;
- margin: 24px 0;
-}
-
-.nav-link,
-.button {
- border: 1px solid var(--line);
- background: var(--panel);
- color: var(--text);
- padding: 10px 14px;
- border-radius: 8px;
- cursor: pointer;
-}
-
-.nav-link.active,
-.button.primary {
- background: var(--accent-strong);
- border-color: var(--accent-strong);
-}
-
-.button.danger {
- color: #ffd0d0;
-}
-
-.button.button-small {
- padding: 8px 10px;
- font-size: 13px;
-}
-
-.status-card,
-.panel,
-.stat-panel {
- background: var(--panel);
- border: 1px solid var(--line);
- border-radius: 8px;
-}
-
-.status-card {
- padding: 16px;
- margin-top: 16px;
-}
-
-.section-title {
- font-size: 12px;
- text-transform: uppercase;
- color: var(--soft);
- margin-bottom: 12px;
-}
-
-.status-badge {
- display: inline-flex;
- align-items: center;
- border-radius: 999px;
- padding: 8px 12px;
- background: #183248;
- color: #a7d8ff;
- margin-bottom: 10px;
-}
-
.panel-grid {
display: grid;
gap: 16px;
@@ -177,96 +215,36 @@ button {
margin-bottom: 16px;
}
-.stat-panel {
- padding: 18px;
-}
-
-.stat-value {
- font-size: 32px;
- font-weight: 700;
-}
-
-.toggle-row {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 12px;
-}
-
-.switch {
- position: relative;
- display: inline-flex;
- width: 46px;
- min-width: 46px;
- height: 28px;
-}
-
-.switch input {
- position: absolute;
- inset: 0;
- opacity: 0;
- width: 100%;
- height: 100%;
- min-width: 0;
- margin: 0;
- cursor: pointer;
-}
-
-.switch-slider {
- position: absolute;
- inset: 0;
- border-radius: 999px;
- background: #0d141c;
- border: 1px solid var(--line);
- transition: background-color 0.2s ease, border-color 0.2s ease;
-}
-
-.switch-slider::before {
- content: "";
- position: absolute;
- top: 3px;
- left: 3px;
- width: 20px;
- height: 20px;
- border-radius: 50%;
- background: #c9d4df;
- transition: transform 0.2s ease, background-color 0.2s ease;
-}
-
-.switch input:checked + .switch-slider {
- background: var(--accent-strong);
- border-color: var(--accent-strong);
-}
-
-.switch input:checked + .switch-slider::before {
- transform: translateX(18px);
- background: white;
-}
-
-.switch input:focus-visible + .switch-slider {
- outline: 2px solid rgba(83, 167, 255, 0.45);
- outline-offset: 2px;
-}
-
.panel {
- padding: 18px;
margin-bottom: 16px;
}
.panel-header,
.viewer-sidebar-head,
-.viewer-header {
+.viewer-header,
+.dialog-header,
+.message-meta,
+.job-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
- margin-bottom: 16px;
+}
+
+.stat-value {
+ margin-top: 8px;
+ font-size: 2rem;
+ font-weight: 700;
+}
+
+.stat-compact {
+ font-size: 1.3rem;
}
.inline-form {
display: flex;
- gap: 8px;
flex-wrap: wrap;
+ gap: 8px;
}
.stack-form {
@@ -275,39 +253,65 @@ button {
margin-top: 12px;
}
-.auth-actions {
- margin-top: 12px;
-}
-
-.qr-wrap {
- margin-top: 14px;
- padding: 12px;
- border: 1px solid var(--line);
- border-radius: 8px;
- background: #0d141c;
-}
-
-.qr-wrap img {
- display: block;
- width: 100%;
- max-width: 240px;
- aspect-ratio: 1;
- margin: 0 auto 10px;
- border-radius: 8px;
- background: white;
-}
-
-.hidden {
- display: none !important;
-}
-
input {
min-width: 160px;
- padding: 10px 12px;
- border-radius: 8px;
+ padding: 9px 11px;
+ outline: 0;
+}
+
+input:focus {
+ border-color: var(--accent);
+}
+
+.toggle-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.switch {
+ position: relative;
+ display: inline-flex;
+ width: 48px;
+ min-width: 48px;
+ height: 26px;
+}
+
+.switch input {
+ position: absolute;
+ inset: 0;
+ opacity: 0;
+ min-width: 0;
+ margin: 0;
+ cursor: pointer;
+}
+
+.switch-slider {
+ position: absolute;
+ inset: 0;
border: 1px solid var(--line);
- background: #0d141c;
- color: var(--text);
+ background: #101010;
+}
+
+.switch-slider::before {
+ content: "";
+ position: absolute;
+ top: 4px;
+ left: 4px;
+ width: 16px;
+ height: 16px;
+ background: var(--dim);
+ transition: transform 0.16s ease, background-color 0.16s ease;
+}
+
+.switch input:checked + .switch-slider {
+ border-color: var(--accent);
+}
+
+.switch input:checked + .switch-slider::before {
+ transform: translateX(22px);
+ background: var(--accent);
}
.table-wrap {
@@ -321,102 +325,187 @@ table {
th,
td {
+ padding: 12px 10px;
text-align: left;
- border-bottom: 1px solid var(--line);
- padding: 14px 10px;
vertical-align: top;
+ border-bottom: 1px dashed var(--line);
}
tbody tr:hover {
- background: rgba(255, 255, 255, 0.02);
+ background: #101010;
}
.action-row {
display: flex;
- gap: 8px;
flex-wrap: wrap;
-}
-
-.jobs-list {
- display: grid;
- gap: 12px;
-}
-
-.job-card {
- border: 1px solid var(--line);
- border-radius: 8px;
- padding: 14px;
- background: #121c25;
-}
-
-.job-head {
- display: flex;
- justify-content: space-between;
- gap: 12px;
- margin-bottom: 6px;
+ gap: 8px;
}
.job-status {
+ color: var(--dim);
+ font-size: 0.82rem;
text-transform: uppercase;
- font-size: 12px;
- color: var(--soft);
}
.job-logs {
+ max-height: 220px;
margin: 10px 0 0;
- padding: 12px;
- border-radius: 8px;
- background: #0d141c;
- max-height: 240px;
overflow: auto;
white-space: pre-wrap;
-}
-
-.continuous-form {
- display: grid;
- gap: 12px;
+ color: var(--dim);
}
.continuous-meta {
display: grid;
- gap: 6px;
- margin-top: 16px;
+ gap: 4px;
+ margin: 16px 0;
+ color: var(--dim);
+ font-size: 0.88rem;
}
-.viewer-shell {
- grid-template-columns: 320px minmax(0, 1fr);
+.continuous-meta span {
+ color: var(--text-color);
}
-.viewer-channel-list {
+.checkbox-grid {
display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 8px;
+ margin-top: 8px;
+}
+
+.checkbox-row {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ min-height: 38px;
+ padding: 8px;
+ border: 1px dashed var(--line);
+}
+
+.checkbox-row input {
+ min-width: auto;
+}
+
+.checkbox-row.disabled {
+ opacity: 0.45;
+}
+
+.log-viewer {
+ display: grid;
+ max-height: 420px;
+ overflow: auto;
+ border: 1px solid var(--line);
+ background: #030303;
+}
+
+.log-line {
+ display: grid;
+ grid-template-columns: 86px 112px 74px minmax(0, 1fr);
+ gap: 10px;
+ padding: 5px 9px;
+ border-bottom: 1px solid #151515;
+ font-size: 0.88rem;
+}
+
+.log-line:hover {
+ background: #101010;
+}
+
+.log-time,
+.log-age,
+.log-level {
+ color: var(--dim);
+}
+
+.log-message {
+ white-space: pre-wrap;
+ overflow-wrap: anywhere;
+}
+
+.log-line.info .log-level,
+.log-line.info .log-message {
+ color: var(--info);
+}
+
+.log-line.success .log-level,
+.log-line.success .log-message {
+ color: var(--ok);
+}
+
+.log-line.warn .log-level,
+.log-line.warn .log-message {
+ color: var(--warn);
+}
+
+.log-line.error .log-level,
+.log-line.error .log-message {
+ color: var(--danger);
+}
+
+.settings-dialog {
+ width: min(720px, calc(100vw - 32px));
+ color: var(--text-color);
+ padding: 0;
+}
+
+.settings-dialog::backdrop {
+ background: rgba(0, 0, 0, 0.72);
+}
+
+.dialog-shell {
+ padding: 18px;
+}
+
+.settings-section {
+ margin-top: 18px;
+ padding-top: 16px;
+ border-top: 1px dashed var(--line);
+}
+
+.qr-wrap {
+ margin-top: 14px;
+ padding: 12px;
+ border: 1px dashed var(--line);
+}
+
+.qr-wrap img {
+ display: block;
+ width: 100%;
+ max-width: 240px;
+ aspect-ratio: 1;
+ margin: 0 auto 10px;
+ background: white;
+}
+
+.hidden {
+ display: none !important;
}
.viewer-channel-item {
display: grid;
gap: 4px;
width: 100%;
- text-align: left;
padding: 12px;
- border-radius: 8px;
- border: 1px solid var(--line);
- background: var(--panel);
- color: var(--text);
+ text-align: left;
cursor: pointer;
}
.viewer-channel-item.active {
border-color: var(--accent);
- background: #193246;
+ box-shadow: inset 3px 0 0 var(--accent);
}
-.viewer-channel-name {
- font-weight: 600;
+.viewer-channel-name,
+.message-author {
+ color: var(--accent);
+ font-weight: 700;
}
-.viewer-channel-meta {
- font-size: 12px;
- color: var(--soft);
+.viewer-channel-meta,
+.message-date {
+ color: var(--dim);
+ font-size: 0.82rem;
}
.messages-list {
@@ -426,23 +515,7 @@ tbody tr:hover {
}
.message-card {
- max-width: 760px;
- border: 1px solid var(--line);
- border-radius: 12px;
- padding: 14px;
- background: var(--bubble);
-}
-
-.message-meta {
- display: flex;
- justify-content: space-between;
- gap: 12px;
- margin-bottom: 10px;
-}
-
-.message-author {
- color: #8fd3ff;
- font-weight: 600;
+ max-width: 820px;
}
.message-reply:empty,
@@ -454,7 +527,6 @@ tbody tr:hover {
.message-text {
white-space: pre-wrap;
- line-height: 1.45;
}
.message-media {
@@ -463,16 +535,64 @@ tbody tr:hover {
.message-media img,
.message-media video {
- max-width: 100%;
- border-radius: 10px;
- border: 1px solid var(--line);
display: block;
+ max-width: 100%;
+ border: 1px solid var(--line);
}
.message-footer {
margin-top: 10px;
}
+.api-docs {
+ display: grid;
+ gap: 16px;
+}
+
+.api-path {
+ margin: 0 0 12px;
+ color: var(--accent);
+ font-size: 1rem;
+ overflow-wrap: anywhere;
+}
+
+.api-methods {
+ display: grid;
+ gap: 12px;
+}
+
+.api-method {
+ border-top: 1px dashed var(--line);
+ padding-top: 12px;
+}
+
+.api-method:first-child {
+ border-top: 0;
+ padding-top: 0;
+}
+
+.api-method-head {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+.api-verb {
+ min-width: 58px;
+ padding: 2px 6px;
+ border: 1px solid var(--line);
+ color: var(--ok);
+ text-align: center;
+}
+
+.api-summary {
+ font-weight: 700;
+}
+
+.api-body {
+ max-height: none;
+}
+
@media (max-width: 1100px) {
.app-shell,
.viewer-shell {
@@ -482,10 +602,40 @@ tbody tr:hover {
.sidebar,
.viewer-sidebar {
border-right: 0;
- border-bottom: 1px solid var(--line);
+ border-bottom: 1px dashed var(--line);
}
.panel-grid {
grid-template-columns: 1fr;
}
}
+
+@media (max-width: 720px) {
+ .content,
+ .viewer-main,
+ .sidebar,
+ .viewer-sidebar {
+ padding: 18px;
+ }
+
+ .panel-header,
+ .viewer-header,
+ .dialog-header {
+ display: grid;
+ }
+
+ .inline-form,
+ .inline-form input,
+ .inline-form .button {
+ width: 100%;
+ }
+
+ .log-line {
+ grid-template-columns: 76px 1fr;
+ }
+
+ .log-age,
+ .log-level {
+ display: none;
+ }
+}
diff --git a/webui/swagger.html b/webui/swagger.html
new file mode 100644
index 0000000..558c0fe
--- /dev/null
+++ b/webui/swagger.html
@@ -0,0 +1,57 @@
+
+
+
+
+
+ Telegram Scraper API
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/webui/swagger.js b/webui/swagger.js
new file mode 100644
index 0000000..3e64962
--- /dev/null
+++ b/webui/swagger.js
@@ -0,0 +1,53 @@
+async function loadSpec() {
+ const response = await fetch("/openapi.json");
+ const spec = await response.json();
+ if (!response.ok) {
+ throw new Error(spec.error || "Failed to load OpenAPI spec");
+ }
+ return spec;
+}
+
+function methodPayload(operation) {
+ const body = operation.requestBody?.content?.["application/json"]?.schema;
+ if (!body) return "";
+ return JSON.stringify(body.example || body.properties || body, null, 2);
+}
+
+function renderSpec(spec) {
+ const root = document.getElementById("api-docs");
+ const sectionTemplate = document.getElementById("api-section-template");
+ const methodTemplate = document.getElementById("api-method-template");
+ root.innerHTML = "";
+
+ Object.entries(spec.paths || {}).forEach(([path, methods]) => {
+ const section = sectionTemplate.content.firstElementChild.cloneNode(true);
+ section.querySelector(".api-path").textContent = path;
+ const methodsRoot = section.querySelector(".api-methods");
+
+ Object.entries(methods).forEach(([method, operation]) => {
+ const node = methodTemplate.content.firstElementChild.cloneNode(true);
+ node.querySelector(".api-verb").textContent = method.toUpperCase();
+ node.querySelector(".api-summary").textContent = operation.summary || "";
+ node.querySelector(".api-description").textContent = operation.description || "";
+
+ const body = methodPayload(operation);
+ const bodyNode = node.querySelector(".api-body");
+ if (body) {
+ bodyNode.textContent = body;
+ } else {
+ bodyNode.remove();
+ }
+
+ methodsRoot.appendChild(node);
+ });
+
+ root.appendChild(section);
+ });
+}
+
+loadSpec()
+ .then(renderSpec)
+ .catch((error) => {
+ console.error(error);
+ document.getElementById("api-docs").innerHTML = ``;
+ });
diff --git a/webui/viewer.html b/webui/viewer.html
index 7ab5a0f..0789e86 100644
--- a/webui/viewer.html
+++ b/webui/viewer.html
@@ -1,5 +1,5 @@
-
+
@@ -12,9 +12,9 @@
@@ -22,11 +22,11 @@
diff --git a/webui/viewer.js b/webui/viewer.js
index b830802..7db2adb 100644
--- a/webui/viewer.js
+++ b/webui/viewer.js
@@ -30,7 +30,7 @@ function renderChannelList() {
viewerState.channels.forEach((channel) => {
const node = template.content.firstElementChild.cloneNode(true);
node.querySelector(".viewer-channel-name").textContent = channel.name;
- node.querySelector(".viewer-channel-meta").textContent = `${channel.message_count} сообщений`;
+ node.querySelector(".viewer-channel-meta").textContent = `${channel.message_count} messages`;
if (channel.channel_id === viewerState.channelId) {
node.classList.add("active");
}
@@ -50,8 +50,8 @@ function renderMessages(payload, append = false) {
const channel = payload.channel;
document.getElementById("viewer-title").textContent = channel?.name || payload.channel_id;
document.getElementById("viewer-subtitle").textContent = channel
- ? `${channel.message_count} сообщений, последнее: ${channel.last_date || "—"}`
- : "База данных для канала пока не найдена.";
+ ? `${channel.message_count} messages, latest: ${channel.last_date || "-"}`
+ : "No local database found for this channel yet.";
payload.messages.forEach((message) => {
const node = template.content.firstElementChild.cloneNode(true);
@@ -61,7 +61,7 @@ function renderMessages(payload, append = false) {
const reply = node.querySelector(".message-reply");
if (message.reply_to) {
- reply.textContent = `Ответ на сообщение #${message.reply_to}`;
+ reply.textContent = `Reply to message #${message.reply_to}`;
}
node.querySelector(".message-text").appendChild(textNodeWithBreaks(message.text || ""));
@@ -83,7 +83,7 @@ function renderMessages(payload, append = false) {
link.href = message.media_url;
link.target = "_blank";
link.rel = "noreferrer";
- link.textContent = "Открыть файл";
+ link.textContent = "Open file";
media.appendChild(link);
}
diff --git a/webui_server.py b/webui_server.py
index 9fa86d2..3266148 100644
--- a/webui_server.py
+++ b/webui_server.py
@@ -493,11 +493,11 @@ class TelegramAuthManager:
class ContinuousScrapeManager:
def __init__(self) -> None:
- self.lock = threading.Lock()
+ self.lock = threading.RLock()
self.thread: Optional[threading.Thread] = None
self.stop_event = threading.Event()
self.config: Dict[str, Any] = {
- "enabled": False,
+ "enabled": True,
"interval_minutes": 1,
"channels": [],
"run_all_tracked": True,
@@ -509,15 +509,22 @@ class ContinuousScrapeManager:
"last_iteration_at": None,
"last_error": None,
"logs": [],
+ "log_entries": [],
}
- def _log(self, message: str) -> None:
+ def _log(self, message: str, level: str = "debug") -> None:
+ timestamp = utc_now_iso()
line = f"[{datetime.now().strftime('%H:%M:%S')}] {message}"
+ entry = {"timestamp": timestamp, "level": level, "message": message}
with self.lock:
logs = self.status.setdefault("logs", [])
+ log_entries = self.status.setdefault("log_entries", [])
logs.append(line)
- if len(logs) > 200:
- del logs[:-200]
+ log_entries.append(entry)
+ if len(logs) > 300:
+ del logs[:-300]
+ if len(log_entries) > 300:
+ del log_entries[:-300]
def snapshot(self) -> Dict[str, Any]:
with self.lock:
@@ -526,6 +533,7 @@ class ContinuousScrapeManager:
"status": {
**self.status,
"logs": list(self.status.get("logs", [])),
+ "log_entries": list(self.status.get("log_entries", [])),
},
}
@@ -553,7 +561,7 @@ class ContinuousScrapeManager:
def start(self) -> None:
with self.lock:
if self.status["running"]:
- self._log("Continuous scraping is already running.")
+ self._log("Continuous scraping is already running.", "warn")
return
self.stop_event.clear()
self.status["running"] = True
@@ -561,7 +569,7 @@ class ContinuousScrapeManager:
self.status["last_error"] = None
self.thread = threading.Thread(target=self._run_loop, daemon=True)
self.thread.start()
- self._log("Continuous scraping started.")
+ self._log("Continuous scraping started.", "info")
def stop(self) -> None:
self.stop_event.set()
@@ -569,14 +577,14 @@ class ContinuousScrapeManager:
was_running = self.status["running"]
self.status["running"] = False
if was_running:
- self._log("Continuous scraping stop requested.")
+ self._log("Continuous scraping stop requested.", "warn")
def _resolve_channels(self) -> List[str]:
state = load_state()
with self.lock:
run_all_tracked = self.config["run_all_tracked"]
configured = list(self.config["channels"])
- if run_all_tracked or not configured:
+ if run_all_tracked:
return list(state.get("channels", {}).keys())
tracked = set(state.get("channels", {}).keys())
return [channel for channel in configured if channel in tracked]
@@ -587,11 +595,9 @@ class ContinuousScrapeManager:
interval_minutes = self.snapshot()["config"]["interval_minutes"]
if not channels:
- self._log("No channels configured for continuous scraping.")
+ self._log("No channels configured for continuous scraping.", "warn")
else:
- self._log("=" * 50)
- self._log(f"🚀 Starting iteration for {len(channels)} channel(s).")
- self._log("-" * 50)
+ self._log(f"Starting iteration for {len(channels)} channel(s).", "info")
buffer = io.StringIO()
try:
with (
@@ -607,26 +613,25 @@ class ContinuousScrapeManager:
self.status["last_iteration_at"] = utc_now_iso()
self.status["last_finished_at"] = utc_now_iso()
self.status["last_error"] = None
- self._log("-" * 50)
- self._log("✅ Iteration finished.")
+ self._log("Iteration finished.", "success")
except Exception as exc:
output = buffer.getvalue().strip()
if output:
for line in output.splitlines():
self._log(line)
- self._log(f"Iteration failed: {exc}")
+ self._log(f"Iteration failed: {exc}", "error")
with self.lock:
self.status["last_error"] = str(exc)
sleep_seconds = max(5, interval_minutes * 60)
- self._log(f"💤 Sleeping for {interval_minutes} minute(s).")
+ self._log(f"Sleeping for {interval_minutes} minute(s).", "debug")
interrupted = self.stop_event.wait(timeout=sleep_seconds)
if interrupted:
break
with self.lock:
self.status["running"] = False
- self._log("Continuous scraping stopped.")
+ self._log("Continuous scraping stopped.", "warn")
def import_scraper_class():
@@ -742,6 +747,287 @@ def dashboard_payload(job_runner: JobRunner) -> Dict[str, Any]:
}
+def openapi_payload() -> Dict[str, Any]:
+ json_response = {
+ "200": {
+ "description": "JSON response",
+ "content": {"application/json": {"schema": {"type": "object"}}},
+ }
+ }
+ accepted_response = {
+ "202": {
+ "description": "Job accepted",
+ "content": {"application/json": {"schema": {"type": "object"}}},
+ }
+ }
+ error_response = {"400": {"description": "Bad request"}}
+
+ def json_body(properties: Dict[str, Any], required: Optional[List[str]] = None):
+ return {
+ "required": True,
+ "content": {
+ "application/json": {
+ "schema": {
+ "type": "object",
+ "properties": properties,
+ "required": required or [],
+ "example": {
+ key: value.get("example")
+ for key, value in properties.items()
+ if "example" in value
+ },
+ }
+ }
+ },
+ }
+
+ return {
+ "openapi": "3.0.3",
+ "info": {
+ "title": "Telegram Scraper Web UI API",
+ "version": "0.1.0",
+ "description": "Local endpoints used by the Telegram Scraper web interface.",
+ },
+ "servers": [{"url": "/"}],
+ "paths": {
+ "/api/dashboard": {
+ "get": {
+ "summary": "Dashboard snapshot",
+ "description": "Returns state summary, channels, auth status, and recent jobs.",
+ "responses": json_response,
+ }
+ },
+ "/api/auth": {
+ "get": {
+ "summary": "Authentication status",
+ "description": "Returns saved credential flags, login phase, and QR login data.",
+ "responses": json_response,
+ }
+ },
+ "/api/auth/credentials": {
+ "post": {
+ "summary": "Save Telegram API credentials",
+ "requestBody": json_body(
+ {
+ "api_id": {"type": "integer", "example": 123456},
+ "api_hash": {"type": "string", "example": "0123456789abcdef"},
+ },
+ ["api_id", "api_hash"],
+ ),
+ "responses": {**json_response, **error_response},
+ }
+ },
+ "/api/auth/qr/start": {
+ "post": {
+ "summary": "Start QR login",
+ "requestBody": json_body({}),
+ "responses": {**json_response, **error_response},
+ }
+ },
+ "/api/auth/phone/request": {
+ "post": {
+ "summary": "Request phone login code",
+ "requestBody": json_body(
+ {"phone": {"type": "string", "example": "+1234567890"}},
+ ["phone"],
+ ),
+ "responses": {**json_response, **error_response},
+ }
+ },
+ "/api/auth/phone/submit": {
+ "post": {
+ "summary": "Submit phone login code",
+ "requestBody": json_body(
+ {"code": {"type": "string", "example": "12345"}},
+ ["code"],
+ ),
+ "responses": {**json_response, **error_response},
+ }
+ },
+ "/api/auth/password": {
+ "post": {
+ "summary": "Submit 2FA password",
+ "requestBody": json_body(
+ {"password": {"type": "string", "example": "password"}},
+ ["password"],
+ ),
+ "responses": {**json_response, **error_response},
+ }
+ },
+ "/api/continuous": {
+ "get": {
+ "summary": "Continuous scraping status",
+ "responses": json_response,
+ },
+ "post": {
+ "summary": "Update continuous scraping",
+ "requestBody": json_body(
+ {
+ "enabled": {"type": "boolean", "example": True},
+ "interval_minutes": {"type": "integer", "example": 5},
+ "run_all_tracked": {"type": "boolean", "example": False},
+ "channels": {
+ "type": "array",
+ "items": {"type": "string"},
+ "example": ["-1001234567890"],
+ },
+ }
+ ),
+ "responses": {**json_response, **error_response},
+ },
+ },
+ "/health/continuous": {
+ "get": {
+ "summary": "Continuous scraping health",
+ "responses": json_response,
+ }
+ },
+ "/api/channels": {
+ "get": {
+ "summary": "List tracked channels",
+ "responses": json_response,
+ }
+ },
+ "/api/channels/{channel_id}/messages": {
+ "get": {
+ "summary": "List channel messages",
+ "parameters": [
+ {
+ "name": "channel_id",
+ "in": "path",
+ "required": True,
+ "schema": {"type": "string"},
+ },
+ {
+ "name": "limit",
+ "in": "query",
+ "schema": {"type": "integer", "default": 120, "maximum": 300},
+ },
+ {
+ "name": "before",
+ "in": "query",
+ "schema": {"type": "integer"},
+ },
+ ],
+ "responses": json_response,
+ }
+ },
+ "/api/channels/add": {
+ "post": {
+ "summary": "Track a channel",
+ "requestBody": json_body(
+ {
+ "channel_id": {"type": "string", "example": "-1001234567890"},
+ "name": {"type": "string", "example": "Research feed"},
+ },
+ ["channel_id"],
+ ),
+ "responses": {**json_response, **error_response},
+ }
+ },
+ "/api/channels/remove": {
+ "post": {
+ "summary": "Remove a tracked channel",
+ "requestBody": json_body(
+ {"channel_id": {"type": "string", "example": "-1001234567890"}},
+ ["channel_id"],
+ ),
+ "responses": {**json_response, **error_response},
+ }
+ },
+ "/api/settings/media": {
+ "post": {
+ "summary": "Toggle media scraping",
+ "requestBody": json_body(
+ {"value": {"type": "boolean", "example": True}},
+ ["value"],
+ ),
+ "responses": {**accepted_response, **error_response},
+ }
+ },
+ "/api/jobs": {
+ "get": {
+ "summary": "Recent jobs",
+ "responses": json_response,
+ }
+ },
+ "/api/jobs/{job_id}": {
+ "get": {
+ "summary": "Job details",
+ "parameters": [
+ {
+ "name": "job_id",
+ "in": "path",
+ "required": True,
+ "schema": {"type": "string"},
+ }
+ ],
+ "responses": {**json_response, "404": {"description": "Job not found"}},
+ }
+ },
+ "/api/jobs/scrape": {
+ "post": {
+ "summary": "Scrape one channel or all tracked channels",
+ "requestBody": json_body(
+ {"channel_id": {"type": "string", "example": "-1001234567890"}}
+ ),
+ "responses": accepted_response,
+ }
+ },
+ "/api/jobs/export": {
+ "post": {
+ "summary": "Export all tracked channels",
+ "requestBody": json_body({}),
+ "responses": accepted_response,
+ }
+ },
+ "/api/jobs/export-channel": {
+ "post": {
+ "summary": "Export one channel",
+ "requestBody": json_body(
+ {"channel_id": {"type": "string", "example": "-1001234567890"}},
+ ["channel_id"],
+ ),
+ "responses": {**accepted_response, **error_response},
+ }
+ },
+ "/api/jobs/rescrape-media": {
+ "post": {
+ "summary": "Rescrape media for a channel",
+ "requestBody": json_body(
+ {"channel_id": {"type": "string", "example": "-1001234567890"}},
+ ["channel_id"],
+ ),
+ "responses": {**accepted_response, **error_response},
+ }
+ },
+ "/api/jobs/fix-media": {
+ "post": {
+ "summary": "Fix missing media for a channel",
+ "requestBody": json_body(
+ {"channel_id": {"type": "string", "example": "-1001234567890"}},
+ ["channel_id"],
+ ),
+ "responses": {**accepted_response, **error_response},
+ }
+ },
+ "/api/jobs/refresh-dialogs": {
+ "post": {
+ "summary": "Refresh Telegram dialogs",
+ "requestBody": json_body({}),
+ "responses": accepted_response,
+ }
+ },
+ "/openapi.json": {
+ "get": {
+ "summary": "OpenAPI document",
+ "responses": json_response,
+ }
+ },
+ },
+ }
+
+
class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
server_version = "TelegramScraperWebUI/0.1"
@@ -760,12 +1046,18 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
return self.serve_file(
WEBUI_DIR / "viewer.html", "text/html; charset=utf-8"
)
+ if path in {"/swagger", "/swigger", "/docs"}:
+ return self.serve_file(
+ WEBUI_DIR / "swagger.html", "text/html; charset=utf-8"
+ )
if path.startswith("/static/"):
relative = path.removeprefix("/static/")
return self.serve_static(relative)
if path.startswith("/media/"):
relative = path.removeprefix("/media/")
return self.serve_media(relative)
+ if path == "/openapi.json":
+ return self.send_json(openapi_payload())
if path == "/api/dashboard":
return self.send_json(dashboard_payload(self.app.job_runner))
if path == "/api/auth":
@@ -819,6 +1111,12 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
return self.serve_file(
WEBUI_DIR / "viewer.html", "text/html; charset=utf-8", head_only=True
)
+ if path in {"/swagger", "/swigger", "/docs"}:
+ return self.serve_file(
+ WEBUI_DIR / "swagger.html",
+ "text/html; charset=utf-8",
+ head_only=True,
+ )
if path.startswith("/static/"):
relative = path.removeprefix("/static/")
return self.serve_static(relative, head_only=True)
@@ -834,6 +1132,7 @@ class TelegramScraperRequestHandler(BaseHTTPRequestHandler):
"/api/jobs",
"/api/channels",
"/health/continuous",
+ "/openapi.json",
}
or path.startswith("/api/jobs/")
or (path.startswith("/api/channels/") and path.endswith("/messages"))
@@ -1098,6 +1397,7 @@ class TelegramScraperWebServer(ThreadingHTTPServer):
self.job_runner = JobRunner()
self.auth_manager = TelegramAuthManager()
self.continuous_manager = ContinuousScrapeManager()
+ self.continuous_manager.start()
def run_server(host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> None: