This commit is contained in:
Ivan Neplokhov 2026-01-18 23:29:16 +04:00
parent 4bba4f3149
commit a3a259bd3b
8 changed files with 326 additions and 86 deletions

View File

@ -194,8 +194,25 @@ class TaskRunner {
result.strategy,
result.strategyMeta
);
const detailed = [
`user=${item.user_id}`,
`error=${result.error || "unknown"}`,
`strategy=${result.strategy || "—"}`,
`meta=${result.strategyMeta || "—"}`,
`source=${item.source_chat || "—"}`,
`account=${result.accountPhone || result.accountId || "—"}`
].join(" | ");
this.store.addAccountEvent(
watcherAccount ? watcherAccount.id : 0,
watcherAccount ? watcherAccount.phone : "",
"invite_failed",
detailed
);
}
}
if (!pending.length) {
errors.push("queue empty");
}
}
} catch (error) {
errors.push(error.message || String(error));

View File

@ -1298,14 +1298,16 @@ class TelegramManager {
: { accessHash: "", strategy: "", detail: "no sender_id", attempts: [] };
if (!resolved || !resolved.accessHash) {
if (shouldLogEvent(`${chatId}:skip`, 30000)) {
const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []);
const strategyLines = this._formatStrategyAttemptLines(resolved ? resolved.attempts : []);
const reason = senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя";
const extra = strategySummary ? `; стратегии: ${strategySummary}` : "";
const strategyBlock = strategyLines.length
? `\nШаги попыток:\n- ${strategyLines.join("\n- ")}`
: "";
this.store.addAccountEvent(
monitorAccount.account.id,
monitorAccount.account.phone,
"new_message_skipped",
`${formatGroupLabel(st)}: ${reason}${extra}, ${this._describeSender(message)}`
`${formatGroupLabel(st)}\nПричина: ${reason}${strategyBlock}\nОтправитель: ${this._describeSender(message)}`
);
}
return;
@ -1352,13 +1354,15 @@ class TelegramManager {
}
if (!senderPayload.accessHash && !senderPayload.username) {
if (shouldLogEvent(`${chatId}:skip`, 30000)) {
const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []);
const extra = strategySummary ? `; стратегии: ${strategySummary}` : "";
const strategyLines = this._formatStrategyAttemptLines(resolved ? resolved.attempts : []);
const strategyBlock = strategyLines.length
? `\nШаги попыток:\n- ${strategyLines.join("\n- ")}`
: "";
this.store.addAccountEvent(
monitorAccount.account.id,
monitorAccount.account.phone,
"new_message_skipped",
`${formatGroupLabel(st)}: нет access_hash (нет в списке участников)${extra}`
`${formatGroupLabel(st)}\nПричина: нет access_hash (нет в списке участников)${strategyBlock}`
);
}
return;
@ -1444,14 +1448,16 @@ class TelegramManager {
if (!resolved || !resolved.accessHash) {
skipped += 1;
if (shouldLogEvent(`${key}:skip`, 30000)) {
const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []);
const strategyLines = this._formatStrategyAttemptLines(resolved ? resolved.attempts : []);
const reason = senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя";
const extra = strategySummary ? `; стратегии: ${strategySummary}` : "";
const strategyBlock = strategyLines.length
? `\nШаги попыток:\n- ${strategyLines.join("\n- ")}`
: "";
this.store.addAccountEvent(
monitorAccount.account.id,
monitorAccount.account.phone,
"new_message_skipped",
`${formatGroupLabel(st)}: ${reason}${extra}, ${this._describeSender(message)}`
`${formatGroupLabel(st)}\nПричина: ${reason}${strategyBlock}\nОтправитель: ${this._describeSender(message)}`
);
}
continue;
@ -1501,13 +1507,15 @@ class TelegramManager {
if (!senderPayload.accessHash && !senderPayload.username) {
skipped += 1;
if (shouldLogEvent(`${key}:skip`, 30000)) {
const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []);
const extra = strategySummary ? `; стратегии: ${strategySummary}` : "";
const strategyLines = this._formatStrategyAttemptLines(resolved ? resolved.attempts : []);
const strategyBlock = strategyLines.length
? `\nШаги попыток:\n- ${strategyLines.join("\n- ")}`
: "";
this.store.addAccountEvent(
monitorAccount.account.id,
monitorAccount.account.phone,
"new_message_skipped",
`${formatGroupLabel(st)}: нет access_hash (нет в списке участников)${extra}`
`${formatGroupLabel(st)}\nПричина: нет access_hash (нет в списке участников)${strategyBlock}`
);
}
continue;
@ -1554,7 +1562,7 @@ class TelegramManager {
monitorAccount.account.id,
monitorAccount.account.phone,
"monitor_skip",
`${formatGroupLabel(st)}: сообщения есть, но пользователей нет (пропущено: ${skipped})`
`${formatGroupLabel(st)}\nПричина: сообщения есть, но авторов нельзя определить (анонимные админы/каналы, скрытые участники, нет access_hash)\nПропущено: ${skipped}`
);
}
}
@ -1660,9 +1668,9 @@ class TelegramManager {
const reason = senderInfo && senderInfo.reason ? senderInfo.reason : "нет данных пользователя";
skipReasons[reason] = (skipReasons[reason] || 0) + 1;
if (!strategySkipSample) {
const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []);
if (strategySummary) {
strategySkipSample = `${group}: ${reason}; стратегии: ${strategySummary}`;
const strategyLines = this._formatStrategyAttemptLines(resolved ? resolved.attempts : []);
if (strategyLines.length) {
strategySkipSample = `${group}\nПричина: ${reason}\nШаги попыток:\n- ${strategyLines.join("\n- ")}`;
}
}
continue;
@ -1687,9 +1695,9 @@ class TelegramManager {
senderPayload.accessHash = resolved.accessHash;
}
if (!senderPayload.accessHash && !senderPayload.username && !strategySkipSample) {
const strategySummary = this._formatStrategyAttempts(resolved ? resolved.attempts : []);
if (strategySummary) {
strategySkipSample = `${group}: нет access_hash; стратегии: ${strategySummary}`;
const strategyLines = this._formatStrategyAttemptLines(resolved ? resolved.attempts : []);
if (strategyLines.length) {
strategySkipSample = `${group}\nПричина: нет access_hash (нет в списке участников)\nШаги попыток:\n- ${strategyLines.join("\n- ")}`;
}
}
}
@ -1806,14 +1814,57 @@ class TelegramManager {
}
}
_formatStrategyAttempts(attempts) {
if (!Array.isArray(attempts) || attempts.length === 0) return "";
const parts = attempts.map((item) => {
const status = item.ok ? "ok" : "fail";
const detail = item.detail ? String(item.detail).replace(/\s+/g, " ").slice(0, 80) : "";
return detail ? `${item.strategy}:${status} (${detail})` : `${item.strategy}:${status}`;
_formatStrategyAttemptLines(attempts) {
if (!Array.isArray(attempts) || attempts.length === 0) return [];
return attempts.map((item, index) => {
const label = this._strategyLabelRu(item.strategy);
const status = item.ok ? "успех" : "не удалось";
const detail = this._translateStrategyDetail(item.detail);
const detailText = detail ? `${detail}` : "";
return `${index + 1}) ${label}${status}${detailText}`;
});
return parts.join("; ");
}
_strategyLabelRu(strategy) {
switch (strategy) {
case "access_hash":
return "access_hash из сообщения";
case "participants":
return "участники группы";
case "username":
return "поиск по username";
case "entity":
return "getEntity по userId";
case "retry":
return "повторная попытка";
default:
return strategy || "стратегия";
}
}
_translateStrategyDetail(detail) {
if (!detail) return "";
const raw = String(detail).trim();
if (!raw) return "";
const normalized = raw.toLowerCase();
if (normalized === "from message") return "access_hash найден в сообщении";
if (normalized === "invalid access_hash") return "access_hash невалиден";
if (normalized === "from participants") return "найден в списке участников";
if (normalized === "no result") return "нет результата";
if (normalized === "resolve failed") return "не удалось найти пользователя по username";
if (normalized === "getentity(userid)") return "получен через getEntity";
if (normalized === "no access_hash") return "нет access_hash";
if (normalized === "no sender_id") return "в сообщении нет sender_id";
if (normalized.includes("could not find the input entity")) {
return "не удалось получить сущность пользователя (entity)";
}
if (normalized.startsWith("not in participants")) {
const match = raw.match(/\((\d+)\)/);
const checked = match ? ` (проверено: ${match[1]})` : "";
return `не найден в списке участников${checked}`;
}
if (normalized === "invalid") return "ошибка в данных пользователя";
return `деталь: ${raw.replace(/\s+/g, " ").slice(0, 120)}`;
}
stopTaskMonitor(taskId) {

View File

@ -1,4 +1,4 @@
import React, { Suspense, useEffect, useMemo, useRef, useState } from "react";
import React, { Suspense, useDeferredValue, useEffect, useMemo, useRef, useState } from "react";
const AccountsTab = React.lazy(() => import("./tabs/AccountsTab.jsx"));
const LogsTab = React.lazy(() => import("./tabs/LogsTab.jsx"));
@ -66,8 +66,10 @@ const normalizeTask = (row) => ({
});
const normalizeIntervals = (form) => {
const min = Math.max(1, Number(form.minIntervalMinutes || 1));
let max = Math.max(1, Number(form.maxIntervalMinutes || 1));
const minValue = Number(form.minIntervalMinutes);
const maxValue = Number(form.maxIntervalMinutes);
const min = Number.isFinite(minValue) && minValue > 0 ? minValue : 1;
let max = Number.isFinite(maxValue) && maxValue > 0 ? maxValue : 1;
if (max < min) max = min;
return { ...form, minIntervalMinutes: min, maxIntervalMinutes: max };
};
@ -147,11 +149,19 @@ export default function App() {
const [invitePage, setInvitePage] = useState(1);
const [inviteFilter, setInviteFilter] = useState("all");
const [taskSort, setTaskSort] = useState("activity");
const [sidebarExpanded, setSidebarExpanded] = useState(false);
const [sidebarExpanded, setSidebarExpanded] = useState(true);
const [expandedInviteId, setExpandedInviteId] = useState(null);
const [now, setNow] = useState(Date.now());
const [isVisible, setIsVisible] = useState(!document.hidden);
const bellRef = useRef(null);
const settingsAutosaveReady = useRef(false);
const tasksPollInFlight = useRef(false);
const accountsPollInFlight = useRef(false);
const logsPollInFlight = useRef(false);
const eventsPollInFlight = useRef(false);
const deferredTaskSearch = useDeferredValue(taskSearch);
const deferredLogSearch = useDeferredValue(logSearch);
const deferredInviteSearch = useDeferredValue(inviteSearch);
const competitorGroups = useMemo(() => {
return competitorText
@ -175,6 +185,13 @@ export default function App() {
});
return map;
}, [accounts]);
const accountStatsMap = useMemo(() => {
const map = new Map();
(accountStats || []).forEach((item) => {
map.set(item.id, item);
});
return map;
}, [accountStats]);
const roleSummary = useMemo(() => {
const monitor = [];
const invite = [];
@ -353,7 +370,7 @@ export default function App() {
}, [tasks, taskStatusMap]);
const filteredTasks = useMemo(() => {
const query = taskSearch.trim().toLowerCase();
const query = deferredTaskSearch.trim().toLowerCase();
const filtered = tasks.filter((task) => {
const name = (task.name || "").toLowerCase();
const group = (task.our_group || "").toLowerCase();
@ -393,51 +410,102 @@ export default function App() {
return b.id - a.id;
});
return sorted;
}, [tasks, taskSearch, taskFilter, taskSort, taskStatusMap]);
}, [tasks, deferredTaskSearch, taskFilter, taskSort, taskStatusMap]);
useEffect(() => {
if (!window.api) return undefined;
const interval = setInterval(async () => {
const load = async () => {
if (!isVisible || tasksPollInFlight.current) return;
tasksPollInFlight.current = true;
try {
const tasksData = await window.api.listTasks();
setTasks(tasksData);
await loadTaskStatuses(tasksData);
if (selectedTaskId != null) {
setTaskStatus(await window.api.taskStatus(selectedTaskId));
}
} finally {
tasksPollInFlight.current = false;
}
};
load();
const interval = setInterval(async () => {
await load();
}, 5000);
return () => clearInterval(interval);
}, [selectedTaskId, isVisible]);
useEffect(() => {
if (!window.api) return undefined;
const load = async () => {
if (!isVisible || accountsPollInFlight.current) return;
accountsPollInFlight.current = true;
try {
setAccounts(await window.api.listAccounts());
setAccountAssignments(await window.api.listAccountAssignments());
const statusData = await window.api.getStatus();
setAccountStats(statusData.accountStats || []);
if (selectedTaskId != null) {
setTaskStatus(await window.api.taskStatus(selectedTaskId));
} finally {
accountsPollInFlight.current = false;
}
}, 5000);
};
load();
const interval = setInterval(load, 15000);
return () => clearInterval(interval);
}, [selectedTaskId]);
}, [isVisible]);
useEffect(() => {
if (!window.api || activeTab !== "logs" || selectedTaskId == null) return undefined;
const load = async () => {
if (!isVisible || logsPollInFlight.current) return;
logsPollInFlight.current = true;
try {
setLogs(await window.api.listLogs({ limit: 100, taskId: selectedTaskId }));
setInvites(await window.api.listInvites({ limit: 200, taskId: selectedTaskId }));
} finally {
logsPollInFlight.current = false;
}
};
load();
const interval = setInterval(load, 5000);
return () => clearInterval(interval);
}, [activeTab, selectedTaskId]);
}, [activeTab, selectedTaskId, isVisible]);
useEffect(() => {
if (!window.api || activeTab !== "events") return undefined;
const load = async () => {
if (!isVisible || eventsPollInFlight.current) return;
eventsPollInFlight.current = true;
try {
setAccountEvents(await window.api.listAccountEvents(200));
} finally {
eventsPollInFlight.current = false;
}
};
load();
const interval = setInterval(load, 10000);
return () => clearInterval(interval);
}, [activeTab]);
}, [activeTab, isVisible]);
useEffect(() => {
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, []);
useEffect(() => {
const handleVisibility = () => {
setIsVisible(!document.hidden);
};
document.addEventListener("visibilitychange", handleVisibility);
window.addEventListener("focus", handleVisibility);
window.addEventListener("blur", handleVisibility);
return () => {
document.removeEventListener("visibilitychange", handleVisibility);
window.removeEventListener("focus", handleVisibility);
window.removeEventListener("blur", handleVisibility);
};
}, []);
useEffect(() => {
if (selectedTaskId == null) return;
setTaskStatusMap((prev) => ({
@ -469,7 +537,7 @@ export default function App() {
return `${minutes}:${String(seconds).padStart(2, "0")}`;
};
const explainInviteError = (error) => {
const explainInviteError = useMemo(() => (error) => {
if (!error) return "";
if (error === "USER_ID_INVALID") {
return "Пользователь удален/скрыт; access_hash невалиден для этой сессии; приглашение в канал/чат без валидной сущности.";
@ -477,6 +545,36 @@ export default function App() {
if (error === "CHAT_WRITE_FORBIDDEN") {
return "Аккаунт не может приглашать: нет прав или он не участник группы.";
}
if (error === "USER_NOT_MUTUAL_CONTACT") {
return "Пользователь не взаимный контакт для добавляющего аккаунта. Обычно это происходит, когда в группе/канале включена опция «добавлять могут только контакты» или у пользователя закрыт приём инвайтов. Решение: использовать аккаунт, который уже в контактах у пользователя, или поменять настройки группы.";
}
if (error === "USER_PRIVACY_RESTRICTED") {
return "Пользователь ограничил приватность и не принимает инвайты.";
}
if (error === "USER_NOT_PARTICIPANT") {
return "Аккаунт не состоит в целевой группе или канал приватный.";
}
if (error === "USER_BANNED_IN_CHANNEL") {
return "Пользователь заблокирован в группе или канале назначения.";
}
if (error === "USER_BOT") {
return "Бота нельзя приглашать как обычного пользователя.";
}
if (error === "USER_KICKED") {
return "Пользователь был удален из группы ранее.";
}
if (error === "CHAT_ADMIN_REQUIRED") {
return "Для добавления участников нужны права администратора.";
}
if (error === "USER_ALREADY_PARTICIPANT") {
return "Пользователь уже состоит в целевой группе.";
}
if (error === "INVITE_HASH_EXPIRED" || error === "INVITE_HASH_INVALID") {
return "Инвайт-ссылка недействительна или истекла.";
}
if (error === "CHANNEL_PRIVATE") {
return "Целевая группа/канал приватные и недоступны по ссылке.";
}
if (error === "AUTH_KEY_DUPLICATED") {
return "Сессия используется в другом месте, Telegram отозвал ключ.";
}
@ -484,7 +582,18 @@ export default function App() {
return "Ограничение Telegram по частоте действий.";
}
return "";
};
}, []);
const explainTdataError = useMemo(() => (error) => {
if (!error) return "";
if (error.includes("AUTH_KEY_DUPLICATED")) {
return "Эта сессия уже используется в другом месте. Выйдите из аккаунта на других устройствах и пересоберите tdata.";
}
if (error === "DUPLICATE_ACCOUNT") {
return "Аккаунт уже добавлен в приложение.";
}
return "";
}, []);
const showNotification = (text, tone) => {
if (tone === "success") return;
@ -519,7 +628,7 @@ export default function App() {
}, [notifications, notificationFilter]);
const filteredLogs = useMemo(() => {
const query = logSearch.trim().toLowerCase();
const query = deferredLogSearch.trim().toLowerCase();
if (!query) return logs;
return logs.filter((log) => {
const text = [
@ -533,10 +642,10 @@ export default function App() {
.toLowerCase();
return text.includes(query);
});
}, [logs, logSearch]);
}, [logs, deferredLogSearch]);
const filteredInvites = useMemo(() => {
const query = inviteSearch.trim().toLowerCase();
const query = deferredInviteSearch.trim().toLowerCase();
return invites.filter((invite) => {
if (inviteFilter === "success" && invite.status !== "success") return false;
if (inviteFilter === "error" && invite.status === "success") return false;
@ -558,7 +667,7 @@ export default function App() {
if (!query) return true;
return text.includes(query);
});
}, [invites, inviteSearch, inviteFilter]);
}, [invites, deferredInviteSearch, inviteFilter]);
const inviteStrategyStats = useMemo(() => {
let success = 0;
@ -1273,7 +1382,8 @@ export default function App() {
});
if (result && result.canceled) return;
if (!result.ok) {
showNotification(result.error || "Ошибка импорта tdata", "error");
const hint = explainTdataError(result.error || "");
showNotification(hint ? `${result.error}. ${hint}` : (result.error || "Ошибка импорта tdata"), "error");
return;
}
setTdataResult(result);
@ -1515,6 +1625,9 @@ export default function App() {
<div key={`${item.path}-${index}`} className="tdata-error-row">
<div className="tdata-error-path">{item.path}</div>
<div className="tdata-error-text">{item.error}</div>
{explainTdataError(item.error) && (
<div className="tdata-error-hint">{explainTdataError(item.error)}</div>
)}
</div>
))}
</div>
@ -2038,8 +2151,11 @@ export default function App() {
<input
type="number"
min="1"
value={taskForm.minIntervalMinutes}
onChange={(event) => setTaskForm({ ...taskForm, minIntervalMinutes: Number(event.target.value) })}
value={taskForm.minIntervalMinutes || ""}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, minIntervalMinutes: value === "" ? "" : Number(value) });
}}
onBlur={() => setTaskForm(normalizeIntervals(taskForm))}
/>
</label>
@ -2048,8 +2164,11 @@ export default function App() {
<input
type="number"
min="1"
value={taskForm.maxIntervalMinutes}
onChange={(event) => setTaskForm({ ...taskForm, maxIntervalMinutes: Number(event.target.value) })}
value={taskForm.maxIntervalMinutes || ""}
onChange={(event) => {
const value = event.target.value;
setTaskForm({ ...taskForm, maxIntervalMinutes: value === "" ? "" : Number(value) });
}}
onBlur={() => setTaskForm(normalizeIntervals(taskForm))}
/>
</label>
@ -2133,6 +2252,7 @@ export default function App() {
onChange={(event) => setTaskForm({ ...taskForm, randomAccounts: event.target.checked })}
/>
Случайный выбор аккаунтов
<span className="hint">Инвайты распределяются случайно между доступными аккаунтами.</span>
</label>
<label className="checkbox">
<input
@ -2141,6 +2261,7 @@ export default function App() {
onChange={(event) => setTaskForm({ ...taskForm, multiAccountsPerRun: event.target.checked })}
/>
Несколько аккаунтов за цикл
<span className="hint">Если выключено в каждом цикле используется один аккаунт.</span>
</label>
<label className="checkbox">
<input
@ -2149,6 +2270,7 @@ export default function App() {
onChange={(event) => setTaskForm({ ...taskForm, retryOnFail: event.target.checked })}
/>
Повторять при ошибке
<span className="hint">Повторяем до 2 раз при неудачном инвайте.</span>
</label>
<label className="checkbox">
<input
@ -2157,6 +2279,7 @@ export default function App() {
onChange={(event) => setTaskForm({ ...taskForm, stopOnBlocked: event.target.checked })}
/>
Останавливать при блокировках
<span className="hint">Останавливает задачу, если % ограниченных аккаунтов выше порога.</span>
</label>
</div>
<div className="row">
@ -2189,7 +2312,7 @@ export default function App() {
<Suspense fallback={<div className="card">Загрузка...</div>}>
<AccountsTab
accounts={accounts}
accountStats={accountStats}
accountStatsMap={accountStatsMap}
settings={settings}
membershipStatus={membershipStatus}
assignedAccountMap={assignedAccountMap}

View File

@ -544,6 +544,11 @@ textarea {
flex: 1;
}
.row-inline.column {
flex-direction: column;
align-items: stretch;
}
.select-inline {
display: inline-flex;
align-items: center;
@ -1027,6 +1032,11 @@ label .hint {
color: #b91c1c;
}
.tdata-error-hint {
font-size: 11px;
color: #7c2d12;
}
.status-text {
font-size: 12px;
color: #1d4ed8;
@ -1109,6 +1119,7 @@ label .hint {
.log-errors {
font-size: 12px;
color: #475569;
white-space: pre-line;
}
.invite-details {
@ -1120,12 +1131,17 @@ label .hint {
font-size: 12px;
display: grid;
gap: 4px;
white-space: pre-line;
}
.wrap {
word-break: break-all;
}
.pre-line {
white-space: pre-line;
}
button:disabled {
opacity: 0.6;
cursor: not-allowed;

View File

@ -1,8 +1,8 @@
import React, { useState } from "react";
import React, { memo, useState } from "react";
export default function AccountsTab({
function AccountsTab({
accounts,
accountStats,
accountStatsMap,
settings,
membershipStatus,
assignedAccountMap,
@ -76,7 +76,7 @@ export default function AccountsTab({
{accountBuckets.freeOrSelected.map((account) => {
const assignedTasks = assignedAccountMap.get(account.id) || [];
const membership = membershipStatus[account.id];
const stats = accountStats.find((item) => item.id === account.id);
const stats = accountStatsMap.get(account.id);
const remaining = stats && stats.remainingToday != null ? stats.remainingToday : null;
const used = stats ? stats.usedToday : 0;
const limit = stats ? stats.limit : settings.accountDailyLimit;
@ -184,7 +184,7 @@ export default function AccountsTab({
type="button"
onClick={() => setAccountRolesAll(account.id, !selected)}
>
{selected ? "Снять" : "Оба"}
{selected ? "Снять роли" : "Оба"}
</button>
</div>
)}
@ -227,7 +227,7 @@ export default function AccountsTab({
})
.join(", ");
const membership = membershipStatus[account.id];
const stats = accountStats.find((item) => item.id === account.id);
const stats = accountStatsMap.get(account.id);
const remaining = stats && stats.remainingToday != null ? stats.remainingToday : null;
const used = stats ? stats.usedToday : 0;
const limit = stats ? stats.limit : settings.accountDailyLimit;
@ -342,3 +342,5 @@ export default function AccountsTab({
</section>
);
}
export default memo(AccountsTab);

View File

@ -1,6 +1,6 @@
import React, { useMemo, useState } from "react";
import React, { memo, useMemo, useState } from "react";
export default function EventsTab({ accountEvents, formatTimestamp }) {
function EventsTab({ accountEvents, formatTimestamp }) {
const [typeFilter, setTypeFilter] = useState("all");
const [query, setQuery] = useState("");
@ -24,7 +24,7 @@ export default function EventsTab({ accountEvents, formatTimestamp }) {
return (
<section className="card logs">
<h2>События аккаунтов</h2>
<div className="row-inline">
<div className="row-inline column">
<input
type="text"
value={query}
@ -60,3 +60,5 @@ export default function EventsTab({ accountEvents, formatTimestamp }) {
</section>
);
}
export default memo(EventsTab);

View File

@ -1,6 +1,6 @@
import React from "react";
import React, { memo } from "react";
export default function LogsTab({
function LogsTab({
logsTab,
setLogsTab,
taskNotice,
@ -52,7 +52,8 @@ export default function LogsTab({
if (!Array.isArray(parsed)) return meta;
return parsed
.map((item) => `${strategyLabel(item.strategy)}: ${item.ok ? "ok" : "fail"}${item.detail ? ` (${item.detail})` : ""}`)
.join(" | ");
.map((line) => `- ${line}`)
.join("\n");
} catch (error) {
return meta;
}
@ -68,6 +69,13 @@ export default function LogsTab({
}
};
const getDurationMs = (start, finish) => {
const startMs = new Date(start).getTime();
const finishMs = new Date(finish).getTime();
if (!Number.isFinite(startMs) || !Number.isFinite(finishMs)) return null;
return Math.max(0, finishMs - startMs);
};
return (
<section className="card logs">
<div className="row-header">
@ -153,11 +161,24 @@ export default function LogsTab({
Пользователи: {successIds.length ? successIds.join(", ") : "—"}
</div>
{log.invitedCount === 0 && errors.length === 0 && (
<div className="log-errors">Причина: очередь пуста</div>
<div className="log-errors">Причина: цикл завершён сразу очередь пуста</div>
)}
{errors.length > 0 && (
<div className="log-errors">Ошибки: {errors.join(" | ")}</div>
<div className="log-errors">
Ошибки: {errors.map((err) => {
const code = String(err).split(":").pop().trim();
const reason = explainInviteError(code) || "Причина не определена";
return code ? `${err} (${reason})` : err;
}).join(" | ")}
</div>
)}
{(() => {
const durationMs = getDurationMs(log.startedAt, log.finishedAt);
if (durationMs != null && durationMs < 1000) {
return <div className="log-errors">Цикл завершён сразу: очередь пуста или ошибка на первой попытке.</div>;
}
return null;
})()}
</div>
</div>
);
@ -270,14 +291,16 @@ export default function LogsTab({
{invite.error && invite.error !== "" && (
<div className="log-errors">Причина: {invite.error}</div>
)}
{invite.error && explainInviteError(invite.error) && (
<div className="log-users">Вероятная причина: {explainInviteError(invite.error)}</div>
{invite.error && (
<div className="log-users">
Вероятная причина: {explainInviteError(invite.error) || "Причина не определена"}
</div>
)}
{invite.strategy && (
<div className="log-users">Стратегия: {invite.strategy}</div>
)}
{invite.strategyMeta && (
<div className="log-users">Стратегии: {formatStrategies(invite.strategyMeta)}</div>
<div className="log-users">{`Стратегии:\n${formatStrategies(invite.strategyMeta)}`}</div>
)}
{invite.strategyMeta && !hasStrategySuccess(invite.strategyMeta) && (
<div className="log-errors">Все стратегии не сработали</div>
@ -299,9 +322,11 @@ export default function LogsTab({
<div>Статус: {invite.status}</div>
<div>Пропуск: {invite.skippedReason || "—"}</div>
<div>Ошибка: {invite.error || "—"}</div>
<div>Вероятная причина: {explainInviteError(invite.error) || ""}</div>
<div>Вероятная причина: {explainInviteError(invite.error) || "Причина не определена"}</div>
<div>Стратегия: {invite.strategy || "—"}</div>
<div>Стратегии: {invite.strategyMeta ? formatStrategies(invite.strategyMeta) : "—"}</div>
<div className="pre-line">
{invite.strategyMeta ? `Стратегии:\n${formatStrategies(invite.strategyMeta)}` : "Стратегии: —"}
</div>
{invite.strategyMeta && !hasStrategySuccess(invite.strategyMeta) && (
<div>Результат: все стратегии не сработали</div>
)}
@ -317,3 +342,5 @@ export default function LogsTab({
</section>
);
}
export default memo(LogsTab);

View File

@ -1,6 +1,6 @@
import React from "react";
import React, { memo } from "react";
export default function SettingsTab({ settings, onSettingsChange, settingsNotice, saveSettings }) {
function SettingsTab({ settings, onSettingsChange, settingsNotice, saveSettings }) {
return (
<section className="card">
<h2>Глобальные настройки аккаунтов</h2>
@ -42,3 +42,5 @@ export default function SettingsTab({ settings, onSettingsChange, settingsNotice
</section>
);
}
export default memo(SettingsTab);