/
gesar93
/
gesar.chlogs
Обзор
Документация
Войти
/
gesar93
/
gesar.chlogs
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
Безопасность
master
admin/viewer.php
922 строки
42 KB
Ilya Donskikh
Initial
02 фев 2026, 09:43
02 фев 2026, 09:43
e45c3ed
Код
Авторство
О чём код?
<?php use Bitrix\Gesar\Kafka\Exchange\ExchangePauseService; use Bitrix\Gesar\Kafka\Notifier\TelegramNotifier; use Bitrix\Main\Loader; use ClickHouseDB\Client; require_once $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_admin_before.php'; global $USER, $APPLICATION; if (!$USER || !$USER->IsAdmin()) { require_once $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_admin_after.php'; echo 'Access denied'; require_once $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/epilog_admin.php'; die(); } Loader::includeModule('gesar.chlogs'); $kafkaAvailable = Loader::includeModule('gesar.kafka'); if (!class_exists(Client::class)) { require_once $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_admin_after.php'; echo 'Composer dependencies are not installed. Run composer install in the module directory.'; require_once $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/epilog_admin.php'; die(); } $chCfg = is_file(__DIR__ . '/../config/clickhouse.php') ? require __DIR__ . '/../config/clickhouse.php' : []; $CH_HOST = (string)($chCfg['host'] ?? 'http://localhost:8123'); $CH_DB = (string)($chCfg['database'] ?? 'logs'); $CH_TABLE = (string)($chCfg['table'] ?? 'kafka_messages'); $CH_USER = (string)($chCfg['username'] ?? ''); $CH_PASS = (string)($chCfg['password'] ?? ''); $isAdmin = $USER->IsAuthorized() && $USER->IsAdmin(); $simpleLogger = new class { public function emergency($message, array $context = []): void { error_log("[EMERGENCY] $message: " . json_encode($context, JSON_UNESCAPED_UNICODE)); } public function alert($message, array $context = []): void { error_log("[ALERT] $message: " . json_encode($context, JSON_UNESCAPED_UNICODE)); } public function critical($message, array $context = []): void { error_log("[CRITICAL] $message: " . json_encode($context, JSON_UNESCAPED_UNICODE)); } public function error($message, array $context = []): void { error_log("[ERROR] $message: " . json_encode($context, JSON_UNESCAPED_UNICODE)); } public function warning($message, array $context = []): void { error_log("[WARNING] $message: " . json_encode($context, JSON_UNESCAPED_UNICODE)); } public function notice($message, array $context = []): void { error_log("[NOTICE] $message: " . json_encode($context, JSON_UNESCAPED_UNICODE)); } public function info($message, array $context = []): void { error_log("[INFO] $message: " . json_encode($context, JSON_UNESCAPED_UNICODE)); } public function debug($message, array $context = []): void { error_log("[DEBUG] $message: " . json_encode($context, JSON_UNESCAPED_UNICODE)); } public function log($level, $message, array $context = []): void { error_log("[$level] $message: " . json_encode($context, JSON_UNESCAPED_UNICODE)); } }; $pauseService = $kafkaAvailable ? new ExchangePauseService($simpleLogger) : null; $telegramNotifier = $kafkaAvailable ? new TelegramNotifier($simpleLogger) : null; // Handle AJAX requests for exchange control if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['action'])) { header('Content-Type: application/json; charset=utf-8'); // Проверка прав администратора if (!$isAdmin) { echo json_encode(['success' => false, 'message' => 'Доступ запрещен. Требуются права администратора.'], JSON_UNESCAPED_UNICODE); exit; } if (!$pauseService) { echo json_encode([ 'success' => false, 'message' => 'Модуль gesar.kafka не установлен или не загружен. Управление паузой обмена недоступно.', ], JSON_UNESCAPED_UNICODE); exit; } $action = $_POST['action']; $response = ['success' => false, 'message' => '', 'debug' => ['action' => $action, 'user_id' => $USER->GetID()]]; try { switch ($action) { case 'continue': $result = $pauseService->continueExchange($USER->GetID()); $response['debug']['continue_result'] = $result; if ($result) { $response['success'] = true; $response['message'] = 'Обмен продолжен'; // Пытаемся отправить уведомление в Telegram (не критично если не получится) try { $userName = $USER->GetFullName() ?: $USER->GetLogin(); if ($telegramNotifier) { $telegramNotifier->notifyExchangeContinued($USER->GetID(), $userName); } } catch (\Exception $e) { $simpleLogger->warning('Failed to send Telegram notification', ['error' => $e->getMessage()]); } $simpleLogger->info('Exchange continued by admin', [ 'admin_id' => $USER->GetID(), 'admin_name' => $userName ?? 'unknown' ]); } else { $response['message'] = 'Не удалось продолжить обмен'; } break; case 'skip': $result = $pauseService->skipMessage($USER->GetID()); $response['debug']['skip_result'] = $result; if ($result) { $response['success'] = true; $response['message'] = 'Сообщение пропущено'; // Пытаемся отправить уведомление в Telegram (не критично если не получится) try { $userName = $USER->GetFullName() ?: $USER->GetLogin(); if ($telegramNotifier) { $telegramNotifier->notifyMessageSkipped($USER->GetID(), $userName); } } catch (\Exception $e) { $simpleLogger->warning('Failed to send Telegram notification', ['error' => $e->getMessage()]); } $simpleLogger->info('Message skipped by admin', [ 'admin_id' => $USER->GetID(), 'admin_name' => $userName ?? 'unknown' ]); } else { $response['message'] = 'Не удалось пропустить сообщение'; } break; case 'get_status': $pauseData = $pauseService->getPauseData(); $response['success'] = true; $response['data'] = [ 'is_paused' => $pauseService->isPaused(), 'needs_action' => $pauseService->needsAdminAction(), 'pause_data' => $pauseData ]; break; default: $response['message'] = 'Неизвестное действие'; } } catch (Exception $e) { $response['message'] = 'Ошибка: ' . $e->getMessage(); $simpleLogger->error('Error processing admin action', [ 'action' => $action, 'error' => $e->getMessage() ]); } echo json_encode($response, JSON_UNESCAPED_UNICODE); exit; } // AJAX endpoint для загрузки полного трейса if (isset($_GET['action']) && $_GET['action'] === 'get_full_trace' && isset($_GET['trace_id'])) { header('Content-Type: application/json; charset=utf-8'); $traceId = $_GET['trace_id']; $response = ['success' => false, 'data' => '', 'debug' => []]; try { // Парсим хост правильно - убираем схему и извлекаем хост и порт $parsedUrl = parse_url($CH_HOST); $chHostOnly = $parsedUrl['host'] ?? $CH_HOST; $chPort = $parsedUrl['port'] ?? 8123; $response['debug']['ch_host'] = $chHostOnly; $response['debug']['ch_port'] = $chPort; $config = [ 'host' => $chHostOnly, 'port' => $chPort, 'username' => $CH_USER, 'password' => $CH_PASS ]; $client = new Client($config); $client->database($CH_DB); $tableFqn = $CH_DB . '.' . $CH_TABLE; $escapedTraceId = gesar_chlogs_escape($traceId); $sql = "SELECT full_trace_log FROM {$tableFqn} WHERE trace_id = '{$escapedTraceId}' LIMIT 1"; $response['debug']['sql'] = $sql; $stmt = $client->select($sql); $rows = $stmt->rows(); $response['debug']['rows_count'] = count($rows); if (!empty($rows)) { $fullLog = $rows[0]['full_trace_log'] ?? ''; $response['debug']['log_length'] = strlen($fullLog); $response['debug']['log_empty'] = empty($fullLog); if (!empty($fullLog)) { $response['success'] = true; $response['data'] = $fullLog; } else { $response['message'] = 'Поле full_trace_log пустое'; } } else { $response['message'] = 'Запись не найдена для данного trace_id'; } } catch (\Throwable $e) { $response['message'] = 'Ошибка загрузки лога: ' . $e->getMessage(); $response['debug']['exception'] = get_class($e); $response['debug']['trace'] = $e->getTraceAsString(); } echo json_encode($response, JSON_UNESCAPED_UNICODE); exit; } // Get current exchange status $pauseData = $pauseService ? $pauseService->getPauseData() : null; $isPaused = $pauseService ? $pauseService->isPaused() : false; $needsAction = $pauseService ? $pauseService->needsAdminAction() : false; function gesar_chlogs_escape(string $s): string { // Escape single quotes for ClickHouse SQL return str_replace("'", "\\'", $s); } function gesar_chlogs_build_query_url(array $params, array $overrides = []): string { // Объединяем текущие параметры с переопределениями $merged = array_merge($params, $overrides); // Удаляем пустые значения $merged = array_filter($merged, fn($v) => $v !== '' && $v !== null); return '?' . http_build_query($merged); } function gesar_chlogs_dt_from_input(?string $s, ?string $default = null): ?string { if (!$s || !trim($s)) { return $default; } // Accept formats: 'Y-m-d', 'Y-m-d H:i:s', 'Y-m-d\TH:i' $s = trim($s); $formats = ['Y-m-d\TH:i', 'Y-m-d H:i:s', 'Y-m-d']; foreach ($formats as $fmt) { $dt = DateTime::createFromFormat($fmt, $s); if ($dt instanceof DateTime) { if ($fmt === 'Y-m-d') { $dt->setTime(0, 0, 0); } return $dt->format('Y-m-d H:i:s'); } } return $default; } function gesar_chlogs_parse_ch_dsn(string $host): array { $u = parse_url($host); return [ 'host' => $u['host'] ?? $host, 'port' => isset($u['port']) ? (int)$u['port'] : 8123, 'scheme' => $u['scheme'] ?? 'http', ]; } function gesar_chlogs_client(string $host, string $user, string $pass, string $database): Client { $parts = gesar_chlogs_parse_ch_dsn($host); $config = [ 'host' => $parts['host'], 'port' => $parts['port'], 'username' => $user, 'password' => $pass, ]; $client = new Client($config); // Настройки выполнения (если поддерживается версией библиотеки) try { $client->setTimeout(60); } catch (\Throwable $e) { // Игнорируем, если метод не поддерживается } $client->database($database); return $client; } // Inputs $now = new DateTimeImmutable('now'); $defaultFrom = $now->sub(new DateInterval('P1D'))->format('Y-m-d H:i:s'); $defaultTo = $now->format('Y-m-d H:i:s'); // Processed time filter (empty by default) $procFrom = gesar_chlogs_dt_from_input($_GET['from'] ?? null, null); $procTo = gesar_chlogs_dt_from_input($_GET['to'] ?? null, null); // Event time filter (empty by default) $eventFrom = gesar_chlogs_dt_from_input($_GET['from_event'] ?? null, null); $eventTo = gesar_chlogs_dt_from_input($_GET['to_event'] ?? null, null); $traceId = trim($_GET['trace_id'] ?? ''); $offsetId = trim($_GET['offset_id'] ?? ''); $q = trim($_GET['q'] ?? ''); $statusFilter = trim($_GET['status_filter'] ?? ''); // '' = все, 'success', 'error' $sortField = in_array(trim($_GET['sort_field'] ?? ''), ['status', 'processed_time']) ? trim($_GET['sort_field']) : 'processed_time'; $sort = strtolower(trim($_GET['sort'] ?? 'desc')) === 'asc' ? 'ASC' : 'DESC'; $page = max(1, (int)($_GET['page'] ?? 1)); $perPage = min(500, max(10, (int)($_GET['per_page'] ?? 100))); $offset = ($page - 1) * $perPage; // Build WHERE $where = []; // processed_time window if ($procFrom) { $where[] = "processed_time >= toDateTime('" . gesar_chlogs_escape($procFrom) . "')"; } if ($procTo) { $where[] = "processed_time <= toDateTime('" . gesar_chlogs_escape($procTo) . "')"; } // optional event_time window if ($eventFrom) { $where[] = "event_time >= toDateTime('" . gesar_chlogs_escape($eventFrom) . "')"; } if ($eventTo) { $where[] = "event_time <= toDateTime('" . gesar_chlogs_escape($eventTo) . "')"; } if ($traceId !== '') { $where[] = "trace_id = '" . gesar_chlogs_escape($traceId) . "'"; } if ($offsetId !== '') { $where[] = "kafka_offset = " . (int)$offsetId; } if ($q !== '') { $where[] = "positionCaseInsensitive(body, '" . gesar_chlogs_escape($q) . "') > 0"; } if ($statusFilter !== '' && in_array($statusFilter, ['success', 'error', 'skipped', 'warning'])) { $where[] = "status = '" . gesar_chlogs_escape($statusFilter) . "'"; } $whereSql = $where ? ('WHERE ' . implode(' AND ', $where)) : ''; $tableFqn = $CH_DB . '.' . $CH_TABLE; // Main query (order by selected field) try { $client = gesar_chlogs_client($CH_HOST, $CH_USER, $CH_PASS, $CH_DB); } catch (\Throwable $e) { $rowsData = ['error' => 'Client init failed: ' . $e->getMessage()]; } $rowsData = $rowsData ?? null; try { // Формируем ORDER BY с приоритетом статуса при его выборе // Добавляем kafka_offset для гарантированного порядка сообщений с одинаковым временем $orderBy = $sortField === 'status' ? "status {$sort}, processed_time {$sort}, kafka_offset {$sort}" : "processed_time {$sort}, kafka_offset {$sort}"; $sqlRows = "SELECT processed_time, event_time, trace_id, status, body, error_message, error_trace, kafka_offset FROM {$tableFqn} {$whereSql} ORDER BY {$orderBy} LIMIT {$perPage} OFFSET {$offset}"; $stmtRows = isset($client) ? $client->select($sqlRows) : null; $rowsArr = $stmtRows ? $stmtRows->rows() : []; } catch (\Throwable $e) { $rowsData = ['error' => $e->getMessage()]; $rowsArr = []; } // Count and stats $totalCount = 0; $countData = []; try { $sqlCount = "SELECT count() AS c FROM {$tableFqn} {$whereSql}"; $stmtCount = isset($client) ? $client->select($sqlCount) : null; $r = $stmtCount ? $stmtCount->rows() : []; $totalCount = isset($r[0]['c']) ? (int)$r[0]['c'] : 0; } catch (\Throwable $e) { $countData = ['error' => $e->getMessage()]; } // Вычисляем количество страниц $totalPages = $totalCount > 0 ? (int)ceil($totalCount / $perPage) : 1; $currentQueryParams = [ 'from' => $_GET['from'] ?? '', 'to' => $_GET['to'] ?? '', 'from_event' => $_GET['from_event'] ?? '', 'to_event' => $_GET['to_event'] ?? '', 'trace_id' => $traceId, 'offset_id' => $offsetId, 'q' => $q, 'status_filter' => $statusFilter, 'sort_field' => $_GET['sort_field'] ?? '', 'sort' => $_GET['sort'] ?? '', 'per_page' => $perPage, ]; $APPLICATION->SetTitle('Логи ClickHouse'); require_once $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/prolog_admin_after.php'; ?> <style> .gesar-chlogs { font-family: system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, 'Helvetica Neue', Arial, 'Noto Sans', 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; margin: 20px; } .gesar-chlogs h1 { margin-bottom: 8px; } .gesar-chlogs form { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 8px; align-items: end; } .gesar-chlogs label { font-size: 12px; color: #555; display: block; margin-bottom: 4px; } .gesar-chlogs input[type="text"], .gesar-chlogs input[type="datetime-local"], .gesar-chlogs select { width: 100%; padding: 6px 8px; border: 1px solid #ccc; border-radius: 4px; } .gesar-chlogs button { padding: 8px 12px; background: #2563eb; color: #fff; border: 0; border-radius: 4px; cursor: pointer; } .gesar-chlogs button:hover { background: #1e40af; } .gesar-chlogs a.btn { display:inline-block; padding:8px 12px; background:#6b7280; color:#fff; border-radius:4px; text-decoration:none; } .gesar-chlogs a.btn:hover { background:#4b5563; } .gesar-chlogs .stats { margin: 16px 0; display: flex; gap: 16px; flex-wrap: wrap; } .gesar-chlogs .stat { background: #f8fafc; border: 1px solid #e5e7eb; border-radius: 6px; padding: 10px 12px; } .gesar-chlogs table { width: 100%; border-collapse: collapse; margin-top: 10px; } .gesar-chlogs th, .gesar-chlogs td { padding: 8px; border-bottom: 1px solid #eee; vertical-align: top; } .gesar-chlogs th { text-align: left; background: #f1f5f9; position: sticky; top: 0; z-index: 1; } .gesar-chlogs .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; font-size: 12px; } .gesar-chlogs .pill { display: inline-block; padding: 2px 6px; border-radius: 999px; font-size: 12px; color: #fff; } .gesar-chlogs .ok { background: #16a34a; } .gesar-chlogs .warn { background: #f97316; } .gesar-chlogs .err { background: #dc2626; } .gesar-chlogs .skipped { background: #eab308; } .gesar-chlogs .error-details { margin-top: 8px; padding: 8px; background: #fef2f2; border-left: 3px solid #dc2626; border-radius: 4px; } .gesar-chlogs .error-message { font-weight: bold; color: #991b1b; margin-bottom: 4px; } .gesar-chlogs .error-trace-btn { display: inline-block; margin-top: 4px; padding: 4px 8px; background: #991b1b; color: #fff; border: none; border-radius: 3px; cursor: pointer; font-size: 11px; } .gesar-chlogs .error-trace-btn:hover { background: #7f1d1d; } .gesar-chlogs .modal { display: none; position: fixed; z-index: 1000; left: 0; top: 0; width: 100%; height: 100%; overflow: auto; background-color: rgba(0,0,0,0.6); } .gesar-chlogs .modal-content { background-color: #fefefe; margin: 5% auto; padding: 20px; border: 1px solid #888; border-radius: 8px; width: 90%; max-width: 1000px; max-height: 80vh; overflow-y: auto; } .gesar-chlogs .modal-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px; border-bottom: 2px solid #dc2626; padding-bottom: 10px; } .gesar-chlogs .modal-header h3 { margin: 0; color: #991b1b; } .gesar-chlogs .modal-close { color: #aaa; font-size: 28px; font-weight: bold; cursor: pointer; line-height: 20px; } .gesar-chlogs .modal-close:hover, .gesar-chlogs .modal-close:focus { color: #000; } .gesar-chlogs .modal-trace { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace; font-size: 12px; color: #374151; white-space: pre-wrap; background: #f9fafb; padding: 15px; border-radius: 4px; border: 1px solid #e5e7eb; } .gesar-chlogs .footer { margin-top: 20px; font-size: 12px; color: #666; } .gesar-chlogs .grid { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; } .gesar-chlogs .box { border: 1px solid #e5e7eb; border-radius: 6px; padding: 8px; background: #fcfcfd; } .gesar-chlogs .bucket { font-size: 12px; color: #374151; } .gesar-chlogs .pagination { margin: 20px 0; display: flex; gap: 4px; align-items: center; flex-wrap: wrap; } .gesar-chlogs .pagination a, .gesar-chlogs .pagination span { display: inline-block; padding: 6px 12px; border: 1px solid #e5e7eb; border-radius: 4px; text-decoration: none; color: #374151; background: #fff; } .gesar-chlogs .pagination a:hover { background: #f1f5f9; } .gesar-chlogs .pagination .current { background: #2563eb; color: #fff; border-color: #2563eb; font-weight: bold; } .gesar-chlogs .pagination .disabled { opacity: 0.5; cursor: not-allowed; pointer-events: none; } .gesar-chlogs .pagination-info { font-size: 14px; color: #666; margin-left: 16px; } </style> <div class="gesar-chlogs"> <?php if (!empty($_GET['debug'])): ?> <pre style="background:#111;color:#eee;padding:8px;border-radius:6px;overflow:auto"> CLICKHOUSE_HOST = <?=htmlspecialchars($CH_HOST)?> CLICKHOUSE_DATABASE = <?=htmlspecialchars($CH_DB)?> CLICKHOUSE_TABLE = <?=htmlspecialchars($CH_TABLE)?> CLICKHOUSE_USER = <?=htmlspecialchars($CH_USER)?> </pre> <?php endif; ?> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px;"> <h1 style="margin: 0;">Логи Kafka → ClickHouse</h1> <div style="display: flex; gap: 10px;"> <a href="gesar_chlogs_manual_import.php" class="btn" style="background: #8b5cf6; text-decoration: none;"> 🔄 Ручной импорт </a> <a href="gesar_chlogs_offset.php" class="btn" style="background: #f97316; text-decoration: none;"> ⚙️ Offset </a> </div> </div> <?php if ($isAdmin): ?> <!-- Exchange Control Panel --> <div id="exchange-control" style="margin-bottom: 20px; padding: 15px; border-radius: 8px; <?php if ($needsAction) { echo 'background: #fff3cd; border: 2px solid #ffc107;'; } elseif ($isPaused && !$needsAction) { echo 'background: #d1ecf1; border: 2px solid #17a2b8;'; } else { echo 'background: #d4edda; border: 2px solid #28a745;'; } ?>"> <div style="display: flex; justify-content: space-between; align-items: center;"> <div> <strong style="font-size: 16px;"> <?php if ($needsAction): ?> ⚠️ ОБМЕН ПРИОСТАНОВЛЕН - ТРЕБУЕТСЯ ДЕЙСТВИЕ <?php elseif ($isPaused): ?> <?php $resolution = $pauseData['resolution'] ?? null; if ($resolution === 'skip') { echo '⏭️ СООБЩЕНИЕ ПРОПУЩЕНО'; } elseif ($resolution === 'continue') { echo '✅ ОБМЕН ПРОДОЛЖЕН'; } else { echo '⏸️ ОБМЕН ПРИОСТАНОВЛЕН'; } ?> <?php else: ?> ✅ ОБМЕН РАБОТАЕТ НОРМАЛЬНО <?php endif; ?> </strong> <?php if ($pauseData): ?> <div style="margin-top: 8px; font-size: 13px; color: #666;"> <div><strong>Время:</strong> <?= htmlspecialchars($pauseData['timestamp'] ?? 'Неизвестно') ?></div> <?php if (!empty($pauseData['reason'])): ?> <div><strong>Причина:</strong> <?= htmlspecialchars($pauseData['reason']) ?></div> <?php endif; ?> <?php if (!empty($pauseData['resolved_at'])): ?> <div><strong>Решено:</strong> <?= htmlspecialchars($pauseData['resolved_at']) ?> (ID: <?= (int)($pauseData['resolved_by'] ?? 0) ?>)</div> <?php endif; ?> </div> <?php if ($needsAction && !empty($pauseData['context'])): ?> <details style="margin-top: 10px;"> <summary style="cursor: pointer; font-size: 13px; color: #666;">Показать детали</summary> <pre style="background: #fff; padding: 10px; border-radius: 4px; margin-top: 8px; font-size: 11px; overflow-x: auto;"><?= htmlspecialchars(json_encode($pauseData['context'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) ?></pre> <?php if (!empty($pauseData['message_data'])): ?> <pre style="background: #fff; padding: 10px; border-radius: 4px; margin-top: 8px; font-size: 11px; overflow-x: auto;"><?= htmlspecialchars(json_encode($pauseData['message_data'], JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE)) ?></pre> <?php endif; ?> </details> <?php endif; ?> <?php endif; ?> </div> <?php if ($needsAction): ?> <div style="display: flex; gap: 8px;"> <button onclick="performAction('continue')" class="btn" style="background: #28a745; padding: 10px 16px;"> ✅ Принять </button> <button onclick="performAction('skip')" class="btn" style="background: #ffc107; color: #333; padding: 10px 16px;"> ⏭️ Пропустить </button> </div> <?php endif; ?> </div> <div id="action-message" style="margin-top: 10px; display: none;"></div> </div> <?php endif; ?> <form method="get"> <div> <label>Дата обработки от</label> <input type="datetime-local" name="from" value="<?=htmlspecialchars($procFrom ? (new DateTimeImmutable($procFrom))->format('Y-m-d\TH:i') : '')?>"> </div> <div> <label>Дата обработки до</label> <input type="datetime-local" name="to" value="<?=htmlspecialchars($procTo ? (new DateTimeImmutable($procTo))->format('Y-m-d\TH:i') : '')?>"> </div> <div> <label>Дата сообщения от</label> <input type="datetime-local" name="from_event" value="<?=htmlspecialchars($eventFrom ? (new DateTimeImmutable($eventFrom))->format('Y-m-d\TH:i') : '')?>"> </div> <div> <label>Дата сообщения до</label> <input type="datetime-local" name="to_event" value="<?=htmlspecialchars($eventTo ? (new DateTimeImmutable($eventTo))->format('Y-m-d\TH:i') : '')?>"> </div> <div> <label>Trace ID</label> <input type="text" name="trace_id" value="<?=htmlspecialchars($traceId)?>" placeholder="UUID"> </div> <div> <label>Kafka Offset</label> <input type="text" name="offset_id" value="<?=htmlspecialchars($offsetId)?>" placeholder="число"> </div> <div> <label>Поиск в теле</label> <input type="text" name="q" value="<?=htmlspecialchars($q)?>" placeholder="подстрока"> </div> <div> <label>Статус</label> <select name="status_filter"> <option value="" <?=$statusFilter===''?'selected':''?>>Все</option> <option value="success" <?=$statusFilter==='success'?'selected':''?>>Успех</option> <option value="warning" <?=$statusFilter==='warning'?'selected':''?>>Предупреждение</option> <option value="error" <?=$statusFilter==='error'?'selected':''?>>Ошибка</option> <option value="skipped" <?=$statusFilter==='skipped'?'selected':''?>>Пропущено</option> </select> </div> <div> <label>Поле сортировки</label> <select name="sort_field"> <option value="processed_time" <?=$sortField==='processed_time'?'selected':''?>>По дате</option> <option value="status" <?=$sortField==='status'?'selected':''?>>По статусу</option> </select> </div> <div> <label>Направление</label> <select name="sort"> <option value="desc" <?=$sort==='DESC'?'selected':''?>>↓ (убыв)</option> <option value="asc" <?=$sort==='ASC'?'selected':''?>>↑ (возр)</option> </select> </div> <div> <button type="submit">Фильтровать</button> <?php $hasFilters = ($procFrom || $procTo || $eventFrom || $eventTo || $traceId !== '' || $offsetId !== '' || $q !== '' || $statusFilter !== ''); if ($hasFilters): ?> <a class="btn" href="<?=htmlspecialchars($_SERVER['PHP_SELF'])?>">Очистить</a> <?php endif; ?> </div> </form> <?php $topErrors = []; foreach ([['Rows',$rowsData], ['Count',$countData]] as [$name,$res]) { if (isset($res['error'])) { $topErrors[] = $name . ': ' . ($res['error'] ?? ''); } } ?> <?php if (!empty($topErrors)): ?> <div style="margin:12px 0; padding:10px; border:1px solid #fecaca; background:#fef2f2; color:#7f1d1d"> <b>Ошибки запросов:</b><br> <?=nl2br(htmlspecialchars(implode("\n", $topErrors)))?> </div> <?php endif; ?> <h3>Сообщения</h3> <table> <thead> <tr> <th>Обработано</th> <th>Время сообщения</th> <th>Offset</th> <th>Trace ID</th> <th>Статус</th> <th>Тело (Kafka)</th> <th>Ошибка</th> </tr> </thead> <tbody> <?php if (isset($rowsData['error'])): ?> <tr><td colspan="7" style="color:#b91c1c">Ошибка запроса: <?=htmlspecialchars($rowsData['error'])?></td></tr> <?php else: ?> <?php $rowsArr = $rowsArr ?? ($rowsData['data'] ?? []); if (empty($rowsArr) && $totalCount > 0) { echo '<tr><td colspan="7">Данных в выбранном диапазоне/фильтрах нет. Попробуйте расширить диапазон или очистить фильтры.</td></tr>'; } ?> <?php foreach ($rowsArr as $row): ?> <?php $pt = $row['processed_time'] ?? ($row[0] ?? ''); $et = $row['event_time'] ?? ($row[1] ?? ''); $tr = $row['trace_id'] ?? ($row[2] ?? ''); $st = $row['status'] ?? ($row[3] ?? ''); $body = $row['body'] ?? ($row[4] ?? ''); $errMsg = $row['error_message'] ?? ($row[5] ?? ''); $errTrace = $row['error_trace'] ?? ($row[6] ?? ''); $kOffset = $row['kafka_offset'] ?? ($row[7] ?? 0); ?> <tr> <td class="mono"><?=htmlspecialchars($pt)?></td> <td class="mono"><?=htmlspecialchars($et)?></td> <td class="mono"><?=number_format($kOffset, 0, '.', ' ')?></td> <td class="mono"> <?=htmlspecialchars($tr)?> <?php $fullLogId = 'fulllog_' . md5($tr . $pt); ?> <br><button class="error-trace-btn" onclick="loadFullTrace('<?=htmlspecialchars($tr)?>', '<?=$fullLogId?>')" style="margin-top: 4px;"> 📝 Полный трейс </button> <a href="gesar_chlogs_manual_import.php?trace_id=<?=urlencode($tr)?>" class="error-trace-btn" style="margin-top: 4px; display: inline-block; text-decoration: none;"> 🔄 Переобработать </a> <div id="<?=$fullLogId?>" class="modal"> <div class="modal-content"> <div class="modal-header"> <h3>📝 Полный лог трейса: <?=htmlspecialchars(substr($tr, 0, 8))?>...</h3> <span class="modal-close" onclick="closeTraceModal('<?=$fullLogId?>')">×</span> </div> <div class="modal-trace" id="<?=$fullLogId?>_content">Загрузка...</div> </div> </div> </td> <td class="mono"><?php if ($st === 'success') { $cls = 'ok'; } elseif ($st === 'warning') { $cls = 'warn'; } elseif ($st === 'skipped') { $cls = 'skipped'; } else { $cls = 'err'; } ?><span class="pill <?=$cls?>"><?=htmlspecialchars($st)?></span></td> <td class="mono"><?=nl2br(htmlspecialchars($body))?></td> <td class="mono"> <?php if ($st === 'error' && !empty($errMsg)): ?> <div class="error-details"> <div class="error-message">❌ <?=htmlspecialchars($errMsg)?></div> <?php if (!empty($errTrace)): ?> <?php $traceId = 'trace_' . md5($tr . $pt); ?> <button class="error-trace-btn" onclick="showTraceModal('<?=$traceId?>')"> 📋 Показать трейс </button> <div id="<?=$traceId?>" class="modal"> <div class="modal-content"> <div class="modal-header"> <h3>🔍 Stack Trace</h3> <span class="modal-close" onclick="closeTraceModal('<?=$traceId?>')">×</span> </div> <div class="modal-trace"><?=htmlspecialchars($errTrace)?></div> </div> </div> <?php endif; ?> </div> <?php elseif ($st === 'warning' && !empty($errMsg)): ?> <div style="padding: 8px; background: #ffedd5; border-left: 3px solid #f97316; border-radius: 4px;"> <div style="color: #9a3412; font-weight: 500;">⚠️ <?=htmlspecialchars($errMsg)?></div> </div> <?php elseif ($st === 'skipped' && !empty($errMsg)): ?> <div style="padding: 8px; background: #fef9c3; border-left: 3px solid #eab308; border-radius: 4px;"> <div style="color: #854d0e; font-weight: 500;">⊘ <?=htmlspecialchars($errMsg)?></div> </div> <?php else: ?> <span style="color: #9ca3af;">—</span> <?php endif; ?> </td> </tr> <?php endforeach; ?> <?php endif; ?> </tbody> </table> <?php if ($totalPages > 1): ?> <div class="pagination"> <?php // Предыдущая страница if ($page > 1) { echo '<a href="' . htmlspecialchars(gesar_chlogs_build_query_url($currentQueryParams, ['page' => $page - 1])) . '">← Предыдущая</a>'; } else { echo '<span class="disabled">← Предыдущая</span>'; } // Показываем страницы с умной навигацией $range = 2; // Показываем по 2 страницы с каждой стороны от текущей $start = max(1, $page - $range); $end = min($totalPages, $page + $range); // Первая страница if ($start > 1) { echo '<a href="' . htmlspecialchars(gesar_chlogs_build_query_url($currentQueryParams, ['page' => 1])) . '">1</a>'; if ($start > 2) { echo '<span>...</span>'; } } // Страницы в диапазоне for ($i = $start; $i <= $end; $i++) { if ($i == $page) { echo '<span class="current">' . $i . '</span>'; } else { echo '<a href="' . htmlspecialchars(gesar_chlogs_build_query_url($currentQueryParams, ['page' => $i])) . '">' . $i . '</a>'; } } // Последняя страница if ($end < $totalPages) { if ($end < $totalPages - 1) { echo '<span>...</span>'; } echo '<a href="' . htmlspecialchars(gesar_chlogs_build_query_url($currentQueryParams, ['page' => $totalPages])) . '">' . $totalPages . '</a>'; } // Следующая страница if ($page < $totalPages) { echo '<a href="' . htmlspecialchars(gesar_chlogs_build_query_url($currentQueryParams, ['page' => $page + 1])) . '">Следующая →</a>'; } else { echo '<span class="disabled">Следующая →</span>'; } ?> <span class="pagination-info"> Страница <?=$page?> из <?=$totalPages?> | Показано <?=count($rowsArr)?> из <?=number_format($totalCount, 0, '.', ' ')?> </span> </div> <?php endif; ?> <div class="footer"> Источник данных: ClickHouse (таблица <code><?=htmlspecialchars($tableFqn)?></code>) </div> <?php if ($isAdmin): ?> <script> function performAction(action) { const messageContainer = document.getElementById('action-message'); const buttons = document.querySelectorAll('#exchange-control button'); // Блокируем кнопки buttons.forEach(btn => btn.disabled = true); // Показываем сообщение о загрузке messageContainer.style.display = 'block'; messageContainer.style.padding = '10px'; messageContainer.style.borderRadius = '4px'; messageContainer.style.background = '#e7f3ff'; messageContainer.style.color = '#004085'; messageContainer.textContent = 'Обработка...'; // Отправляем запрос fetch(window.location.href, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: 'action=' + encodeURIComponent(action) }) .then(response => { console.log('Response status:', response.status); return response.text(); }) .then(text => { console.log('Response text:', text); try { const data = JSON.parse(text); console.log('Parsed data:', data); if (data.success) { messageContainer.style.background = '#d4edda'; messageContainer.style.color = '#155724'; messageContainer.textContent = '✅ ' + data.message; // Перезагружаем страницу через 1.5 секунды setTimeout(() => location.reload(), 1500); } else { messageContainer.style.background = '#f8d7da'; messageContainer.style.color = '#721c24'; messageContainer.textContent = '❌ ' + data.message; buttons.forEach(btn => btn.disabled = false); } } catch (e) { console.error('JSON parse error:', e); messageContainer.style.background = '#f8d7da'; messageContainer.style.color = '#721c24'; messageContainer.textContent = '❌ Ошибка парсинга ответа: ' + text.substring(0, 100); buttons.forEach(btn => btn.disabled = false); } }) .catch(error => { console.error('Fetch error:', error); messageContainer.style.background = '#f8d7da'; messageContainer.style.color = '#721c24'; messageContainer.textContent = '❌ Ошибка при выполнении действия: ' + error.message; buttons.forEach(btn => btn.disabled = false); }); } // Автообновление статуса каждые 15 секунд (только если требуется действие) <?php if ($needsAction): ?> setInterval(() => { fetch(window.location.href, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, body: 'action=get_status' }) .then(response => response.json()) .then(data => { if (data.success && !data.data.needs_action) { // Статус изменился, перезагружаем страницу location.reload(); } }) .catch(error => console.error('Error checking status:', error)); }, 15000); <?php endif; ?> </script> <?php endif; ?> <script> // Функции модального окна доступны всегда (не только для админов) function showTraceModal(modalId) { const modal = document.getElementById(modalId); if (modal) { modal.style.display = 'block'; modal.onclick = function(event) { if (event.target === modal) { closeTraceModal(modalId); } }; } } function closeTraceModal(modalId) { const modal = document.getElementById(modalId); if (modal) { modal.style.display = 'none'; } } // AJAX загрузка полного трейса function loadFullTrace(traceId, modalId) { const modal = document.getElementById(modalId); const content = document.getElementById(modalId + '_content'); if (!modal || !content) { return; } // Открываем модальное окно showTraceModal(modalId); // Показываем индикатор загрузки content.textContent = 'Загрузка полного лога...'; content.style.color = '#666'; // Загружаем данные через AJAX fetch('?action=get_full_trace&trace_id=' + encodeURIComponent(traceId)) .then(response => response.json()) .then(data => { console.log('Full trace response:', data); if (data.success) { if (data.data && data.data.trim() !== '') { content.textContent = data.data; content.style.color = '#374151'; } else { content.textContent = 'Полный лог отсутствует для данного trace_id'; content.style.color = '#999'; } } else { let errorMsg = 'Ошибка загрузки: ' + (data.message || 'Неизвестная ошибка'); if (data.debug) { errorMsg += '\n\nОтладка:\n' + JSON.stringify(data.debug, null, 2); } content.textContent = errorMsg; content.style.color = '#dc2626'; } }) .catch(error => { content.textContent = 'Ошибка загрузки: ' + error.message; content.style.color = '#dc2626'; console.error('Error loading full trace:', error); }); } // Закрытие по ESC document.addEventListener('keydown', function(event) { if (event.key === 'Escape') { const openModals = document.querySelectorAll('.modal[style*="display: block"]'); openModals.forEach(modal => { modal.style.display = 'none'; }); } }); </script> </div> <?php require_once $_SERVER['DOCUMENT_ROOT'] . '/bitrix/modules/main/include/epilog_admin.php'; ?>