From 1f0aea0038a98d6e3f791e3a42020198e4010a7f Mon Sep 17 00:00:00 2001 From: Meni Yakove Date: Thu, 20 Nov 2025 11:53:34 +0200 Subject: [PATCH 1/4] feat(ui): revamp log viewer with enhanced filtering, layout, and export features - Add collapsible side panel for filters - Implement date range filtering - Switch to table layout for better readability - Align filter inputs and labels consistently - Add support for structured JSON log export - Unify log entry rendering across main view and modals - Suppress noisy MCP ClosedResourceError logs --- webhook_server/app.py | 18 ++ webhook_server/tests/test_log_api.py | 10 +- webhook_server/web/log_viewer.py | 32 ++- webhook_server/web/static/css/log_viewer.css | 246 ++++++++++++++++--- webhook_server/web/static/js/log_viewer.js | 111 ++++++--- webhook_server/web/templates/log_viewer.html | 170 +++++++------ 6 files changed, 432 insertions(+), 155 deletions(-) diff --git a/webhook_server/app.py b/webhook_server/app.py index b89543928..f9d4bc307 100644 --- a/webhook_server/app.py +++ b/webhook_server/app.py @@ -1,6 +1,7 @@ import asyncio import ipaddress import json +import logging import os from collections.abc import AsyncGenerator from contextlib import asynccontextmanager @@ -59,6 +60,20 @@ mcp: Any | None = None +class IgnoreMCPClosedResourceErrorFilter(logging.Filter): + """Filter to suppress ClosedResourceError logs from MCP server.""" + + def filter(self, record: logging.LogRecord) -> bool: + # Check for the specific error message from mcp.server.streamable_http + if "Error in message router" in record.getMessage(): + if record.exc_info: + exc_type, _, _ = record.exc_info + # Check if it's a ClosedResourceError (from anyio) + if exc_type and "ClosedResourceError" in exc_type.__name__: + return False + return True + + # Helper function to wrap the imported gate_by_allowlist_ips with ALLOWED_IPS async def gate_by_allowlist_ips_dependency(request: Request) -> None: """Dependency wrapper for IP allowlist gating.""" @@ -79,6 +94,9 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]: global _lifespan_http_client _lifespan_http_client = httpx.AsyncClient(timeout=HTTP_TIMEOUT_SECONDS) + # Apply filter to MCP logger to suppress client disconnect noise + logging.getLogger("mcp.server.streamable_http").addFilter(IgnoreMCPClosedResourceErrorFilter()) + try: LOGGER.info("Application starting up...") diff --git a/webhook_server/tests/test_log_api.py b/webhook_server/tests/test_log_api.py index 39908e35a..da072622d 100644 --- a/webhook_server/tests/test_log_api.py +++ b/webhook_server/tests/test_log_api.py @@ -469,10 +469,16 @@ def test_get_log_directory(self, controller): def test_generate_json_export(self, controller, sample_log_entries): """Test JSON export generation.""" - result = controller._generate_json_export(sample_log_entries) + filters = {"level": "INFO"} + result = controller._generate_json_export(sample_log_entries, filters) assert isinstance(result, str) parsed = json.loads(result) - assert len(parsed) == 3 + assert "export_metadata" in parsed + assert "log_entries" in parsed + assert parsed["export_metadata"]["filters_applied"] == filters + assert parsed["export_metadata"]["total_entries"] == 3 + assert len(parsed["log_entries"]) == 3 + assert parsed["log_entries"][0]["level"] == "INFO" def test_analyze_pr_flow_empty_entries(self, controller): """Test PR flow analysis with empty entries.""" diff --git a/webhook_server/web/log_viewer.py b/webhook_server/web/log_viewer.py index 19b88dc74..00e4958ca 100644 --- a/webhook_server/web/log_viewer.py +++ b/webhook_server/web/log_viewer.py @@ -335,8 +335,24 @@ def export_logs( if len(filtered_entries) >= limit: break + # Collect filters for metadata + filters = { + "hook_id": hook_id, + "pr_number": pr_number, + "repository": repository, + "event_type": event_type, + "github_user": github_user, + "level": level, + "start_time": start_time.isoformat() if start_time else None, + "end_time": end_time.isoformat() if end_time else None, + "search": search, + "limit": limit, + } + # Remove None values + filters = {k: v for k, v in filters.items() if v is not None} + # Generate JSON export content - content = self._generate_json_export(filtered_entries) + content = self._generate_json_export(filtered_entries, filters) media_type = "application/json" filename = f"webhook_logs_{datetime.datetime.now().strftime('%Y%m%d_%H%M%S')}.json" @@ -840,16 +856,26 @@ def _get_fallback_html(self) -> str: """ - def _generate_json_export(self, entries: list[LogEntry]) -> str: + def _generate_json_export(self, entries: list[LogEntry], filters: dict[str, Any] | None = None) -> str: """Generate JSON export content from log entries. Args: entries: List of log entries to export + filters: Dictionary of filters applied to the export Returns: JSON content as string """ - return json.dumps([entry.to_dict() for entry in entries], indent=2) + export_data = { + "export_metadata": { + "generated_at": datetime.datetime.now(datetime.UTC).isoformat(), + "filters_applied": filters or {}, + "total_entries": len(entries), + "export_format": "json", + }, + "log_entries": [entry.to_dict() for entry in entries], + } + return json.dumps(export_data, indent=2) def _analyze_pr_flow(self, entries: list[LogEntry], hook_id: str) -> dict[str, Any]: """Analyze PR workflow stages from log entries. diff --git a/webhook_server/web/static/css/log_viewer.css b/webhook_server/web/static/css/log_viewer.css index 7a550d1b6..da3b2fff2 100644 --- a/webhook_server/web/static/css/log_viewer.css +++ b/webhook_server/web/static/css/log_viewer.css @@ -104,37 +104,184 @@ body { .theme-toggle:hover { background: var(--button-hover); } +/* Control Panel Styles */ +.control-panel { + background: var(--container-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + margin-bottom: 20px; + overflow: hidden; + transition: all 0.3s ease; +} + +.panel-header { + padding: 10px 20px; + background: var(--tag-bg); + border-bottom: 1px solid var(--border-color); + display: flex; + justify-content: space-between; + align-items: center; +} + +.panel-header h3 { + margin: 0; + font-size: 16px; + color: var(--text-color); +} + +.btn-icon { + background: none; + border: none; + cursor: pointer; + font-size: 16px; + color: var(--text-secondary); + transition: transform 0.3s ease; +} + +.filters-container { + padding: 20px; + transition: max-height 0.3s ease, opacity 0.3s ease; +} + +.filters-container.collapsed { + max-height: 0; + opacity: 0; + padding: 0 20px; + overflow: hidden; +} + .filters { display: grid; - grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); - gap: 10px; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 15px; margin-bottom: 20px; } + .filter-group { display: flex; flex-direction: column; } + .filter-group label { + margin-bottom: 5px; font-weight: bold; - margin-bottom: 3px; font-size: 14px; - color: var(--text-color); } + .filter-group input, .filter-group select { padding: 8px; border: 1px solid var(--input-border); + background-color: var(--input-bg); + color: var(--text-color); border-radius: 4px; - background: var(--input-bg); + width: 100%; + box-sizing: border-box; +} + +.controls-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 15px; + padding-bottom: 15px; + border-bottom: 1px solid var(--border-color); +} + +.controls-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.log-stats { + display: flex; + gap: 20px; + font-size: 14px; + color: var(--text-secondary); +} + +.log-stats strong { color: var(--text-color); - transition: - background-color 0.3s ease, - border-color 0.3s ease; } -.log-entries { + +/* Toggle Switch */ +.toggle-group { + display: flex; + align-items: center; + gap: 10px; +} + +.toggle-switch { + position: relative; + display: inline-block; + width: 40px; + height: 20px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--input-border); + transition: .4s; + border-radius: 20px; +} + +.slider:before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + left: 2px; + bottom: 2px; + background-color: white; + transition: .4s; + border-radius: 50%; +} + +input:checked + .slider { + background-color: var(--button-bg); +} + +input:checked + .slider:before { + transform: translateX(20px); +} + +/* Log Header */ +.log-header-row { + display: flex; + padding: 10px; + background: var(--tag-bg); border: 1px solid var(--border-color); - border-radius: 4px; - min-height: 200px; + border-bottom: none; + border-radius: 4px 4px 0 0; + font-weight: bold; + font-size: 14px; + color: var(--text-secondary); +} + +.col-timestamp { + width: 180px; + flex-shrink: 0; +} +.col-level { + width: 80px; + flex-shrink: 0; + text-align: left; +} +.col-message { + flex: 1; + padding-left: 10px; } /* Loading skeleton styles */ @@ -392,43 +539,64 @@ body { display: none; max-width: 300px; } +.log-entries { + border: 1px solid var(--border-color); + border-radius: 0 0 4px 4px; + min-height: 200px; + background: var(--container-bg); +} + .log-entry { - padding: 10px; + display: flex; + padding: 8px 10px; border-bottom: 1px solid var(--log-entry-border); font-family: monospace; - font-size: 14px; - transition: background-color 0.3s ease; -} -.log-entry:last-child { - border-bottom: none; -} -.log-entry.INFO { - background-color: var(--log-info-bg); -} -.log-entry.ERROR { - background-color: var(--log-error-bg); -} -.log-entry.WARNING { - background-color: var(--log-warning-bg); -} -.log-entry.DEBUG { - background-color: var(--log-debug-bg); -} -.log-entry.STEP { - background-color: var(--log-step-bg); -} -.log-entry.SUCCESS { - background-color: var(--log-success-bg); + font-size: 13px; + transition: background-color 0.2s ease; + align-items: center; } -.timestamp { + +.log-entry .timestamp { + width: 180px; + flex-shrink: 0; color: var(--timestamp-color); } -.level { + +.log-entry .level { + width: 80px; + flex-shrink: 0; + text-align: left; font-weight: bold; + border-radius: 4px; + padding: 2px 0; + font-size: 11px; } -.message { - margin-left: 10px; + +.log-entry .message { + flex: 1; + margin-left: 0; + word-break: break-word; + overflow-wrap: anywhere; +} + +.log-entry.INFO .level { background-color: var(--level-info-bg); color: var(--level-info-border); } +.log-entry.ERROR .level { background-color: var(--log-error-bg); color: #dc3545; } +.log-entry.WARNING .level { background-color: var(--log-warning-bg); color: #856404; } +.log-entry.DEBUG .level { background-color: var(--tag-bg); color: var(--text-secondary); } +.log-entry.STEP .level { background-color: var(--log-step-bg); color: #0056b3; } +.log-entry.SUCCESS .level { background-color: var(--log-success-bg); color: #155724; } + +.log-entry.INFO { background-color: transparent; } +.log-entry.ERROR { background-color: rgba(220, 53, 69, 0.05); } +.log-entry.WARNING { background-color: rgba(255, 193, 7, 0.05); } +.log-entry.DEBUG { background-color: transparent; } +.log-entry.STEP { background-color: rgba(13, 110, 253, 0.05); } +.log-entry.SUCCESS { background-color: rgba(25, 135, 84, 0.05); } + +.log-entry:hover { + background-color: var(--log-entry-border); } + .hook-id, .pr-number, .repository, diff --git a/webhook_server/web/static/js/log_viewer.js b/webhook_server/web/static/js/log_viewer.js index 679c45ffa..ef4218397 100644 --- a/webhook_server/web/static/js/log_viewer.js +++ b/webhook_server/web/static/js/log_viewer.js @@ -36,6 +36,8 @@ function connectWebSocket() { const user = document.getElementById("userFilter").value.trim(); const level = document.getElementById("levelFilter").value; const search = document.getElementById("searchFilter").value.trim(); + const startTime = document.getElementById("startTimeFilter").value; + const endTime = document.getElementById("endTimeFilter").value; if (hookId) filters.append("hook_id", hookId); if (prNumber) filters.append("pr_number", prNumber); @@ -43,6 +45,8 @@ function connectWebSocket() { if (user) filters.append("github_user", user); if (level) filters.append("level", level); if (search) filters.append("search", search); + if (startTime) filters.append("start_time", new Date(startTime).toISOString()); + if (endTime) filters.append("end_time", new Date(endTime).toISOString()); const wsUrl = `${protocol}//${window.location.host}/logs/ws${ filters.toString() ? "?" + filters.toString() : "" @@ -101,6 +105,15 @@ function addLogEntry(entry) { clearFilterCache(); // Clear cache when entries change renderLogEntriesOptimized(); + // Auto-scroll if enabled + const autoScrollToggle = document.getElementById("autoScrollToggle"); + if (autoScrollToggle && autoScrollToggle.checked) { + const container = document.getElementById("logEntries"); + if (container) { + container.scrollTop = 0; + } + } + // Update displayed count for real-time entries updateDisplayedCount(); } @@ -156,23 +169,25 @@ function createLogEntryElement(entry) { div.className = `log-entry ${safeLevel}`; - // Create timestamp - const timestamp = document.createElement("span"); + // Column 1: Timestamp + const timestamp = document.createElement("div"); timestamp.className = "timestamp"; timestamp.textContent = new Date(entry.timestamp).toLocaleString(); div.appendChild(timestamp); - // Create level - const level = document.createElement("span"); + // Column 2: Level + const level = document.createElement("div"); level.className = "level"; - level.textContent = `[${entry.level}]`; + level.textContent = entry.level; div.appendChild(level); - // Create message - const message = document.createElement("span"); - message.className = "message"; - message.textContent = entry.message; - div.appendChild(message); + // Column 3: Message and Metadata + const messageCol = document.createElement("div"); + messageCol.className = "message"; + + const messageText = document.createElement("span"); + messageText.textContent = entry.message + " "; // Add space for tags + messageCol.appendChild(messageText); // Create clickable hook ID link if present if (entry.hook_id) { @@ -185,14 +200,15 @@ function createLogEntryElement(entry) { hookLink.textContent = entry.hook_id; hookLink.title = "Click to view workflow"; hookLink.style.cursor = "pointer"; - hookLink.addEventListener("click", () => { + hookLink.addEventListener("click", (e) => { + e.stopPropagation(); showFlowModal(entry.hook_id); }); hookIdSpan.appendChild(hookLink); const closeBracket = document.createTextNode("]"); hookIdSpan.appendChild(closeBracket); - div.appendChild(hookIdSpan); + messageCol.appendChild(hookIdSpan); } // Add other metadata - make PR number clickable @@ -206,30 +222,32 @@ function createLogEntryElement(entry) { prLink.textContent = entry.pr_number; prLink.title = "Click to view all webhook flows for this PR"; prLink.style.cursor = "pointer"; - prLink.addEventListener("click", () => { + prLink.addEventListener("click", (e) => { + e.stopPropagation(); showPrModal(entry.pr_number); }); prSpan.appendChild(prLink); const closeBracket = document.createTextNode("]"); prSpan.appendChild(closeBracket); - div.appendChild(prSpan); + messageCol.appendChild(prSpan); } if (entry.repository) { const repoSpan = document.createElement("span"); repoSpan.className = "repository"; repoSpan.textContent = `[${entry.repository}]`; - div.appendChild(repoSpan); + messageCol.appendChild(repoSpan); } if (entry.github_user) { const userSpan = document.createElement("span"); userSpan.className = "user"; userSpan.textContent = `[User: ${entry.github_user}]`; - div.appendChild(userSpan); + messageCol.appendChild(userSpan); } + div.appendChild(messageCol); return div; } @@ -319,6 +337,8 @@ async function loadHistoricalLogs() { const level = document.getElementById("levelFilter").value; const search = document.getElementById("searchFilter").value.trim(); const limit = document.getElementById("limitFilter").value; + const startTime = document.getElementById("startTimeFilter").value; + const endTime = document.getElementById("endTimeFilter").value; // Use user-configured limit filters.append("limit", limit); @@ -328,6 +348,8 @@ async function loadHistoricalLogs() { if (user) filters.append("github_user", user); if (level) filters.append("level", level); if (search) filters.append("search", search); + if (startTime) filters.append("start_time", new Date(startTime).toISOString()); + if (endTime) filters.append("end_time", new Date(endTime).toISOString()); const response = await fetch(`/logs/api/entries?${filters.toString()}`); @@ -501,6 +523,8 @@ function exportLogs(format) { const level = document.getElementById("levelFilter").value; const search = document.getElementById("searchFilter").value.trim(); const limit = document.getElementById("limitFilter").value; + const startTime = document.getElementById("startTimeFilter").value; + const endTime = document.getElementById("endTimeFilter").value; if (hookId) filters.append("hook_id", hookId); if (prNumber) filters.append("pr_number", prNumber); @@ -508,6 +532,8 @@ function exportLogs(format) { if (user) filters.append("github_user", user); if (level) filters.append("level", level); if (search) filters.append("search", search); + if (startTime) filters.append("start_time", new Date(startTime).toISOString()); + if (endTime) filters.append("end_time", new Date(endTime).toISOString()); filters.append("limit", limit); filters.append("format", format); @@ -549,6 +575,8 @@ function clearFilters() { document.getElementById("userFilter").value = ""; document.getElementById("levelFilter").value = ""; document.getElementById("searchFilter").value = ""; + document.getElementById("startTimeFilter").value = ""; + document.getElementById("endTimeFilter").value = ""; document.getElementById("limitFilter").value = "1000"; // Reset to default // Reload data with cleared filters @@ -574,6 +602,12 @@ document document .getElementById("limitFilter") .addEventListener("change", debounceFilter); +document + .getElementById("startTimeFilter") + .addEventListener("change", debounceFilter); +document + .getElementById("endTimeFilter") + .addEventListener("change", debounceFilter); // Theme management function toggleTheme() { @@ -622,8 +656,30 @@ initializeTheme(); // Initialize connection status updateConnectionStatus(false); +// Toggle control panel +function togglePanel() { + const container = document.querySelector(".filters-container"); + const btn = document.getElementById("togglePanelBtn"); + + if (container.classList.contains("collapsed")) { + container.classList.remove("collapsed"); + btn.style.transform = "rotate(0deg)"; + btn.title = "Collapse Panel"; + } else { + container.classList.add("collapsed"); + btn.style.transform = "rotate(-90deg)"; + btn.title = "Expand Panel"; + } +} + // Initialize event listeners when DOM is ready function initializeEventListeners() { + // Panel toggle button + const togglePanelBtn = document.getElementById("togglePanelBtn"); + if (togglePanelBtn) { + togglePanelBtn.addEventListener("click", togglePanel); + } + // Theme toggle button const themeToggleBtn = document.getElementById("themeToggleBtn"); if (themeToggleBtn) { @@ -1655,27 +1711,8 @@ async function showStepLogsInModal(step, logsContainer) { // Render log entries data.entries.forEach((entry) => { - const logEntry = document.createElement("div"); - const allowed = ["DEBUG", "INFO", "WARNING", "ERROR", "STEP", "SUCCESS"]; - const safeLevel = allowed.includes(entry.level) ? entry.level : "INFO"; - logEntry.className = `log-entry ${safeLevel}`; - - const timestamp = document.createElement("span"); - timestamp.className = "timestamp"; - timestamp.textContent = new Date(entry.timestamp).toLocaleString(); - - const level = document.createElement("span"); - level.className = "level"; - level.textContent = ` [${entry.level}] `; - - const message = document.createElement("span"); - message.className = "message"; - message.textContent = entry.message; - - logEntry.appendChild(timestamp); - logEntry.appendChild(level); - logEntry.appendChild(message); - + // Reuse the main log entry creator for consistency + const logEntry = createLogEntryElement(entry); logsContainer.appendChild(logEntry); }); diff --git a/webhook_server/web/templates/log_viewer.html b/webhook_server/web/templates/log_viewer.html index 315a3a349..50b0eff59 100644 --- a/webhook_server/web/templates/log_viewer.html +++ b/webhook_server/web/templates/log_viewer.html @@ -27,86 +27,108 @@

GitHub Webhook Server - Log Viewer

Connecting... -
-
- Displayed: - 0 entries - Total Available: - 0 entries - Processed: - 0 entries +
+
+

Filters & Controls

+
-
-
- - - - - - -
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
-
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - -
-
- - +
+
+ Shown: 0 + Total: 0 + Scanned: 0 +
+
+ + Auto-scroll +
+
+ +
+ + + + + + +
+
+
Timestamp
+
Level
+
Message
+
+ +