telegram-invite-automation/src/renderer/tabs/LogsTab.jsx
2026-01-27 11:30:04 +04:00

730 lines
30 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { memo } from "react";
function LogsTab({
logsTab,
setLogsTab,
hasSelectedTask,
exportLogs,
clearLogs,
exportInvites,
exportProblemInvites,
exportFallback,
updateFallbackStatus,
clearFallback,
clearInvites,
logSearch,
setLogSearch,
logPage,
setLogPage,
logPageCount,
pagedLogs,
inviteSearch,
setInviteSearch,
invitePage,
setInvitePage,
invitePageCount,
inviteFilter,
setInviteFilter,
pagedInvites,
fallbackSearch,
setFallbackSearch,
fallbackPage,
setFallbackPage,
fallbackPageCount,
pagedFallback,
auditSearch,
setAuditSearch,
auditPage,
setAuditPage,
auditPageCount,
pagedAudit,
formatTimestamp,
explainInviteError,
accountById,
formatAccountLabel,
expandedInviteId,
setExpandedInviteId,
inviteStats,
selectedTask,
taskAccountRoles,
accessStatus,
inviteAccessStatus,
selectedTaskName,
roleSummary,
mutualContactDiagnostics
}) {
const strategyLabel = (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 || "—";
}
};
const formatStrategies = (meta) => {
if (!meta) return "";
try {
const parsed = JSON.parse(meta);
if (!Array.isArray(parsed)) return meta;
return parsed
.map((item) => `${strategyLabel(item.strategy)}: ${item.ok ? "ok" : "fail"}${item.detail ? ` (${item.detail})` : ""}`)
.map((line) => `- ${line}`)
.join("\n");
} catch (error) {
return meta;
}
};
const hasStrategySuccess = (meta) => {
if (!meta) return false;
try {
const parsed = JSON.parse(meta);
return Array.isArray(parsed) && parsed.some((item) => item.ok);
} catch (error) {
return false;
}
};
const formatTargetType = (value) => {
if (!value) return "—";
if (value === "channel") return "канал";
if (value === "megagroup") return "супергруппа";
if (value === "group") return "группа";
return value;
};
const formatErrorWithExplain = (value) => {
if (!value) return "—";
const code = String(value);
const explanation = explainInviteError(code.split(/[:(]/, 1)[0].trim());
if (!explanation) return code;
if (code.includes("(")) return code;
return `${code} (${explanation})`;
};
const explainRawError = (value) => {
if (!value) return "";
const code = String(value).split(/[:(]/, 1)[0].trim();
return explainInviteError(code);
};
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);
};
const hasBothRoles = (accountId) => {
if (!accountId || !taskAccountRoles) return false;
const roles = taskAccountRoles[String(accountId)] || taskAccountRoles[accountId];
return Boolean(roles && roles.monitor && roles.invite);
};
const formatInviteStatus = (status) => {
switch (status) {
case "success":
return "Успех";
case "skipped":
return "Пропуск";
case "unconfirmed":
return "Не подтверждено";
case "failed":
default:
return "Ошибка";
}
};
return (
<section className="card logs">
<div className="row-header">
<h2>Логи и история</h2>
<div className="row-inline">
{logsTab === "logs" && (
<>
<button className="secondary" onClick={() => exportLogs("logs")} disabled={!hasSelectedTask}>Выгрузить</button>
<button className="danger" onClick={() => clearLogs("logs")} disabled={!hasSelectedTask}>Сбросить</button>
</>
)}
{logsTab === "invites" && (
<>
<button className="secondary" onClick={() => exportInvites("invites")} disabled={!hasSelectedTask}>Выгрузить</button>
<button className="secondary" onClick={() => exportProblemInvites("invites")} disabled={!hasSelectedTask}>Проблемные 30 дней</button>
<button className="secondary" onClick={() => exportFallback("invites")} disabled={!hasSelectedTask}>Fallback</button>
<button className="danger" onClick={() => clearInvites("invites")} disabled={!hasSelectedTask}>Сбросить</button>
</>
)}
{logsTab === "fallback" && (
<>
<button className="secondary" onClick={() => exportFallback("fallback")} disabled={!hasSelectedTask}>Выгрузить</button>
<button className="danger" onClick={() => clearFallback("fallback")} disabled={!hasSelectedTask}>Сбросить</button>
</>
)}
</div>
</div>
<div className="log-tabs">
<button
type="button"
className={`tab ${logsTab === "logs" ? "active" : ""}`}
onClick={() => setLogsTab("logs")}
>
Логи
</button>
<button
type="button"
className={`tab ${logsTab === "invites" ? "active" : ""}`}
onClick={() => setLogsTab("invites")}
>
История инвайтов
</button>
<button
type="button"
className={`tab ${logsTab === "audit" ? "active" : ""}`}
onClick={() => setLogsTab("audit")}
>
Изменения
</button>
<button
type="button"
className={`tab ${logsTab === "fallback" ? "active" : ""}`}
onClick={() => setLogsTab("fallback")}
>
Fallback
</button>
<button
type="button"
className={`tab ${logsTab === "diagnostics" ? "active" : ""}`}
onClick={() => setLogsTab("diagnostics")}
>
Диагностика
</button>
</div>
{logsTab === "logs" && (
<>
<div className="row-inline">
<input
type="text"
value={logSearch}
onChange={(event) => {
setLogSearch(event.target.value);
setLogPage(1);
}}
placeholder="Поиск по логам"
/>
<div className="pager">
<button
className="secondary"
type="button"
onClick={() => setLogPage((prev) => Math.max(1, prev - 1))}
disabled={logPage === 1}
>
Назад
</button>
<span>{logPage}/{logPageCount}</span>
<button
className="secondary"
type="button"
onClick={() => setLogPage((prev) => Math.min(logPageCount, prev + 1))}
disabled={logPage === logPageCount}
>
Вперед
</button>
</div>
</div>
{pagedLogs.length === 0 && <div className="empty">Логи пока пустые.</div>}
{pagedLogs.map((log) => {
const successIds = Array.isArray(log.successIds) ? log.successIds : [];
const errors = Array.isArray(log.errors) ? log.errors : [];
const errorMap = new Map();
errors.forEach((err) => {
const parts = String(err).split(":");
if (parts.length < 2) return;
const id = parts[0].trim();
const code = parts.slice(1).join(":").trim();
if (!id) return;
errorMap.set(id, code);
});
const resultRows = [
...successIds.map((id) => ({
id: String(id),
status: "success",
message: "успех"
})),
...Array.from(errorMap.entries()).map(([id, code]) => ({
id,
status: "error",
message: `${code} (${explainInviteError(code) || "Причина не определена"})`
}))
];
return (
<div key={log.id} className="log-row">
<div className="log-time">
<div>Старт цикла: {formatTimestamp(log.startedAt)}</div>
<div>Завершение: {formatTimestamp(log.finishedAt)}</div>
</div>
<div className="log-details">
<div>Добавлено: {log.invitedCount}</div>
{log.meta && (log.meta.batchSize || log.meta.cycleLimit || log.meta.queueCount) && (
<div className="log-users">
Цикл: лимит {log.meta.cycleLimit ?? "—"}, очередь {log.meta.queueCount ?? "—"}, взято {log.meta.batchSize ?? "—"}
</div>
)}
<div className="log-users wrap">
Пользователи: {successIds.length ? successIds.join(", ") : "—"}
</div>
{resultRows.length > 0 && (
<div className="log-users">
<div>Результаты:</div>
<div className="log-result-list">
{resultRows.map((row) => (
<div key={`${log.id}-${row.id}-${row.status}`} className={`log-result ${row.status}`}>
{row.id} {row.message}
</div>
))}
</div>
</div>
)}
{log.invitedCount === 0 && errors.length === 0 && (
<div className="log-errors">Причина: цикл завершён сразу очередь пуста</div>
)}
{errors.length > 0 && (
<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>
);
})}
</>
)}
{logsTab === "invites" && (
<>
{inviteStats && (
<div className="invite-stats">
Всего: {inviteStats.total} | Успех: {inviteStats.success} | Ошибка: {inviteStats.failed} | Пропуск: {inviteStats.skipped} | Не подтверждено: {inviteStats.unconfirmed}
</div>
)}
<div className="row-inline">
<input
type="text"
value={inviteSearch}
onChange={(event) => {
setInviteSearch(event.target.value);
setInvitePage(1);
}}
placeholder="Поиск по инвайтам"
/>
<div className="pager">
<button
className="secondary"
type="button"
onClick={() => setInvitePage((prev) => Math.max(1, prev - 1))}
disabled={invitePage === 1}
>
Назад
</button>
<span>{invitePage}/{invitePageCount}</span>
<button
className="secondary"
type="button"
onClick={() => setInvitePage((prev) => Math.min(invitePageCount, prev + 1))}
disabled={invitePage === invitePageCount}
>
Вперед
</button>
</div>
</div>
<div className="task-filters">
<button
type="button"
className={`chip ${inviteFilter === "all" ? "active" : ""}`}
onClick={() => {
setInviteFilter("all");
setInvitePage(1);
}}
>
Все
</button>
<button
type="button"
className={`chip ${inviteFilter === "success" ? "active" : ""}`}
onClick={() => {
setInviteFilter("success");
setInvitePage(1);
}}
>
Успех
</button>
<button
type="button"
className={`chip ${inviteFilter === "error" ? "active" : ""}`}
onClick={() => {
setInviteFilter("error");
setInvitePage(1);
}}
>
Ошибка
</button>
<button
type="button"
className={`chip ${inviteFilter === "skipped" ? "active" : ""}`}
onClick={() => {
setInviteFilter("skipped");
setInvitePage(1);
}}
>
Пропуск
</button>
<button
type="button"
className={`chip ${inviteFilter === "unconfirmed" ? "active" : ""}`}
onClick={() => {
setInviteFilter("unconfirmed");
setInvitePage(1);
}}
>
Не подтверждено
</button>
</div>
{pagedInvites.length === 0 && <div className="empty">История пока пустая.</div>}
{pagedInvites.map((invite) => (
<div key={invite.id} className="log-row">
<div className="log-time">
<div>{formatTimestamp(invite.invitedAt)}</div>
<div>
<span className={`log-status ${invite.status}`}>{formatInviteStatus(invite.status)}</span>
</div>
</div>
<div className="log-details">
<div>ID: {invite.userId}</div>
<div className="log-users wrap">
Ник: {invite.username ? `@${invite.username}` : "— (нет username в источнике)"}
</div>
<div className="log-users wrap">
Источник: {invite.sourceChat || "—"}
</div>
<div className="log-users wrap">
Цель: {invite.targetChat || "—"}{invite.targetType ? ` (${formatTargetType(invite.targetType)})` : ""}
</div>
<div className="log-users">
Инвайт: {(() => {
const account = accountById.get(invite.accountId);
return account ? formatAccountLabel(account) : (invite.accountPhone || "—");
})()}
{invite.watcherAccountId && invite.accountId && (
<span className={`match-badge ${invite.watcherAccountId === invite.accountId ? "ok" : "warn"}`}>
{invite.watcherAccountId === invite.accountId
? "Инвайт тем же аккаунтом, что наблюдал"
: ""}
</span>
)}
</div>
{invite.watcherAccountId && invite.accountId && invite.watcherAccountId !== invite.accountId
&& selectedTask && selectedTask.randomAccounts && hasBothRoles(invite.watcherAccountId) && (
<div className="log-users">
Примечание: у наблюдателя стоят обе роли, но включен случайный выбор инвайт выполнен другим аккаунтом.
</div>
)}
<div className="log-users">Наблюдатель: {(() => {
const account = accountById.get(invite.watcherAccountId);
return account ? formatAccountLabel(account) : (invite.watcherPhone || "—");
})()}</div>
{invite.skippedReason && invite.skippedReason !== "" && (
<div className="log-errors">Результат: {formatErrorWithExplain(invite.skippedReason)}</div>
)}
{invite.error && invite.error !== "" && (
<div className="log-errors">Ошибка: {formatErrorWithExplain(invite.error)}</div>
)}
<div className="log-errors">
Проверка участия: {invite.confirmError
? formatErrorWithExplain(invite.confirmError)
: (invite.confirmed ? "OK" : "Не подтверждено")}
</div>
{invite.confirmError && invite.confirmError.includes("(") && (
<div className="log-users">
Проверял: {invite.confirmError.slice(invite.confirmError.indexOf("(") + 1, invite.confirmError.lastIndexOf(")"))}
</div>
)}
{invite.strategy && (
<div className="log-users">Стратегия: {invite.strategy}</div>
)}
{invite.strategyMeta && (
<div className="log-users">{`Стратегии:\n${formatStrategies(invite.strategyMeta)}`}</div>
)}
{invite.strategyMeta && !hasStrategySuccess(invite.strategyMeta) && (
<div className="log-errors">Все стратегии не сработали</div>
)}
<button
type="button"
className="ghost"
onClick={() => setExpandedInviteId(expandedInviteId === invite.id ? null : invite.id)}
>
{expandedInviteId === invite.id ? "Скрыть детали" : "Подробнее"}
</button>
{expandedInviteId === invite.id && (
<div className="invite-details">
<div>Задача: {invite.taskId}</div>
<div>Аккаунт ID: {invite.accountId || "—"}</div>
<div>Наблюдатель ID: {invite.watcherAccountId || "—"}</div>
<div>Наблюдатель: {(() => {
const account = accountById.get(invite.watcherAccountId);
return account ? formatAccountLabel(account) : (invite.watcherPhone || "—");
})()}</div>
<div>Цель: {invite.targetChat || "—"}</div>
<div>Тип цели: {formatTargetType(invite.targetType)}</div>
<div>Действие: {invite.action || "invite"}</div>
<div>Статус: {formatInviteStatus(invite.status)}</div>
<div>Результат: {formatErrorWithExplain(invite.skippedReason)}</div>
<div>Ошибка: {formatErrorWithExplain(invite.error)}</div>
<div>Проверка участия: {invite.confirmError
? formatErrorWithExplain(invite.confirmError)
: (invite.confirmed ? "OK" : "Не подтверждено")}</div>
{invite.confirmError && invite.confirmError.includes("(") && (
<div>Проверял: {invite.confirmError.slice(invite.confirmError.indexOf("(") + 1, invite.confirmError.lastIndexOf(")"))}</div>
)}
{invite.watcherAccountId && invite.accountId && invite.watcherAccountId !== invite.accountId
&& selectedTask && selectedTask.randomAccounts && hasBothRoles(invite.watcherAccountId) && (
<div>Примечание: у наблюдателя стоят обе роли, но включен случайный выбор инвайт выполнен другим аккаунтом.</div>
)}
<div>Пояснение: {explainRawError(invite.error) || explainRawError(invite.confirmError) || "Причина не определена"}</div>
<div>Стратегия: {invite.strategy || "—"}</div>
<div className="pre-line">
{invite.strategyMeta ? `Стратегии:\n${formatStrategies(invite.strategyMeta)}` : "Стратегии: —"}
</div>
{invite.strategyMeta && !hasStrategySuccess(invite.strategyMeta) && (
<div>Результат: все стратегии не сработали</div>
)}
<div>Access Hash: {invite.userAccessHash || "—"}</div>
<div>Время: {formatTimestamp(invite.invitedAt)}</div>
</div>
)}
</div>
</div>
))}
</>
)}
{logsTab === "fallback" && (
<>
<div className="row-inline">
<input
type="text"
value={fallbackSearch}
onChange={(event) => {
setFallbackSearch(event.target.value);
setFallbackPage(1);
}}
placeholder="Поиск по fallback"
/>
<div className="pager">
<button
className="secondary"
type="button"
onClick={() => setFallbackPage((prev) => Math.max(1, prev - 1))}
disabled={fallbackPage === 1}
>
Назад
</button>
<span>{fallbackPage}/{fallbackPageCount}</span>
<button
className="secondary"
type="button"
onClick={() => setFallbackPage((prev) => Math.min(fallbackPageCount, prev + 1))}
disabled={fallbackPage === fallbackPageCount}
>
Вперед
</button>
</div>
</div>
{pagedFallback.length === 0 && <div className="empty">Fallback пока пуст.</div>}
{pagedFallback.map((item) => (
<div key={item.id} className="log-row">
<div className="log-time">
<div>{formatTimestamp(item.createdAt)}</div>
<div>{item.route}</div>
</div>
<div className="log-details">
<div>ID: {item.userId}</div>
<div className="log-users wrap">Ник: {item.username ? `@${item.username}` : "—"}</div>
<div className="log-users wrap">Источник: {item.sourceChat || "—"}</div>
<div className="log-users wrap">Цель: {item.targetChat || "—"}</div>
<div className="log-errors">Причина: {item.reason}</div>
<div className="log-users">Статус: {item.status}</div>
<div className="log-actions">
<button
className="secondary"
type="button"
onClick={() => updateFallbackStatus(item.id, "done")}
>
Обработано
</button>
<button
className="ghost"
type="button"
onClick={() => updateFallbackStatus(item.id, "pending")}
>
Вернуть
</button>
</div>
</div>
</div>
))}
</>
)}
{logsTab === "audit" && (
<>
<div className="row-inline">
<input
type="text"
value={auditSearch}
onChange={(event) => {
setAuditSearch(event.target.value);
setAuditPage(1);
}}
placeholder="Поиск по изменениям"
/>
<div className="pager">
<button
className="secondary"
type="button"
onClick={() => setAuditPage((prev) => Math.max(1, prev - 1))}
disabled={auditPage === 1}
>
Назад
</button>
<span>{auditPage}/{auditPageCount}</span>
<button
className="secondary"
type="button"
onClick={() => setAuditPage((prev) => Math.min(auditPageCount, prev + 1))}
disabled={auditPage === auditPageCount}
>
Вперед
</button>
</div>
</div>
{pagedAudit.length === 0 && <div className="empty">История изменений пуста.</div>}
{pagedAudit.map((item) => (
<div key={item.id} className="log-row">
<div className="log-time">
<div>{formatTimestamp(item.createdAt)}</div>
<div>{item.action}</div>
</div>
<div className="log-details">
<div className="pre-line">{item.details || "—"}</div>
</div>
</div>
))}
</>
)}
{logsTab === "diagnostics" && (
<>
<div className="log-users">Для: {selectedTaskName}</div>
{accessStatus && accessStatus.length > 0 && (
<div className="access-block">
<div className="access-title">Доступ к группам</div>
<div className="access-list">
{accessStatus.map((item, index) => (
<div key={`${item.value}-${index}`} className={`access-row ${item.ok ? "ok" : "fail"}`}>
<div className="access-title">
{item.type === "our" ? "Наша" : "Конкурент"}: {item.title || item.value}
</div>
<div className="access-status">
{item.ok ? "Доступ есть" : "Нет доступа"}
</div>
{!item.ok && <div className="access-error">{item.details}</div>}
</div>
))}
</div>
</div>
)}
{inviteAccessStatus && inviteAccessStatus.length > 0 && (
<div className="access-block">
<div className="access-title">Права инвайта</div>
<div className="access-subtitle">
Проверяются аккаунты с ролью инвайта: {roleSummary ? roleSummary.invite.length : "—"}
</div>
<div className="access-list">
{inviteAccessStatus.map((item, index) => (
<div key={`${item.accountId}-${index}`} className={`access-row ${item.canInvite ? "ok" : "fail"}`}>
<div className="access-title">
{(() => {
const account = accountById.get(item.accountId);
return account ? formatAccountLabel(account) : (item.accountPhone || item.accountId);
})()}: {item.title || item.targetChat}
{item.targetType ? ` (${formatTargetType(item.targetType)})` : ""}
</div>
<div className="access-status">
{item.canInvite ? "Можно инвайтить" : "Нет прав"}
</div>
{!item.canInvite && <div className="access-error">{item.reason || "—"}</div>}
</div>
))}
</div>
</div>
)}
{mutualContactDiagnostics && (
<div className="access-block">
<div className="access-title">USER_NOT_MUTUAL_CONTACT</div>
<div className="access-subtitle">
Ошибок в истории: {mutualContactDiagnostics.count}
</div>
<div className="access-list">
<div className="access-row">
<div className="access-title">
Цель: {(() => {
const entry = inviteAccessStatus && inviteAccessStatus[0];
if (entry) {
const label = entry.title || entry.targetChat || (selectedTask ? selectedTask.ourGroup : "—");
const typeLabel = formatTargetType(entry.targetType);
return typeLabel ? `${label} (${typeLabel})` : label;
}
return selectedTask ? selectedTask.ourGroup : "—";
})()}
</div>
<div className="access-status">
{inviteAccessStatus && inviteAccessStatus.length ? "Права проверены" : "Нет данных проверки"}
</div>
</div>
{mutualContactDiagnostics.recent.map((item) => (
<div key={`mutual-${item.id}`} className="access-row fail">
<div className="access-title">
Пользователь: {item.userId}{item.username ? ` (@${item.username})` : ""}
</div>
<div className="access-status">{formatTimestamp(item.invitedAt)}</div>
</div>
))}
</div>
<div className="access-error">
Возможные причины: Telegram ограничивает инвайт, если пользователь скрывает приём приглашений,
целевая группа требует взаимного контакта, или у пользователя есть приватные ограничения.
</div>
</div>
)}
{!accessStatus?.length && !inviteAccessStatus?.length && (
<div className="empty">Диагностика пока пустая.</div>
)}
</>
)}
</section>
);
}
export default memo(LogsTab);