730 lines
30 KiB
JavaScript
730 lines
30 KiB
JavaScript
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);
|