Files
telegram-scraper/webui/app.js
T
forust 62a6a492e0 feat: add per-account web UI with account tabs
- Account tab bar with tab switching (loadAccounts, switchAccount)
- Per-account dashboard panel showing auth status, channels, jobs, continuous
- Settings dialog with account management (add/delete accounts, credentials)
- Auth forms per account (QR, phone, password, set credentials)
- All action buttons with error handling (alert on failure)
- AMOLED dark theme with Telegram-style message bubbles
- Account tab and account list CSS styles
2026-06-27 15:20:49 +02:00

777 lines
26 KiB
JavaScript

/* ── State ───────────────────────────────────────────── */
const state = {
accounts: [],
activeAccount: null,
dashboards: {},
continuous: {},
channels: {},
jobs: {},
authStates: {},
};
/* ── API helper ─────────────────────────────────────── */
async function api(path, options = {}) {
const response = await fetch(path, {
headers: { "Content-Type": "application/json" },
...options,
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || "Request failed");
}
return data;
}
/* ── Format helpers ─────────────────────────────────── */
function formatDate(value) {
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;
}
function confirmAction(message) {
return window.confirm(message);
}
function isAccountAuthorized(auth) {
const status = auth?.auth_status || auth || {};
return (
auth?.phase === "authorized" ||
auth?.status === "authorized" ||
status.status === "ready" ||
status.status === "authorized"
);
}
/* ── Account tab management ─────────────────────────── */
function renderAccountTabs(accounts) {
const container = document.getElementById("account-tabs");
const template = document.getElementById("account-tab-template");
container.innerHTML = "";
accounts.forEach((acc) => {
const node = template.content.firstElementChild.cloneNode(true);
node.dataset.accountId = acc.id;
node.querySelector(".account-tab-name").textContent = acc.label || acc.id;
const statusEl = node.querySelector(".account-tab-status");
if (acc.auth) {
const authOk = isAccountAuthorized(acc.auth);
statusEl.textContent = authOk ? "●" : "○";
statusEl.className = "account-tab-status " + (authOk ? "ok" : "");
}
if (acc.id === state.activeAccount) {
node.classList.add("active");
}
node.addEventListener("click", () => switchAccount(acc.id));
container.appendChild(node);
});
}
function renderAccountPanel(accountId) {
const container = document.getElementById("account-panels");
const template = document.getElementById("account-panel-template");
// Don't duplicate
if (document.getElementById(`panel-${accountId}`)) return;
const node = template.content.firstElementChild.cloneNode(true);
node.id = `panel-${accountId}`;
node.dataset.accountId = accountId;
// Add/remove channel form
const addForm = node.querySelector(".add-channel-form");
addForm.addEventListener("submit", async (event) => {
event.preventDefault();
const channelId = addForm.querySelector(".add-channel-id").value.trim();
const name = addForm.querySelector(".add-channel-name").value.trim();
if (!channelId) return;
await api(`/api/accounts/${accountId}/channels/add`, {
method: "POST",
body: JSON.stringify({ channel_id: channelId, name }),
});
addForm.reset();
await refreshAccount(accountId);
});
// Continuous form
const contForm = node.querySelector(".continuous-form");
contForm.addEventListener("submit", async (event) => {
event.preventDefault();
const enabled = contForm.querySelector(".continuous-enabled").checked;
const intervalMinutes = contForm.querySelector(".continuous-interval").value;
const runAllTracked = contForm.querySelector(".continuous-all").checked;
const channels = Array.from(contForm.querySelectorAll(".continuous-channel-checkbox:checked")).map(
(item) => item.value
);
if (!runAllTracked && channels.length === 0 && enabled) {
if (!confirmAction("Continuous scraping enabled with no selected channels. Save anyway?")) return;
}
await api(`/api/accounts/${accountId}/continuous`, {
method: "POST",
body: JSON.stringify({ enabled, interval_minutes: intervalMinutes, run_all_tracked: runAllTracked, channels }),
});
await refreshAccount(accountId);
});
// "All tracked" checkbox toggles individual checkboxes
const contAll = contForm.querySelector(".continuous-all");
contAll.addEventListener("change", () => {
const checkboxes = contForm.querySelectorAll(".continuous-channel-checkbox");
checkboxes.forEach((cb) => { cb.disabled = contAll.checked; });
});
// Content tabs within account panel
const contentTabs = node.querySelectorAll(".content-tab");
contentTabs.forEach((tab) => {
tab.addEventListener("click", () => {
const targetId = tab.dataset.tabTarget;
contentTabs.forEach((t) => t.classList.toggle("active", t === tab));
node.querySelectorAll(".tab-panel").forEach((p) => {
p.classList.toggle("active", p.dataset.panel === targetId);
});
});
});
// Refresh jobs
node.querySelector(".refresh-jobs-btn").addEventListener("click", () => refreshAccount(accountId));
container.appendChild(node);
}
function switchAccount(accountId) {
if (state.activeAccount === accountId) return;
state.activeAccount = accountId;
// Update tabs
document.querySelectorAll(".account-tab").forEach((tab) => {
tab.classList.toggle("active", tab.dataset.accountId === accountId);
});
// Show/hide panels
document.querySelectorAll(".account-panel").forEach((panel) => {
panel.classList.toggle("hidden", panel.dataset.accountId !== accountId);
});
// Ensure panel exists
if (!document.getElementById(`panel-${accountId}`)) {
renderAccountPanel(accountId);
}
// Update sidebar
updateSidebarAccount();
// Refresh data
refreshAccount(accountId);
// Update settings auth section
updateAuthSection(accountId);
}
function updateSidebarAccount() {
const acc = state.accounts.find((a) => a.id === state.activeAccount);
const nameEl = document.getElementById("active-account-name");
const statusEl = document.getElementById("active-account-status");
if (acc) {
nameEl.textContent = acc.label || acc.id;
const authOk = isAccountAuthorized(acc.auth);
statusEl.textContent = authOk ? "Authorized session" : "Needs login";
statusEl.style.color = authOk ? "var(--ok)" : "var(--danger)";
} else {
nameEl.textContent = "None";
statusEl.textContent = "Add an account in Settings";
statusEl.style.color = "var(--dim)";
}
}
/* ── Dashboard rendering ────────────────────────────── */
function renderChannels(accountId, channels) {
const panel = document.getElementById(`panel-${accountId}`);
if (!panel) return;
const tbody = panel.querySelector(".channels-table");
const template = document.getElementById("channel-row-template");
tbody.innerHTML = "";
channels.forEach((channel) => {
const node = template.content.firstElementChild.cloneNode(true);
node.querySelector(".channel-name").textContent = channel.name;
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(".scrape-btn").addEventListener("click", async () => {
await api(`/api/accounts/${accountId}/jobs/scrape`, {
method: "POST",
body: JSON.stringify({ channel_id: channel.channel_id }),
});
await refreshAccount(accountId);
});
node.querySelector(".export-btn").addEventListener("click", async () => {
await api(`/api/accounts/${accountId}/jobs/export-channel`, {
method: "POST",
body: JSON.stringify({ channel_id: channel.channel_id }),
});
await refreshAccount(accountId);
});
node.querySelector(".export-view-btn").addEventListener("click", () => {
window.location.href = `/viewer?channel=${encodeURIComponent(channel.channel_id)}&account=${encodeURIComponent(accountId)}`;
});
node.querySelector(".media-btn").addEventListener("click", async () => {
await api(`/api/accounts/${accountId}/jobs/rescrape-media`, {
method: "POST",
body: JSON.stringify({ channel_id: channel.channel_id }),
});
await refreshAccount(accountId);
});
node.querySelector(".remove-btn").addEventListener("click", async () => {
if (!confirmAction(`Remove ${channel.name} from tracked channels?`)) return;
await api(`/api/accounts/${accountId}/channels/remove`, {
method: "POST",
body: JSON.stringify({ channel_id: channel.channel_id }),
});
await refreshAccount(accountId);
});
tbody.appendChild(node);
});
// Update channel count stat
panel.querySelector(".channel-count").textContent = String(channels.length);
}
function renderJobs(accountId, jobs) {
const panel = document.getElementById(`panel-${accountId}`);
if (!panel) return;
const root = panel.querySelector(".jobs-list");
const template = document.getElementById("job-template");
root.innerHTML = "";
if (!jobs.length) {
root.innerHTML = '<div class="muted">No jobs yet.</div>';
return;
}
jobs.forEach((job) => {
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 = [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 renderContinuous(accountId, data) {
const panel = document.getElementById(`panel-${accountId}`);
if (!panel) return;
const config = data.config || {};
const status = data.status || {};
const contForm = panel.querySelector(".continuous-form");
contForm.querySelector(".continuous-enabled").checked = Boolean(config.enabled);
contForm.querySelector(".continuous-interval").value = config.interval_minutes || 1;
contForm.querySelector(".continuous-all").checked = Boolean(config.run_all_tracked);
// Channel picker
const chList = panel.querySelector(".continuous-channel-list");
const channels = state.channels[accountId] || [];
const runAll = Boolean(config.run_all_tracked);
const selected = new Set(config.channels || []);
chList.innerHTML = "";
channels.forEach((ch) => {
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 = ch.channel_id;
checkbox.checked = runAll || selected.has(ch.channel_id);
checkbox.disabled = runAll;
const text = document.createElement("span");
text.textContent = `${ch.name} (${ch.channel_id})`;
label.append(checkbox, text);
chList.appendChild(label);
});
// Meta
panel.querySelector(".continuous-status").textContent = status.running ? "running" : "stopped";
panel.querySelector(".continuous-last-iteration").textContent = status.last_iteration_at
? `${relativeTime(status.last_iteration_at)} (${displayTime(status.last_iteration_at)})`
: "-";
panel.querySelector(".continuous-last-error").textContent = status.last_error || "-";
// Logs
const logsRoot = panel.querySelector(".continuous-logs");
const entries = (status.log_entries || status.logs || []).map(normalizeLogEntry);
logsRoot.innerHTML = "";
if (!entries.length) {
logsRoot.innerHTML = '<div class="log-line"><span class="log-time">-</span><span class="log-level">INFO</span><span class="log-message">No logs yet.</span></div>';
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);
logsRoot.appendChild(row);
});
}
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 renderSummary(accountId, data) {
const panel = document.getElementById(`panel-${accountId}`);
if (!panel) return;
const d = data.dashboard || data.state || {};
panel.querySelector(".forwarding-count").textContent = String((d.forwarding_rules || []).length);
const toggle = panel.querySelector(".scrape-media-label");
toggle.textContent = d.scrape_media ? "ON" : "OFF";
}
/* ── Account data loading ───────────────────────────── */
async function refreshAccount(accountId) {
try {
const [dashboard, authData, continuousData] = await Promise.all([
api(`/api/accounts/${accountId}`),
api(`/api/accounts/${accountId}/auth`).catch(() => ({})),
api(`/api/accounts/${accountId}/continuous`).catch(() => ({})),
]);
state.dashboards[accountId] = dashboard;
state.continuous[accountId] = continuousData;
state.authStates[accountId] = authData;
state.channels[accountId] = dashboard.channels || [];
renderSummary(accountId, dashboard);
renderChannels(accountId, dashboard.channels || []);
renderJobs(accountId, dashboard.jobs || []);
renderContinuous(accountId, continuousData);
// Update tab status indicator
const tab = document.querySelector(`.account-tab[data-account-id="${accountId}"]`);
if (tab) {
const statusEl = tab.querySelector(".account-tab-status");
const authOk = isAccountAuthorized(authData);
statusEl.textContent = authOk ? "●" : "○";
statusEl.className = "account-tab-status " + (authOk ? "ok" : "");
}
} catch (err) {
console.error(`Failed to refresh account ${accountId}:`, err);
}
}
async function loadAccounts() {
let accounts = [];
try {
const resp = await api("/api/accounts");
accounts = resp.accounts || [];
} catch {
// Legacy fallback — check /api/auth
try {
const legacy = await api("/api/auth");
if (legacy.saved_credentials?.api_id) {
accounts = [{ id: "default", label: "Default", auth: legacy.auth_status || legacy }];
}
} catch {
// No accounts at all
}
}
state.accounts = accounts;
if (accounts.length === 0) {
document.getElementById("no-accounts-msg").classList.remove("hidden");
return;
}
document.getElementById("no-accounts-msg").classList.add("hidden");
// Render tabs
renderAccountTabs(accounts);
// Render panels for each account
accounts.forEach((acc) => renderAccountPanel(acc.id));
// Determine active account
const activeId = state.activeAccount || accounts[0].id;
switchAccount(activeId);
// Update settings
renderSettingsAccounts();
}
/* ── Settings: Accounts list ────────────────────────── */
function renderSettingsAccounts() {
const container = document.getElementById("accounts-list");
const template = document.getElementById("account-list-item-template");
container.innerHTML = "";
state.accounts.forEach((acc) => {
const node = template.content.firstElementChild.cloneNode(true);
node.querySelector(".account-list-label").textContent = acc.label || acc.id;
node.querySelector(".account-list-id").textContent = acc.id;
const authStatus = node.querySelector(".account-list-auth-status");
const authOk = isAccountAuthorized(acc.auth);
authStatus.textContent = authOk ? "Authorized" : "Needs auth";
authStatus.style.color = authOk ? "var(--ok)" : "var(--danger)";
authStatus.style.fontSize = "0.78rem";
node.querySelector(".account-select-btn").addEventListener("click", () => {
switchAccount(acc.id);
document.getElementById("settings-dialog").close();
});
node.querySelector(".account-remove-btn").addEventListener("click", async () => {
if (!confirmAction(`Remove account "${acc.label || acc.id}"? All its data will be deleted.`)) return;
try {
await api(`/api/accounts/${acc.id}`, { method: "DELETE" });
await loadAccounts();
if (state.activeAccount === acc.id) {
state.activeAccount = null;
}
} catch (err) {
alert(`Failed to remove account: ${err.message}`);
}
});
container.appendChild(node);
});
}
/* ── Auth (operates on active account) ───────────────── */
function updateAuthSection(accountId) {
const label = document.getElementById("auth-account-label");
const acc = state.accounts.find((a) => a.id === accountId);
label.textContent = acc ? (acc.label || acc.id) : "-";
}
async function saveCredentials(accountId) {
const apiId = document.getElementById("api-id-input").value.trim();
const apiHash = document.getElementById("api-hash-input").value.trim();
if (!apiId || !apiHash) return;
await api(`/api/accounts/${accountId}/auth/credentials`, {
method: "POST",
body: JSON.stringify({ api_id: apiId, api_hash: apiHash }),
});
await loadAccounts();
}
async function startQrLogin(accountId) {
const data = await api(`/api/accounts/${accountId}/auth/qr/start`, {
method: "POST",
body: JSON.stringify({}),
});
if (data.qr_image) {
document.getElementById("qr-image").src = data.qr_image;
document.getElementById("qr-wrap").classList.remove("hidden");
}
await loadAccounts();
}
async function requestPhoneCode(accountId) {
const phone = document.getElementById("phone-input").value.trim();
if (!phone) return;
await api(`/api/accounts/${accountId}/auth/phone/request`, {
method: "POST",
body: JSON.stringify({ phone }),
});
await loadAccounts();
}
async function submitPhoneCode(accountId) {
const code = document.getElementById("code-input").value.trim();
if (!code) return;
await api(`/api/accounts/${accountId}/auth/phone/submit`, {
method: "POST",
body: JSON.stringify({ code }),
});
document.getElementById("code-input").value = "";
await loadAccounts();
}
async function submitPassword(accountId) {
const password = document.getElementById("password-input").value.trim();
if (!password) return;
await api(`/api/accounts/${accountId}/auth/password`, {
method: "POST",
body: JSON.stringify({ password }),
});
document.getElementById("password-input").value = "";
await loadAccounts();
}
/* ── Scrape / Export all (for active account) ───────── */
async function scrapeAll() {
if (!state.activeAccount) return;
if (!confirmAction("Queue scraping for all tracked channels?")) return;
await api(`/api/accounts/${state.activeAccount}/jobs/scrape`, {
method: "POST",
body: JSON.stringify({}),
});
await refreshAccount(state.activeAccount);
}
async function exportAll() {
if (!state.activeAccount) return;
if (!confirmAction("Queue export for all tracked channels?")) return;
await api(`/api/accounts/${state.activeAccount}/jobs/export`, {
method: "POST",
body: JSON.stringify({}),
});
await refreshAccount(state.activeAccount);
}
async function refreshDialogs() {
if (!state.activeAccount) return;
await api(`/api/accounts/${state.activeAccount}/jobs/refresh-dialogs`, {
method: "POST",
body: JSON.stringify({}),
});
await refreshAccount(state.activeAccount);
}
async function toggleMedia(value) {
if (!state.activeAccount) return;
await api(`/api/accounts/${state.activeAccount}/settings/media`, {
method: "POST",
body: JSON.stringify({ value }),
});
await refreshAccount(state.activeAccount);
}
/* ── Main ───────────────────────────────────────────── */
async function main() {
// ── Sidebar action buttons ──
document.getElementById("scrape-all-btn").addEventListener("click", async () => {
try { await scrapeAll(); } catch (err) { alert("Scrape error: " + err.message); }
});
document.getElementById("export-all-btn").addEventListener("click", async () => {
try { await exportAll(); } catch (err) { alert("Export error: " + err.message); }
});
document.getElementById("refresh-dialogs-btn").addEventListener("click", async () => {
try { await refreshDialogs(); } catch (err) { alert("Dialog refresh error: " + err.message); }
});
// ── Settings dialog ──
const settingsDialog = document.getElementById("settings-dialog");
document.getElementById("open-settings-btn").addEventListener("click", () => {
// Update auth section for active account
if (state.activeAccount) {
updateAuthSection(state.activeAccount);
}
renderSettingsAccounts();
settingsDialog.showModal();
});
document.getElementById("close-settings-btn").addEventListener("click", () => {
settingsDialog.close();
});
// ── Add account form ──
document.getElementById("add-account-form").addEventListener("submit", async (event) => {
event.preventDefault();
const accountId = document.getElementById("add-account-id").value.trim();
const label = document.getElementById("add-account-label").value.trim();
const apiId = document.getElementById("add-account-api-id").value.trim();
const apiHash = document.getElementById("add-account-api-hash").value.trim();
if (!accountId) return;
try {
await api("/api/accounts", {
method: "POST",
body: JSON.stringify({
account_id: accountId,
label: label || accountId,
api_id: apiId ? parseInt(apiId) : undefined,
api_hash: apiHash || undefined,
}),
});
event.target.reset();
await loadAccounts();
if (!state.activeAccount) {
switchAccount(accountId);
}
} catch (err) {
alert("Failed to add account: " + err.message);
}
});
// ── Credentials form ──
document.getElementById("credentials-form").addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.activeAccount) return;
try {
await saveCredentials(state.activeAccount);
} catch (err) {
alert("Failed to save credentials: " + err.message);
}
});
// ── QR login ──
document.getElementById("start-qr-btn").addEventListener("click", async (event) => {
setBusy(event.currentTarget, true);
try {
if (!state.activeAccount) return;
await startQrLogin(state.activeAccount);
} catch (err) {
alert("QR login error: " + err.message);
} finally {
setBusy(event.currentTarget, false);
}
});
// ── Phone ──
document.getElementById("phone-form").addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.activeAccount) return;
try {
await requestPhoneCode(state.activeAccount);
} catch (err) {
alert("Phone code request error: " + err.message);
}
});
// ── Code ──
document.getElementById("code-form").addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.activeAccount) return;
try {
await submitPhoneCode(state.activeAccount);
} catch (err) {
alert("Code submit error: " + err.message);
}
});
// ── Password ──
document.getElementById("password-form").addEventListener("submit", async (event) => {
event.preventDefault();
if (!state.activeAccount) return;
try {
await submitPassword(state.activeAccount);
} catch (err) {
alert("Password error: " + err.message);
}
});
// ── Media toggle ──
document.getElementById("scrape-media-toggle").addEventListener("change", async (event) => {
const checked = event.currentTarget.checked;
try { await toggleMedia(checked); } catch (err) { alert("Media toggle error: " + err.message); }
});
// ── Load accounts ──
await loadAccounts();
// ── Periodic refresh ──
window.setInterval(() => {
if (state.activeAccount) {
refreshAccount(state.activeAccount);
}
}, 5000);
}
main().catch((error) => {
console.error(error);
alert(error.message);
});