From b97b77a622b76f204d21595cda1d679d1ac7c2fc Mon Sep 17 00:00:00 2001 From: Meni Yakove Date: Fri, 7 Nov 2025 12:03:58 +0200 Subject: [PATCH 01/34] Apply PR #878 changes: Add task correlation fields and structured logging - Add task_id, task_type, task_status fields to LogEntry dataclass - Add format_task_fields() and _sanitize_log_value() helper functions - Update log_viewer.py to use task correlation fields in timeline - Update all logger.step() and logger.success() calls to use format_task_fields() - Update CSS, JS, and HTML templates from PR #878 - Improve log parsing with enhanced regex patterns and timezone support - Add exception chaining and improve error handling in log_viewer.py - Fix mypy return type issues in github_api.py handler methods --- webhook_server/libs/github_api.py | 115 +- webhook_server/libs/log_parser.py | 88 +- webhook_server/utils/helpers.py | 46 +- webhook_server/web/log_viewer.py | 42 +- webhook_server/web/static/css/log_viewer.css | 646 ++++++- webhook_server/web/static/js/log_viewer.js | 1630 +++++++++++++----- webhook_server/web/templates/log_viewer.html | 226 ++- 7 files changed, 2153 insertions(+), 640 deletions(-) diff --git a/webhook_server/libs/github_api.py b/webhook_server/libs/github_api.py index b23ce068f..1d1320b05 100644 --- a/webhook_server/libs/github_api.py +++ b/webhook_server/libs/github_api.py @@ -36,6 +36,7 @@ ) from webhook_server.utils.helpers import ( extract_key_from_dict, + format_task_fields, get_api_with_highest_rate_limit, get_apis_and_tokes_from_config, get_github_repo_api, @@ -115,78 +116,152 @@ def __init__(self, hook_data: dict[Any, Any], headers: Headers, logger: logging. async def process(self) -> Any: event_log: str = f"Event type: {self.github_event}. event ID: {self.x_github_delivery}" - self.logger.step(f"{self.log_prefix} Starting webhook processing: {event_log}") # type: ignore + self.logger.step( # type: ignore[attr-defined] + f"{self.log_prefix} {format_task_fields('webhook_processing', 'webhook_routing', 'started')} " + f"Starting webhook processing: {event_log}", + ) if self.github_event == "ping": - self.logger.step(f"{self.log_prefix} Processing ping event") # type: ignore + self.logger.step( # type: ignore[attr-defined] + f"{self.log_prefix} {format_task_fields('webhook_processing', 'webhook_routing', 'processing')} " + f"Processing ping event", + ) self.logger.debug(f"{self.log_prefix} {event_log}") + self.logger.success( # type: ignore[attr-defined] + f"{self.log_prefix} {format_task_fields('webhook_processing', 'webhook_routing', 'completed')} " + f"Webhook processing completed successfully: ping event", + ) return {"status": requests.codes.ok, "message": "pong"} if self.github_event == "push": - self.logger.step(f"{self.log_prefix} Processing push event") # type: ignore + self.logger.step( # type: ignore[attr-defined] + f"{self.log_prefix} {format_task_fields('webhook_processing', 'webhook_routing', 'processing')} " + f"Processing push event", + ) self.logger.debug(f"{self.log_prefix} {event_log}") - return await PushHandler(github_webhook=self).process_push_webhook_data() + await PushHandler(github_webhook=self).process_push_webhook_data() + self.logger.success( # type: ignore[attr-defined] + f"{self.log_prefix} {format_task_fields('webhook_processing', 'webhook_routing', 'completed')} " + f"Webhook processing completed successfully: push event", + ) + return None if pull_request := await self.get_pull_request(): self.log_prefix = self.prepare_log_prefix(pull_request=pull_request) - self.logger.step(f"{self.log_prefix} Processing pull request event: {event_log}") # type: ignore + self.logger.step( # type: ignore[attr-defined] + f"{self.log_prefix} {format_task_fields('webhook_processing', 'webhook_routing', 'processing')} " + f"Processing pull request event: {event_log}", + ) self.logger.debug(f"{self.log_prefix} {event_log}") if pull_request.draft: - self.logger.step(f"{self.log_prefix} Pull request is draft, skipping processing") # type: ignore + self.logger.step( # type: ignore[attr-defined] + f"{self.log_prefix} {format_task_fields('webhook_processing', 'webhook_routing', 'processing')} " + f"Pull request is draft, skipping processing", + ) self.logger.debug(f"{self.log_prefix} Pull request is draft, doing nothing") return None - self.logger.step(f"{self.log_prefix} Initializing pull request data") # type: ignore + self.logger.step( # type: ignore[attr-defined] + f"{self.log_prefix} {format_task_fields('webhook_processing', 'webhook_routing', 'processing')} " + f"Initializing pull request data", + ) self.last_commit = await self._get_last_commit(pull_request=pull_request) self.parent_committer = pull_request.user.login self.last_committer = getattr(self.last_commit.committer, "login", self.parent_committer) if self.github_event == "issue_comment": - self.logger.step(f"{self.log_prefix} Initializing OWNERS file handler for issue comment") # type: ignore + self.logger.step( # type: ignore[attr-defined] + f"{self.log_prefix} {format_task_fields('webhook_processing', 'webhook_routing', 'processing')} " + f"Initializing OWNERS file handler for issue comment", + ) owners_file_handler = OwnersFileHandler(github_webhook=self) owners_file_handler = await owners_file_handler.initialize(pull_request=pull_request) - self.logger.step(f"{self.log_prefix} Processing issue comment with IssueCommentHandler") # type: ignore - return await IssueCommentHandler( + self.logger.step( # type: ignore[attr-defined] + f"{self.log_prefix} {format_task_fields('webhook_processing', 'webhook_routing', 'processing')} " + f"Processing issue comment with IssueCommentHandler", + ) + await IssueCommentHandler( github_webhook=self, owners_file_handler=owners_file_handler ).process_comment_webhook_data(pull_request=pull_request) + self.logger.success( # type: ignore[attr-defined] + f"{self.log_prefix} {format_task_fields('webhook_processing', 'webhook_routing', 'completed')} " + f"Webhook processing completed successfully: issue comment", + ) + return None elif self.github_event == "pull_request": - self.logger.step(f"{self.log_prefix} Initializing OWNERS file handler for pull request") # type: ignore + self.logger.step( # type: ignore[attr-defined] + f"{self.log_prefix} {format_task_fields('webhook_processing', 'webhook_routing', 'processing')} " + f"Initializing OWNERS file handler for pull request", + ) owners_file_handler = OwnersFileHandler(github_webhook=self) owners_file_handler = await owners_file_handler.initialize(pull_request=pull_request) - self.logger.step(f"{self.log_prefix} Processing pull request with PullRequestHandler") # type: ignore - return await PullRequestHandler( + self.logger.step( # type: ignore[attr-defined] + f"{self.log_prefix} {format_task_fields('webhook_processing', 'webhook_routing', 'processing')} " + f"Processing pull request with PullRequestHandler", + ) + await PullRequestHandler( github_webhook=self, owners_file_handler=owners_file_handler ).process_pull_request_webhook_data(pull_request=pull_request) + self.logger.success( # type: ignore[attr-defined] + f"{self.log_prefix} {format_task_fields('webhook_processing', 'webhook_routing', 'completed')} " + f"Webhook processing completed successfully: pull request", + ) + return None elif self.github_event == "pull_request_review": - self.logger.step(f"{self.log_prefix} Initializing OWNERS file handler for pull request review") # type: ignore + self.logger.step( # type: ignore[attr-defined] + f"{self.log_prefix} {format_task_fields('webhook_processing', 'webhook_routing', 'processing')} " + f"Initializing OWNERS file handler for pull request review", + ) owners_file_handler = OwnersFileHandler(github_webhook=self) owners_file_handler = await owners_file_handler.initialize(pull_request=pull_request) - self.logger.step(f"{self.log_prefix} Processing pull request review with PullRequestReviewHandler") # type: ignore - return await PullRequestReviewHandler( + self.logger.step( # type: ignore[attr-defined] + f"{self.log_prefix} {format_task_fields('webhook_processing', 'webhook_routing', 'processing')} " + f"Processing pull request review with PullRequestReviewHandler", + ) + await PullRequestReviewHandler( github_webhook=self, owners_file_handler=owners_file_handler ).process_pull_request_review_webhook_data( pull_request=pull_request, ) + self.logger.success( # type: ignore[attr-defined] + f"{self.log_prefix} {format_task_fields('webhook_processing', 'webhook_routing', 'completed')} " + f"Webhook processing completed successfully: pull request review", + ) + return None elif self.github_event == "check_run": - self.logger.step(f"{self.log_prefix} Initializing OWNERS file handler for check run") # type: ignore + self.logger.step( # type: ignore[attr-defined] + f"{self.log_prefix} {format_task_fields('webhook_processing', 'webhook_routing', 'processing')} " + f"Initializing OWNERS file handler for check run", + ) owners_file_handler = OwnersFileHandler(github_webhook=self) owners_file_handler = await owners_file_handler.initialize(pull_request=pull_request) - self.logger.step(f"{self.log_prefix} Processing check run with CheckRunHandler") # type: ignore + self.logger.step( # type: ignore[attr-defined] + f"{self.log_prefix} {format_task_fields('webhook_processing', 'webhook_routing', 'processing')} " + f"Processing check run with CheckRunHandler", + ) if await CheckRunHandler( github_webhook=self, owners_file_handler=owners_file_handler ).process_pull_request_check_run_webhook_data(pull_request=pull_request): if self.hook_data["check_run"]["name"] != CAN_BE_MERGED_STR: - self.logger.step(f"{self.log_prefix} Checking if pull request can be merged after check run") # type: ignore - return await PullRequestHandler( + self.logger.step( # type: ignore[attr-defined] + f"{self.log_prefix} {format_task_fields('webhook_processing', 'webhook_routing', 'processing')} " + f"Checking if pull request can be merged after check run", + ) + await PullRequestHandler( github_webhook=self, owners_file_handler=owners_file_handler ).check_if_can_be_merged(pull_request=pull_request) + self.logger.success( # type: ignore[attr-defined] + f"{self.log_prefix} {format_task_fields('webhook_processing', 'webhook_routing', 'completed')} " + f"Webhook processing completed successfully: check run", + ) + return None @property def add_api_users_to_auto_verified_and_merged_users(self) -> None: diff --git a/webhook_server/libs/log_parser.py b/webhook_server/libs/log_parser.py index 21c2b1f3f..e0f1a9954 100644 --- a/webhook_server/libs/log_parser.py +++ b/webhook_server/libs/log_parser.py @@ -3,9 +3,10 @@ import asyncio import datetime import re +from collections.abc import AsyncGenerator from dataclasses import dataclass from pathlib import Path -from typing import Any, AsyncGenerator +from typing import Any from simple_logger.logger import get_logger @@ -23,6 +24,9 @@ class LogEntry: repository: str | None = None pr_number: int | None = None github_user: str | None = None + task_id: str | None = None + task_type: str | None = None + task_status: str | None = None def to_dict(self) -> dict[str, Any]: """Convert LogEntry to dictionary for JSON serialization.""" @@ -36,6 +40,9 @@ def to_dict(self) -> dict[str, Any]: "repository": self.repository, "pr_number": self.pr_number, "github_user": self.github_user, + "task_id": self.task_id, + "task_type": self.task_type, + "task_status": self.task_status, } @@ -57,30 +64,46 @@ def __init__(self) -> None: # With PR: "{colored_repo} [{event}][{delivery_id}][{user}][PR {number}]: {message}" # Without PR: "{colored_repo} [{event}][{delivery_id}][{user}]: {message}" # Full log format: "timestamp logger level colored_repo [event][delivery_id][user][PR number]: message" - # Example: "2025-07-31T10:30:00.123000 GithubWebhook INFO repo-name [pull_request][abc123][user][PR 123]: Processing webhook" + # Example: "2025-07-31T10:30:00.123000 GithubWebhook INFO repo-name + # [pull_request][abc123][user][PR 123]: Processing webhook" + # Supports: + # - Optional fractional seconds + # - Optional timezone (Z or ±HH:MM format, e.g., +00:00, -05:00) + # - Flexible whitespace between fields + # - Logger names with dots/hyphens LOG_PATTERN = re.compile( - r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+) (\w+) (?:\x1b\[[\d;]*m)?(\w+)(?:\x1b\[[\d;]*m)? (.+)$" + r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?)\s+" + r"([\w.-]+)\s+(?:\x1b\[[\d;]*m)?([\w.-]+)(?:\x1b\[[\d;]*m)?\s+(.+)$" ) # Pattern to extract GitHub context from prepare_log_prefix format # Matches: colored_repo [event][delivery_id][user][PR number]: message GITHUB_CONTEXT_PATTERN = re.compile( - r"(?:\x1b\[[0-9;]*m)?([^\x1b\[\s]+)(?:\x1b\[[0-9;]*m)? \[([^\]]+)\]\[([^\]]+)\]\[([^\]]+)\](?:\[PR (\d+)\])?: (.+)" + r"(?:\x1b\[[0-9;]*m)?([^\x1b\[\s]+)(?:\x1b\[[0-9;]*m)? " + r"\[([^\]]+)\]\[([^\]]+)\]\[([^\]]+)\](?:\[PR (\d+)\])?: (.+)" ) ANSI_ESCAPE_PATTERN = re.compile(r"\x1b\[[0-9;]*m") + # Precompiled patterns for task field extraction (performance optimization) + TASK_ID_PATTERN = re.compile(r"\[task_id=([^\]]+)\]") + TASK_TYPE_PATTERN = re.compile(r"\[task_type=([^\]]+)\]") + TASK_STATUS_PATTERN = re.compile(r"\[task_status=([^\]]+)\]") + def is_workflow_step(self, entry: LogEntry) -> bool: """ - Check if a log entry is a workflow step (logger.step call). + Check if a log entry is a workflow milestone step. + + Only entries with task_id AND task_status are considered workflow milestones. + This filters out internal/initialization steps and only shows meaningful business events. Args: entry: LogEntry to check Returns: - True if this is a workflow step entry + True if this is a workflow milestone entry (has task_id and task_status) """ - return entry.level.upper() == "STEP" + return bool(entry.task_id and entry.task_status) def extract_workflow_steps(self, entries: list[LogEntry], hook_id: str) -> list[LogEntry]: """ @@ -116,24 +139,32 @@ def parse_log_entry(self, log_line: str) -> LogEntry | None: timestamp_str, logger_name, level, message = match.groups() # Parse ISO timestamp format: "2025-07-31T10:30:00.123000" + # Handle 'Z' timezone suffix which fromisoformat doesn't accept try: - timestamp = datetime.datetime.fromisoformat(timestamp_str) + normalized_timestamp = timestamp_str.replace("Z", "+00:00") + timestamp = datetime.datetime.fromisoformat(normalized_timestamp) except ValueError: return None # Extract GitHub webhook context from prepare_log_prefix format repository, event_type, hook_id, github_user, pr_number, cleaned_message = self._extract_github_context(message) + # Extract task correlation fields from message and strip them from the message + task_id, task_type, task_status, final_message = self._extract_task_fields(cleaned_message) + return LogEntry( timestamp=timestamp, level=level, logger_name=logger_name, - message=cleaned_message, + message=final_message, hook_id=hook_id, event_type=event_type, repository=repository, pr_number=pr_number, github_user=github_user, + task_id=task_id, + task_type=task_type, + task_status=task_status, ) def _extract_github_context( @@ -170,6 +201,43 @@ def _extract_github_context( cleaned_message = self.ANSI_ESCAPE_PATTERN.sub("", message) return None, None, None, None, None, cleaned_message + def _extract_task_fields(self, message: str) -> tuple[str | None, str | None, str | None, str]: + """Extract task correlation fields from log message. + + Extracts task_id, task_type, and task_status from patterns like: + [task_id=check_tox] [task_type=ci_check] [task_status=started] + + The task tokens are removed from the returned message to avoid duplication + and improve free-text search, as these values are stored in dedicated fields. + + Args: + message: Log message to extract from + + Returns: + Tuple of (task_id, task_type, task_status, cleaned_message) + """ + task_id = None + task_type = None + task_status = None + cleaned_message = message + + # Extract task_id using precompiled pattern + if task_id_match := self.TASK_ID_PATTERN.search(cleaned_message): + task_id = task_id_match.group(1) + cleaned_message = self.TASK_ID_PATTERN.sub("", cleaned_message, count=1).strip() + + # Extract task_type using precompiled pattern + if task_type_match := self.TASK_TYPE_PATTERN.search(cleaned_message): + task_type = task_type_match.group(1) + cleaned_message = self.TASK_TYPE_PATTERN.sub("", cleaned_message, count=1).strip() + + # Extract task_status using precompiled pattern + if task_status_match := self.TASK_STATUS_PATTERN.search(cleaned_message): + task_status = task_status_match.group(1) + cleaned_message = self.TASK_STATUS_PATTERN.sub("", cleaned_message, count=1).strip() + + return task_id, task_type, task_status, cleaned_message + def parse_log_file(self, file_path: Path) -> list[LogEntry]: """ Parse an entire log file and return list of LogEntry objects. @@ -216,7 +284,7 @@ async def tail_log_file(self, file_path: Path, follow: bool = True) -> AsyncGene if not file_path.exists(): return - with open(file_path, "r", encoding="utf-8") as f: + with open(file_path, encoding="utf-8") as f: # Move to end of file f.seek(0, 2) diff --git a/webhook_server/utils/helpers.py b/webhook_server/utils/helpers.py index 968773a2b..c96ca396e 100644 --- a/webhook_server/utils/helpers.py +++ b/webhook_server/utils/helpers.py @@ -89,6 +89,46 @@ def get_logger_with_params( ) +def _sanitize_log_value(value: str) -> str: + """Sanitize value for safe inclusion in structured log messages. + + Prevents log injection by removing newlines and escaping brackets. + + Args: + value: Raw value to sanitize + + Returns: + Sanitized value safe for log formatting + """ + # Remove newlines and carriage returns to prevent log injection + sanitized = value.replace("\n", " ").replace("\r", " ") + # Escape brackets to prevent breaking structured log parsing + sanitized = sanitized.replace("[", "\\[").replace("]", "\\]") + return sanitized + + +def format_task_fields(task_id: str | None = None, task_type: str | None = None, task_status: str | None = None) -> str: + """Format task correlation fields for log messages. + + Args: + task_id: Task identifier (e.g., "check_tox", "webhook_processing") + task_type: Task type category (e.g., "ci_check", "webhook_routing") + task_status: Task status (e.g., "started", "completed", "failed") + + Returns: + Formatted string with task fields in brackets, or empty string if no fields provided. + Example: "[task_id=check_tox] [task_type=ci_check] [task_status=started]" + """ + parts = [] + if task_id: + parts.append(f"[task_id={_sanitize_log_value(task_id)}]") + if task_type: + parts.append(f"[task_type={_sanitize_log_value(task_type)}]") + if task_status: + parts.append(f"[task_status={_sanitize_log_value(task_status)}]") + return " ".join(parts) + + def extract_key_from_dict(key: Any, _dict: dict[Any, Any]) -> Any: if isinstance(_dict, dict): for _key, _val in _dict.items(): @@ -357,10 +397,10 @@ def prepare_log_prefix( else: repository_color = repository_name or "" - # Build prefix components - components = [event_type, delivery_id] + # Build prefix components (sanitize to prevent log injection) + components = [_sanitize_log_value(event_type), _sanitize_log_value(delivery_id)] if api_user: - components.append(api_user) + components.append(_sanitize_log_value(api_user)) prefix = f"{repository_color} [{']['.join(components)}]" diff --git a/webhook_server/web/log_viewer.py b/webhook_server/web/log_viewer.py index d08173c22..4afd3e1c1 100644 --- a/webhook_server/web/log_viewer.py +++ b/webhook_server/web/log_viewer.py @@ -5,8 +5,9 @@ import logging import os import re +from collections.abc import Generator, Iterator from pathlib import Path -from typing import Any, Generator, Iterator +from typing import Any from fastapi import HTTPException, WebSocket, WebSocketDisconnect from fastapi.responses import HTMLResponse, StreamingResponse @@ -80,12 +81,12 @@ def get_log_page(self) -> HTMLResponse: try: html_content = self._get_log_viewer_html() return HTMLResponse(content=html_content) - except FileNotFoundError: + except FileNotFoundError as e: self.logger.error("Log viewer HTML template not found") - raise HTTPException(status_code=404, detail="Log viewer template not found") + raise HTTPException(status_code=404, detail="Log viewer template not found") from e except Exception as e: self.logger.error(f"Error serving log viewer page: {e}") - raise HTTPException(status_code=500, detail="Internal server error") + raise HTTPException(status_code=500, detail="Internal server error") from e def get_log_entries( self, @@ -211,13 +212,13 @@ def get_log_entries( except ValueError as e: self.logger.warning(f"Invalid parameters for log entries request: {e}") - raise HTTPException(status_code=400, detail=str(e)) + raise HTTPException(status_code=400, detail=str(e)) from e except (OSError, PermissionError) as e: self.logger.error(f"File access error loading log entries: {e}") - raise HTTPException(status_code=500, detail="Error accessing log files") + raise HTTPException(status_code=500, detail="Error accessing log files") from e except Exception as e: self.logger.error(f"Unexpected error getting log entries: {e}") - raise HTTPException(status_code=500, detail="Internal server error") + raise HTTPException(status_code=500, detail="Internal server error") from e def _entry_matches_filters( self, @@ -353,13 +354,13 @@ def generate() -> Generator[bytes, None, None]: except ValueError as e: if "Result set too large" in str(e): self.logger.warning(f"Export request too large: {e}") - raise HTTPException(status_code=413, detail=str(e)) + raise HTTPException(status_code=413, detail=str(e)) from e else: self.logger.warning(f"Invalid export parameters: {e}") - raise HTTPException(status_code=400, detail=str(e)) + raise HTTPException(status_code=400, detail=str(e)) from e except Exception as e: self.logger.error(f"Error generating export: {e}") - raise HTTPException(status_code=500, detail="Export generation failed") + raise HTTPException(status_code=500, detail="Export generation failed") from e async def handle_websocket( self, @@ -479,13 +480,13 @@ def get_pr_flow_data(self, hook_id: str) -> dict[str, Any]: except ValueError as e: if "No data found" in str(e): self.logger.warning(f"PR flow data not found: {e}") - raise HTTPException(status_code=404, detail=str(e)) + raise HTTPException(status_code=404, detail=str(e)) from e else: self.logger.warning(f"Invalid PR flow hook_id: {e}") - raise HTTPException(status_code=400, detail=str(e)) + raise HTTPException(status_code=400, detail=str(e)) from e except Exception as e: self.logger.error(f"Error getting PR flow data: {e}") - raise HTTPException(status_code=500, detail="Internal server error") + raise HTTPException(status_code=500, detail="Internal server error") from e def get_workflow_steps(self, hook_id: str) -> dict[str, Any]: """Get workflow step timeline data for a specific hook ID. @@ -525,13 +526,13 @@ def get_workflow_steps(self, hook_id: str) -> dict[str, Any]: except ValueError as e: if "No data found" in str(e) or "No workflow steps found" in str(e): self.logger.warning(f"Workflow steps not found: {e}") - raise HTTPException(status_code=404, detail=str(e)) + raise HTTPException(status_code=404, detail=str(e)) from e else: self.logger.warning(f"Invalid hook ID: {e}") - raise HTTPException(status_code=400, detail=str(e)) + raise HTTPException(status_code=400, detail=str(e)) from e except Exception as e: self.logger.error(f"Error getting workflow steps: {e}") - raise HTTPException(status_code=500, detail="Internal server error") + raise HTTPException(status_code=500, detail="Internal server error") from e def _build_workflow_timeline(self, workflow_steps: list[LogEntry], hook_id: str) -> dict[str, Any]: """Build timeline data from workflow step entries. @@ -541,7 +542,7 @@ def _build_workflow_timeline(self, workflow_steps: list[LogEntry], hook_id: str) hook_id: The hook ID for this timeline Returns: - Dictionary with timeline data structure + Dictionary with timeline data structure including task correlation fields """ # Sort steps by timestamp sorted_steps = sorted(workflow_steps, key=lambda x: x.timestamp) @@ -564,6 +565,9 @@ def _build_workflow_timeline(self, workflow_steps: list[LogEntry], hook_id: str) "repository": step.repository, "event_type": step.event_type, "pr_number": step.pr_number, + "task_id": step.task_id, + "task_type": step.task_type, + "task_status": step.task_status, }) # Calculate total duration @@ -703,12 +707,12 @@ def _get_log_viewer_html(self) -> str: template_path = Path(__file__).parent / "templates" / "log_viewer.html" try: - with open(template_path, "r", encoding="utf-8") as f: + with open(template_path, encoding="utf-8") as f: return f.read() except FileNotFoundError: self.logger.error(f"Log viewer template not found at {template_path}") return self._get_fallback_html() - except IOError as e: + except OSError as e: self.logger.error(f"Failed to read log viewer template: {e}") return self._get_fallback_html() diff --git a/webhook_server/web/static/css/log_viewer.css b/webhook_server/web/static/css/log_viewer.css index d596c7d89..4a612e636 100644 --- a/webhook_server/web/static/css/log_viewer.css +++ b/webhook_server/web/static/css/log_viewer.css @@ -3,6 +3,7 @@ --bg-color: #f5f5f5; --container-bg: #ffffff; --text-color: #333333; + --text-secondary: #666666; --border-color: #dddddd; --input-bg: #ffffff; --input-border: #dddddd; @@ -15,12 +16,16 @@ --status-disconnected-text: #721c24; --status-disconnected-border: #f5c6cb; --log-entry-border: #eeeeee; + /* Log level colors */ --log-info-bg: #d4f8d4; --log-error-bg: #ffd6d6; --log-warning-bg: #fff3cd; --log-debug-bg: #f8f9fa; --log-step-bg: #e3f2fd; --log-success-bg: #d1f2d1; + /* Level badge colors */ + --level-info-bg: #d1ecf1; + --level-info-border: #17a2b8; --tag-bg: #e9ecef; --timestamp-color: #666666; } @@ -30,6 +35,7 @@ --bg-color: #1a1a1a; --container-bg: #2d2d2d; --text-color: #e0e0e0; + --text-secondary: #999999; --border-color: #404040; --input-bg: #3d3d3d; --input-border: #555555; @@ -42,12 +48,16 @@ --status-disconnected-text: #f8d7da; --status-disconnected-border: #f5c6cb; --log-entry-border: #404040; + /* Log level colors */ --log-info-bg: #1e4a1e; --log-error-bg: #5a1e1e; --log-warning-bg: #5a4a1e; --log-debug-bg: #2a2a2a; --log-step-bg: #1a237e; --log-success-bg: #1e4a1e; + /* Level badge colors */ + --level-info-bg: #0c4a5a; + --level-info-border: #3ebdcc; --tag-bg: #4a4a4a; --timestamp-color: #888888; } @@ -58,7 +68,9 @@ body { padding: 20px; background-color: var(--bg-color); color: var(--text-color); - transition: background-color 0.3s ease, color 0.3s ease; + transition: + background-color 0.3s ease, + color 0.3s ease; } .container { max-width: 95vw; @@ -66,7 +78,7 @@ body { background: var(--container-bg); padding: 20px; border-radius: 8px; - box-shadow: 0 2px 4px rgba(0,0,0,0.1); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); transition: background-color 0.3s ease; } .header { @@ -77,7 +89,9 @@ body { justify-content: space-between; align-items: center; } -.header h1 { margin: 0; } +.header h1 { + margin: 0; +} .theme-toggle { background: var(--button-bg); color: white; @@ -87,19 +101,41 @@ body { cursor: pointer; transition: background-color 0.3s ease; } -.theme-toggle:hover { background: var(--button-hover); } -.filters { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 10px; margin-bottom: 20px; } -.filter-group { display: flex; flex-direction: column; } -.filter-group label { font-weight: bold; margin-bottom: 3px; font-size: 14px; color: var(--text-color); } -.filter-group input, .filter-group select { +.theme-toggle:hover { + background: var(--button-hover); +} +.filters { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; + margin-bottom: 20px; +} +.filter-group { + display: flex; + flex-direction: column; +} +.filter-group label { + 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); border-radius: 4px; background: var(--input-bg); color: var(--text-color); - transition: background-color 0.3s ease, border-color 0.3s ease; + transition: + background-color 0.3s ease, + border-color 0.3s ease; +} +.log-entries { + border: 1px solid var(--border-color); + border-radius: 4px; + min-height: 200px; } -.log-entries { border: 1px solid var(--border-color); border-radius: 4px; min-height: 200px; } /* Loading skeleton styles */ .loading-skeleton { @@ -114,14 +150,27 @@ body { height: 14px; margin: 4px 0; border-radius: 3px; - background: linear-gradient(90deg, var(--border-color) 25%, var(--input-bg) 50%, var(--border-color) 75%); + background: linear-gradient( + 90deg, + var(--border-color) 25%, + var(--input-bg) 50%, + var(--border-color) 75% + ); background-size: 200% 100%; animation: shimmer 1.5s infinite; } -.skeleton-timestamp { width: 20%; } -.skeleton-level { width: 10%; } -.skeleton-message { width: 60%; } -.skeleton-meta { width: 30%; } +.skeleton-timestamp { + width: 20%; +} +.skeleton-level { + width: 10%; +} +.skeleton-message { + width: 60%; +} +.skeleton-meta { + width: 30%; +} .loading-text { text-align: center; color: var(--timestamp-color); @@ -164,12 +213,20 @@ body { /* Animations */ @keyframes pulse { - 0% { opacity: 1; } - 100% { opacity: 0.6; } + 0% { + opacity: 1; + } + 100% { + opacity: 0.6; + } } @keyframes shimmer { - 0% { background-position: -200% 0; } - 100% { background-position: 200% 0; } + 0% { + background-position: -200% 0; + } + 100% { + background-position: 200% 0; + } } /* Timeline styles */ @@ -329,7 +386,7 @@ body { border-radius: 4px; padding: 8px; font-size: 12px; - box-shadow: 0 2px 8px rgba(0,0,0,0.1); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); z-index: 1000; pointer-events: none; display: none; @@ -342,17 +399,40 @@ body { 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); } -.timestamp { color: var(--timestamp-color); } -.level { font-weight: bold; } -.message { margin-left: 10px; } -.hook-id, .pr-number, .repository, .user { +.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); +} +.timestamp { + color: var(--timestamp-color); +} +.level { + font-weight: bold; +} +.message { + margin-left: 10px; +} +.hook-id, +.pr-number, +.repository, +.user { margin-left: 10px; padding: 2px 6px; background-color: var(--tag-bg); @@ -360,7 +440,9 @@ body { font-size: 12px; transition: background-color 0.3s ease; } -.controls { margin-bottom: 20px; } +.controls { + margin-bottom: 20px; +} .btn { padding: 10px 20px; background-color: var(--button-bg); @@ -371,8 +453,14 @@ body { margin-right: 10px; transition: background-color 0.3s ease; } -.btn:hover { background-color: var(--button-hover); } -.status { padding: 10px; margin-bottom: 20px; border-radius: 4px; } +.btn:hover { + background-color: var(--button-hover); +} +.status { + padding: 10px; + margin-bottom: 20px; + border-radius: 4px; +} .status.connected { background-color: var(--status-connected-bg); color: var(--status-connected-text); @@ -401,11 +489,491 @@ body { align-items: center; } +/* Flow Modal Styles */ +.modal { + position: fixed; + z-index: 1000; + left: 0; + top: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.modal-content { + background-color: var(--container-bg); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + width: 90%; + max-width: 800px; + max-height: 90vh; + display: flex; + flex-direction: column; + animation: slideIn 0.3s ease; +} + +@keyframes slideIn { + from { + transform: translateY(-50px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 20px 24px; + border-bottom: 2px solid var(--border-color); +} + +.modal-header h2 { + margin: 0; + font-size: 24px; + color: var(--text-color); +} + +.modal-close { + background: none; + border: none; + font-size: 32px; + color: var(--text-secondary); + cursor: pointer; + padding: 0; + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + transition: all 0.2s ease; +} + +.modal-close:hover { + background-color: rgba(255, 0, 0, 0.1); + color: #ff4444; +} + +.modal-body { + padding: 24px; + overflow-y: auto; + flex: 1; +} + +.flow-summary { + background: var(--level-info-bg); + border-left: 4px solid var(--level-info-border); + padding: 16px; + border-radius: 8px; + margin-bottom: 24px; +} + +.flow-summary h3 { + margin: 0 0 12px 0; + font-size: 18px; + color: var(--level-info-border); +} + +.flow-summary-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 16px; + margin-top: 12px; +} + +.flow-summary-item { + display: flex; + flex-direction: column; + gap: 4px; +} + +.flow-summary-label { + font-size: 12px; + color: var(--text-secondary); + text-transform: uppercase; + font-weight: 600; +} + +.flow-summary-value { + font-size: 18px; + font-weight: 700; + color: var(--text-color); +} + +.flow-visualization { + position: relative; +} + +.flow-step-container { + position: relative; + margin-bottom: 8px; +} + +.flow-step-container:not(:last-child)::before { + content: ""; + position: absolute; + left: 19px; + top: 40px; + bottom: 0; + width: 2px; + background: var(--border-color); + z-index: 0; +} + +.flow-step { + display: flex; + gap: 16px; + position: relative; +} + +.flow-step-number { + flex-shrink: 0; + width: 40px; + height: 40px; + border-radius: 50%; + background: var(--level-info-border); + color: white; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 16px; + z-index: 1; + position: relative; +} + +.flow-step.success .flow-step-number { + background: #28a745; +} + +.flow-step.error .flow-step-number { + background: #dc3545; +} + +.flow-step.warning .flow-step-number { + background: #ffc107; +} + +.flow-step-content { + flex: 1; + background: var(--container-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 12px 16px; + transition: all 0.2s ease; +} + +.flow-step-content:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + border-color: var(--level-info-border); + transform: translateX(4px); +} + +.flow-step-title { + font-weight: 600; + color: var(--text-color); + margin-bottom: 4px; + font-size: 15px; +} + +.flow-step-time { + font-size: 12px; + color: var(--text-secondary); + display: flex; + gap: 12px; + margin-top: 4px; +} + +.flow-step-duration { + font-weight: 600; + color: var(--level-info-border); +} + +.flow-success { + background: rgba(40, 167, 69, 0.1); + border-color: #28a745; + padding: 16px; + border-radius: 8px; + text-align: center; + margin-top: 24px; +} + +.flow-success h3 { + color: #28a745; + margin: 0; + font-size: 18px; +} + +.flow-error { + background: rgba(220, 53, 69, 0.1); + border: 1px solid #dc3545; + border-left: 4px solid #dc3545; + padding: 16px; + border-radius: 8px; + margin-top: 24px; +} + +.flow-error h3 { + color: #dc3545; + margin: 0 0 8px 0; + font-size: 16px; +} + +.flow-error-message { + font-size: 14px; + color: var(--text-color); + font-family: monospace; + background: rgba(0, 0, 0, 0.2); + padding: 8px; + border-radius: 4px; + margin-top: 8px; +} + /* Responsive adjustments */ @media (max-width: 768px) { - .filters { grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 8px; } - .filter-group label { font-size: 13px; } - .filter-group input, .filter-group select { padding: 6px; font-size: 14px; } - .controls { display: flex; flex-wrap: wrap; gap: 8px; } - .btn { padding: 8px 16px; font-size: 14px; } + .filters { + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 8px; + } + .filter-group label { + font-size: 13px; + } + .filter-group input, + .filter-group select { + padding: 6px; + font-size: 14px; + } + .controls { + display: flex; + flex-wrap: wrap; + gap: 8px; + } + .btn { + padding: 8px 16px; + font-size: 14px; + } + + .modal-content { + width: 95%; + max-height: 95vh; + } + + .modal-header { + padding: 16px; + } + + .modal-body { + padding: 16px; + } + + .flow-summary-grid { + grid-template-columns: 1fr; + } +} + +.step-logs-container { + margin-top: 12px; + margin-left: 56px; + margin-bottom: 12px; + max-height: 300px; + overflow-y: auto; + background: var(--log-debug-bg); + border: 1px solid var(--border-color); + border-left: 3px solid var(--level-info-border); + border-radius: 4px; + padding: 12px; +} + +.step-logs-container .log-entry { + padding: 8px; + margin-bottom: 8px; + border-bottom: 1px solid var(--log-entry-border); + font-family: monospace; + font-size: 13px; +} + +.step-logs-container .log-entry:last-child { + border-bottom: none; + margin-bottom: 0; +} + +.hook-id-link { + cursor: pointer; + color: var(--button-bg); + text-decoration: underline; + font-weight: bold; +} + +.hook-id-link:hover { + color: var(--button-hover); +} + +.pr-number-link { + cursor: pointer; + color: var(--button-bg); + text-decoration: underline; + font-weight: bold; +} + +.pr-number-link:hover { + color: var(--button-hover); +} + +/* PR Modal Styles */ +.pr-summary { + background: var(--level-info-bg); + border-left: 4px solid var(--level-info-border); + padding: 16px; + border-radius: 8px; + margin-bottom: 24px; +} + +.pr-summary h3 { + margin: 0 0 12px 0; + font-size: 18px; + color: var(--level-info-border); +} + +.pr-hook-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.pr-hook-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: var(--container-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + cursor: pointer; + transition: all 0.2s ease; +} + +.pr-hook-item:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); + border-color: var(--button-bg); + transform: translateX(4px); +} + +.pr-hook-icon { + font-size: 20px; + color: var(--button-bg); +} + +.pr-hook-id { + flex: 1; + font-family: monospace; + font-size: 14px; + color: var(--text-color); + font-weight: 600; +} + +/* Task Group Styles */ +.task-group { + margin-bottom: 16px; + border: 1px solid var(--border-color); + border-radius: 8px; + overflow: hidden; + background: var(--container-bg); +} + +.task-group-header { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 16px; + background: var(--log-debug-bg); + border-bottom: 1px solid var(--border-color); + cursor: pointer; + transition: all 0.2s ease; + user-select: none; +} + +.task-group-header:hover { + background: var(--log-entry-border); +} + +.task-group-arrow { + font-size: 14px; + transition: transform 0.3s ease; + display: inline-block; + width: 16px; + text-align: center; +} + +.task-group-arrow.expanded { + transform: rotate(90deg); +} + +.task-group-arrow.collapsed { + transform: rotate(0deg); +} + +.task-group-status { + font-size: 18px; + font-weight: bold; + width: 24px; + text-align: center; +} + +.task-group-success { + color: #28a745; +} + +.task-group-error { + color: #dc3545; +} + +.task-group-in_progress { + color: #007bff; +} + +.task-group-title { + flex: 1; + font-weight: 600; + font-size: 15px; + color: var(--text-color); +} + +.task-group-duration { + font-size: 13px; + color: var(--timestamp-color); + font-weight: 600; + padding: 4px 8px; + background: var(--tag-bg); + border-radius: 4px; +} + +.task-group-steps { + padding: 8px; + background: var(--container-bg); +} + +.task-group-steps .flow-step-container.nested { + margin-left: 20px; + position: relative; +} + +.task-group-steps .flow-step-container.nested::before { + left: -1px; } diff --git a/webhook_server/web/static/js/log_viewer.js b/webhook_server/web/static/js/log_viewer.js index fdcaaaed2..852104551 100644 --- a/webhook_server/web/static/js/log_viewer.js +++ b/webhook_server/web/static/js/log_viewer.js @@ -1,16 +1,23 @@ let ws = null; let logEntries = []; +// Configuration constants +const CONFIG = { + // Maximum number of entries to fetch when loading PR details + // This prevents performance issues with very large datasets + PR_FETCH_LIMIT: 10000, +}; + function updateConnectionStatus(connected) { - const status = document.getElementById('connectionStatus'); - const statusText = document.getElementById('statusText'); + const status = document.getElementById("connectionStatus"); + const statusText = document.getElementById("statusText"); if (connected) { - status.className = 'status connected'; - statusText.textContent = 'Connected - Real-time updates active'; + status.className = "status connected"; + statusText.textContent = "Connected - Real-time updates active"; } else { - status.className = 'status disconnected'; - statusText.textContent = 'Disconnected - Real-time updates inactive'; + status.className = "status disconnected"; + statusText.textContent = "Disconnected - Real-time updates inactive"; } } @@ -19,46 +26,48 @@ function connectWebSocket() { ws.close(); } - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; // Build WebSocket URL with current filter parameters const filters = new URLSearchParams(); - const hookId = document.getElementById('hookIdFilter').value.trim(); - const prNumber = document.getElementById('prNumberFilter').value.trim(); - const repository = document.getElementById('repositoryFilter').value.trim(); - const user = document.getElementById('userFilter').value.trim(); - const level = document.getElementById('levelFilter').value; - const search = document.getElementById('searchFilter').value.trim(); - - if (hookId) filters.append('hook_id', hookId); - if (prNumber) filters.append('pr_number', prNumber); - if (repository) filters.append('repository', repository); - if (user) filters.append('github_user', user); - if (level) filters.append('level', level); - if (search) filters.append('search', search); - - const wsUrl = `${protocol}//${window.location.host}/logs/ws${filters.toString() ? '?' + filters.toString() : ''}`; + const hookId = document.getElementById("hookIdFilter").value.trim(); + const prNumber = document.getElementById("prNumberFilter").value.trim(); + const repository = document.getElementById("repositoryFilter").value.trim(); + const user = document.getElementById("userFilter").value.trim(); + const level = document.getElementById("levelFilter").value; + const search = document.getElementById("searchFilter").value.trim(); + + if (hookId) filters.append("hook_id", hookId); + if (prNumber) filters.append("pr_number", prNumber); + if (repository) filters.append("repository", repository); + if (user) filters.append("github_user", user); + if (level) filters.append("level", level); + if (search) filters.append("search", search); + + const wsUrl = `${protocol}//${window.location.host}/logs/ws${ + filters.toString() ? "?" + filters.toString() : "" + }`; ws = new WebSocket(wsUrl); - ws.onopen = function() { + ws.onopen = function () { updateConnectionStatus(true); - console.log('WebSocket connected'); + console.log("WebSocket connected"); }; - ws.onmessage = function(event) { + ws.onmessage = function (event) { const logEntry = JSON.parse(event.data); addLogEntry(logEntry); }; - ws.onclose = function() { + ws.onclose = function () { updateConnectionStatus(false); - console.log('WebSocket disconnected'); + console.log("WebSocket disconnected"); }; - ws.onerror = function(error) { + ws.onerror = function (error) { updateConnectionStatus(false); - console.error('WebSocket error:', error); + console.error("WebSocket error:", error); }; } @@ -75,7 +84,7 @@ function disconnectWebSocket() { // Helper function to apply memory bounding to logEntries array function applyMemoryBounding() { - const maxEntries = parseInt(document.getElementById('limitFilter').value); + const maxEntries = parseInt(document.getElementById("limitFilter").value); if (logEntries.length > maxEntries) { // Remove oldest entries to keep array size bounded logEntries = logEntries.slice(0, maxEntries); @@ -96,13 +105,13 @@ function addLogEntry(entry) { } function updateDisplayedCount() { - const displayedCount = document.getElementById('displayedCount'); + const displayedCount = document.getElementById("displayedCount"); const filteredEntries = filterLogEntries(logEntries); displayedCount.textContent = filteredEntries.length; } function renderLogEntriesOptimized() { - const container = document.getElementById('logEntries'); + const container = document.getElementById("logEntries"); const filteredEntries = filterLogEntries(logEntries); // Always use direct rendering to prevent any scrollbar flashing @@ -114,7 +123,7 @@ function renderLogEntriesDirect(container, entries) { // Use DocumentFragment for efficient DOM manipulation to minimize reflows const fragment = document.createDocumentFragment(); - entries.forEach(entry => { + entries.forEach((entry) => { const entryElement = createLogEntryElement(entry); fragment.appendChild(entryElement); }); @@ -131,32 +140,96 @@ function renderLogEntriesDirect(container, entries) { // All rendering now uses direct DOM manipulation only function createLogEntryElement(entry) { - const div = document.createElement('div'); + const div = document.createElement("div"); // Whitelist of allowed log levels to prevent class-name injection - const allowedLevels = ['DEBUG', 'INFO', 'WARNING', 'ERROR', 'STEP', 'SUCCESS']; - const safeLevel = allowedLevels.includes(entry.level) ? entry.level : 'INFO'; // Default fallback + const allowedLevels = [ + "DEBUG", + "INFO", + "WARNING", + "ERROR", + "STEP", + "SUCCESS", + ]; + const safeLevel = allowedLevels.includes(entry.level) ? entry.level : "INFO"; // Default fallback div.className = `log-entry ${safeLevel}`; - // Use efficient string template - div.innerHTML = ` - ${new Date(entry.timestamp).toLocaleString()} - [${entry.level}] - ${escapeHtml(entry.message)} - ${entry.hook_id ? `[Hook: ${escapeHtml(entry.hook_id)}]` : ''} - ${entry.pr_number ? `[PR: #${entry.pr_number}]` : ''} - ${entry.repository ? `[${escapeHtml(entry.repository)}]` : ''} - ${entry.github_user ? `[User: ${escapeHtml(entry.github_user)}]` : ''} - `; + // Create timestamp + const timestamp = document.createElement("span"); + timestamp.className = "timestamp"; + timestamp.textContent = new Date(entry.timestamp).toLocaleString(); + div.appendChild(timestamp); + + // Create level + const level = document.createElement("span"); + level.className = "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); + + // Create clickable hook ID link if present + if (entry.hook_id) { + const hookIdSpan = document.createElement("span"); + hookIdSpan.className = "hook-id"; + hookIdSpan.textContent = "[Hook: "; + + const hookLink = document.createElement("span"); + hookLink.className = "hook-id-link"; + hookLink.textContent = entry.hook_id; + hookLink.title = "Click to view workflow"; + hookLink.style.cursor = "pointer"; + hookLink.addEventListener("click", () => { + showFlowModal(entry.hook_id); + }); - return div; -} + hookIdSpan.appendChild(hookLink); + const closeBracket = document.createTextNode("]"); + hookIdSpan.appendChild(closeBracket); + div.appendChild(hookIdSpan); + } + + // Add other metadata - make PR number clickable + if (entry.pr_number) { + const prSpan = document.createElement("span"); + prSpan.className = "pr-number"; + prSpan.textContent = "[PR: #"; + + const prLink = document.createElement("span"); + prLink.className = "pr-number-link"; + prLink.textContent = entry.pr_number; + prLink.title = "Click to view all webhook flows for this PR"; + prLink.style.cursor = "pointer"; + prLink.addEventListener("click", () => { + showPrModal(entry.pr_number); + }); -function escapeHtml(text) { - const div = document.createElement('div'); - div.textContent = text; - return div.innerHTML; + prSpan.appendChild(prLink); + const closeBracket = document.createTextNode("]"); + prSpan.appendChild(closeBracket); + div.appendChild(prSpan); + } + + if (entry.repository) { + const repoSpan = document.createElement("span"); + repoSpan.className = "repository"; + repoSpan.textContent = `[${entry.repository}]`; + div.appendChild(repoSpan); + } + + if (entry.github_user) { + const userSpan = document.createElement("span"); + userSpan.className = "user"; + userSpan.textContent = `[User: ${entry.github_user}]`; + div.appendChild(userSpan); + } + + return div; } // Alias for backward compatibility @@ -165,23 +238,26 @@ function renderLogEntries() { } function renderLogEntriesDirectly(entries) { - const container = document.getElementById('logEntries'); + const container = document.getElementById("logEntries"); // Always use direct rendering for backend-filtered data to ensure all entries show renderLogEntriesDirect(container, entries); } // Optimized filtering with caching and early exit -let lastFilterHash = ''; +let lastFilterHash = ""; let cachedFilteredEntries = []; function filterLogEntries(entries) { - const hookId = document.getElementById('hookIdFilter').value.trim(); - const prNumber = document.getElementById('prNumberFilter').value.trim(); - const repository = document.getElementById('repositoryFilter').value.trim(); - const user = document.getElementById('userFilter').value.trim(); - const level = document.getElementById('levelFilter').value; - const search = document.getElementById('searchFilter').value.trim().toLowerCase(); + const hookId = document.getElementById("hookIdFilter").value.trim(); + const prNumber = document.getElementById("prNumberFilter").value.trim(); + const repository = document.getElementById("repositoryFilter").value.trim(); + const user = document.getElementById("userFilter").value.trim(); + const level = document.getElementById("levelFilter").value; + const search = document + .getElementById("searchFilter") + .value.trim() + .toLowerCase(); // Create hash of current filters for caching const filterHash = `${hookId}-${prNumber}-${repository}-${user}-${level}-${search}-${entries.length}`; @@ -192,11 +268,13 @@ function filterLogEntries(entries) { } // Pre-compile search terms for better performance - const searchTerms = search ? search.split(' ').filter(term => term.length > 0) : []; + const searchTerms = search + ? search.split(" ").filter((term) => term.length > 0) + : []; const prNumberInt = prNumber ? parseInt(prNumber) : null; // Use optimized filtering with early exits - const filtered = entries.filter(entry => { + const filtered = entries.filter((entry) => { // Exact matches first (fastest) if (hookId && entry.hook_id !== hookId) return false; if (prNumberInt && entry.pr_number !== prNumberInt) return false; @@ -207,7 +285,7 @@ function filterLogEntries(entries) { // Text search last (slowest) if (searchTerms.length > 0) { const messageText = entry.message.toLowerCase(); - return searchTerms.every(term => messageText.includes(term)); + return searchTerms.every((term) => messageText.includes(term)); } return true; @@ -222,7 +300,7 @@ function filterLogEntries(entries) { // Clear filter cache when entries change function clearFilterCache() { - lastFilterHash = ''; + lastFilterHash = ""; cachedFilteredEntries = []; } @@ -233,22 +311,22 @@ async function loadHistoricalLogs() { // Build API URL with current filter parameters const filters = new URLSearchParams(); - const hookId = document.getElementById('hookIdFilter').value.trim(); - const prNumber = document.getElementById('prNumberFilter').value.trim(); - const repository = document.getElementById('repositoryFilter').value.trim(); - const user = document.getElementById('userFilter').value.trim(); - const level = document.getElementById('levelFilter').value; - const search = document.getElementById('searchFilter').value.trim(); - const limit = document.getElementById('limitFilter').value; + const hookId = document.getElementById("hookIdFilter").value.trim(); + const prNumber = document.getElementById("prNumberFilter").value.trim(); + const repository = document.getElementById("repositoryFilter").value.trim(); + const user = document.getElementById("userFilter").value.trim(); + const level = document.getElementById("levelFilter").value; + const search = document.getElementById("searchFilter").value.trim(); + const limit = document.getElementById("limitFilter").value; // Use user-configured limit - filters.append('limit', limit); - if (hookId) filters.append('hook_id', hookId); - if (prNumber) filters.append('pr_number', prNumber); - if (repository) filters.append('repository', repository); - if (user) filters.append('github_user', user); - if (level) filters.append('level', level); - if (search) filters.append('search', search); + filters.append("limit", limit); + if (hookId) filters.append("hook_id", hookId); + if (prNumber) filters.append("pr_number", prNumber); + if (repository) filters.append("repository", repository); + if (user) filters.append("github_user", user); + if (level) filters.append("level", level); + if (search) filters.append("search", search); const response = await fetch(`/logs/api/entries?${filters.toString()}`); @@ -259,9 +337,10 @@ async function loadHistoricalLogs() { // Try to parse error message from response body const errorData = await response.json(); if (errorData.detail || errorData.message || errorData.error) { - errorMessage = errorData.detail || errorData.message || errorData.error; + errorMessage = + errorData.detail || errorData.message || errorData.error; } - } catch (parseError) { + } catch { // If JSON parsing fails, use the status text } throw new Error(errorMessage); @@ -274,7 +353,7 @@ async function loadHistoricalLogs() { // Progressive loading for large datasets if (data.entries.length > 200) { - await loadEntriesProgressivelyDirect(data.entries); + await loadEntriesDirectly(data.entries); } else { logEntries = data.entries; // Apply memory bounding after loading entries @@ -286,44 +365,27 @@ async function loadHistoricalLogs() { hideLoadingSkeleton(); } catch (error) { - console.error('Error loading historical logs:', error); + console.error("Error loading historical logs:", error); hideLoadingSkeleton(); - showErrorMessage('Failed to load log entries'); + showErrorMessage("Failed to load log entries"); } } -async function loadEntriesProgressively(entries) { - const chunkSize = 50; - logEntries = []; - clearFilterCache(); // Clear cache when loading new entries - - for (let i = 0; i < entries.length; i += chunkSize) { - const chunk = entries.slice(i, i + chunkSize); - logEntries.push(...chunk); - // Apply memory bounding after each chunk to prevent unbounded growth - applyMemoryBounding(); - clearFilterCache(); // Clear cache for each chunk - renderLogEntries(); - - // Add small delay to prevent UI blocking - if (i + chunkSize < entries.length) { - await new Promise(resolve => setTimeout(resolve, 10)); - } - } -} - -async function loadEntriesProgressivelyDirect(entries) { - // For backend-filtered data, just render all entries at once - // Progressive loading isn't needed since data is already filtered and limited +async function loadEntriesDirectly(entries) { + // Backend-filtered entries are assigned and rendered all at once + // All entries are displayed immediately - backend handles chunked streaming logEntries = entries; // Apply memory bounding after direct assignment applyMemoryBounding(); + hideLoadingSkeleton(); renderLogEntriesDirectly(logEntries); - console.log(`Loaded ${entries.length} backend-filtered entries`); + console.log( + `Loaded and rendered ${entries.length} backend-filtered entries at once`, + ); } function showLoadingSkeleton() { - const container = document.getElementById('logEntries'); + const container = document.getElementById("logEntries"); container.innerHTML = `
${createSkeletonEntry()} @@ -348,48 +410,71 @@ function createSkeletonEntry() { } function hideLoadingSkeleton() { - const skeleton = document.querySelector('.loading-skeleton'); + const skeleton = document.querySelector(".loading-skeleton"); if (skeleton) { skeleton.remove(); } } function showErrorMessage(message) { - const container = document.getElementById('logEntries'); - container.innerHTML = ` -
- ⚠️ - ${message} - -
- `; + const container = document.getElementById("logEntries"); - // Add event listener to the dynamically created retry button - const retryBtn = document.getElementById('retryBtn'); - if (retryBtn) { - retryBtn.addEventListener('click', loadHistoricalLogs); - } + // Create error message structure safely using DOM methods to prevent XSS + const errorDiv = document.createElement("div"); + errorDiv.className = "error-message"; + + const iconSpan = document.createElement("span"); + iconSpan.className = "error-icon"; + iconSpan.textContent = "⚠️"; + + const messageSpan = document.createElement("span"); + messageSpan.textContent = message; // Safe - automatically escapes HTML + + const retryBtn = document.createElement("button"); + retryBtn.id = "retryBtn"; + retryBtn.className = "retry-btn"; + retryBtn.textContent = "Retry"; + retryBtn.addEventListener("click", loadHistoricalLogs); + + errorDiv.appendChild(iconSpan); + errorDiv.appendChild(messageSpan); + errorDiv.appendChild(retryBtn); + + container.replaceChildren(errorDiv); } function updateLogStatistics(data) { - const statsPanel = document.getElementById('logStats'); - const displayedCount = document.getElementById('displayedCount'); - const totalCount = document.getElementById('totalCount'); - const processedCount = document.getElementById('processedCount'); + const statsPanel = document.getElementById("logStats"); + const displayedCount = document.getElementById("displayedCount"); + const totalCount = document.getElementById("totalCount"); + const processedCount = document.getElementById("processedCount"); // Update counts from API response displayedCount.textContent = data.entries ? data.entries.length : 0; - processedCount.textContent = data.entries_processed || '0'; + processedCount.textContent = data.entries_processed || "0"; // Use the total log count estimate for better user information - totalCount.textContent = data.total_log_count_estimate || 'Unknown'; + totalCount.textContent = data.total_log_count_estimate || "Unknown"; // Show the statistics panel - statsPanel.style.display = 'block'; + statsPanel.style.display = "block"; // Add indicator for partial scans if (data.is_partial_scan) { - processedCount.innerHTML = `${data.entries_processed} (partial scan)`; + // Clear existing content and rebuild safely to prevent XSS + processedCount.textContent = ""; // Clear first + + // Add the count as safe text + const countText = document.createTextNode( + String(data.entries_processed || "0") + " ", + ); + processedCount.appendChild(countText); + + // Add the partial scan indicator + const partialIndicator = document.createElement("small"); + partialIndicator.style.color = "var(--timestamp-color)"; + partialIndicator.textContent = "(partial scan)"; + processedCount.appendChild(partialIndicator); } } @@ -398,34 +483,34 @@ function clearLogs() { clearFilterCache(); // Clear cache when clearing entries // Clear the container directly to avoid any scrollbar flashing - const container = document.getElementById('logEntries'); + const container = document.getElementById("logEntries"); container.replaceChildren(); // More efficient than innerHTML = '' // Hide stats panel when no entries - document.getElementById('logStats').style.display = 'none'; + document.getElementById("logStats").style.display = "none"; } function exportLogs(format) { const filters = new URLSearchParams(); - const hookId = document.getElementById('hookIdFilter').value.trim(); - const prNumber = document.getElementById('prNumberFilter').value.trim(); - const repository = document.getElementById('repositoryFilter').value.trim(); - const user = document.getElementById('userFilter').value.trim(); - const level = document.getElementById('levelFilter').value; - const search = document.getElementById('searchFilter').value.trim(); - const limit = document.getElementById('limitFilter').value; - - if (hookId) filters.append('hook_id', hookId); - if (prNumber) filters.append('pr_number', prNumber); - if (repository) filters.append('repository', repository); - if (user) filters.append('github_user', user); - if (level) filters.append('level', level); - if (search) filters.append('search', search); - filters.append('limit', limit); - filters.append('format', format); + const hookId = document.getElementById("hookIdFilter").value.trim(); + const prNumber = document.getElementById("prNumberFilter").value.trim(); + const repository = document.getElementById("repositoryFilter").value.trim(); + const user = document.getElementById("userFilter").value.trim(); + const level = document.getElementById("levelFilter").value; + const search = document.getElementById("searchFilter").value.trim(); + const limit = document.getElementById("limitFilter").value; + + if (hookId) filters.append("hook_id", hookId); + if (prNumber) filters.append("pr_number", prNumber); + if (repository) filters.append("repository", repository); + if (user) filters.append("github_user", user); + if (level) filters.append("level", level); + if (search) filters.append("search", search); + filters.append("limit", limit); + filters.append("format", format); const url = `/logs/api/export?${filters.toString()}`; - window.open(url, '_blank'); + window.open(url, "_blank"); } function applyFilters() { @@ -442,7 +527,7 @@ function applyFilters() { let filterTimeout; function debounceFilter() { // Clear only filter cache, not entry cache - lastFilterHash = ''; + lastFilterHash = ""; // Immediate client-side filtering for fast feedback renderLogEntries(); @@ -455,114 +540,151 @@ function debounceFilter() { } function clearFilters() { - document.getElementById('hookIdFilter').value = ''; - document.getElementById('prNumberFilter').value = ''; - document.getElementById('repositoryFilter').value = ''; - document.getElementById('userFilter').value = ''; - document.getElementById('levelFilter').value = ''; - document.getElementById('searchFilter').value = ''; - document.getElementById('limitFilter').value = '1000'; // Reset to default + document.getElementById("hookIdFilter").value = ""; + document.getElementById("prNumberFilter").value = ""; + document.getElementById("repositoryFilter").value = ""; + document.getElementById("userFilter").value = ""; + document.getElementById("levelFilter").value = ""; + document.getElementById("searchFilter").value = ""; + document.getElementById("limitFilter").value = "1000"; // Reset to default // Reload data with cleared filters applyFilters(); } -document.getElementById('hookIdFilter').addEventListener('input', debounceFilter); -document.getElementById('prNumberFilter').addEventListener('input', debounceFilter); -document.getElementById('repositoryFilter').addEventListener('input', debounceFilter); -document.getElementById('userFilter').addEventListener('input', debounceFilter); -document.getElementById('levelFilter').addEventListener('change', debounceFilter); -document.getElementById('searchFilter').addEventListener('input', debounceFilter); -document.getElementById('limitFilter').addEventListener('change', debounceFilter); +document + .getElementById("hookIdFilter") + .addEventListener("input", debounceFilter); +document + .getElementById("prNumberFilter") + .addEventListener("input", debounceFilter); +document + .getElementById("repositoryFilter") + .addEventListener("input", debounceFilter); +document.getElementById("userFilter").addEventListener("input", debounceFilter); +document + .getElementById("levelFilter") + .addEventListener("change", debounceFilter); +document + .getElementById("searchFilter") + .addEventListener("input", debounceFilter); +document + .getElementById("limitFilter") + .addEventListener("change", debounceFilter); // Theme management function toggleTheme() { - const currentTheme = document.documentElement.getAttribute('data-theme'); - const newTheme = currentTheme === 'dark' ? 'light' : 'dark'; + const currentTheme = document.documentElement.getAttribute("data-theme"); + const newTheme = currentTheme === "dark" ? "light" : "dark"; - document.documentElement.setAttribute('data-theme', newTheme); + document.documentElement.setAttribute("data-theme", newTheme); // Update theme toggle button icon and accessibility attributes - const themeToggle = document.querySelector('.theme-toggle'); - themeToggle.textContent = newTheme === 'dark' ? '☀️' : '🌙'; - themeToggle.setAttribute('aria-label', newTheme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'); - themeToggle.setAttribute('title', newTheme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'); + const themeToggle = document.querySelector(".theme-toggle"); + themeToggle.textContent = newTheme === "dark" ? "☀️" : "🌙"; + themeToggle.setAttribute( + "aria-label", + newTheme === "dark" ? "Switch to light theme" : "Switch to dark theme", + ); + themeToggle.setAttribute( + "title", + newTheme === "dark" ? "Switch to light theme" : "Switch to dark theme", + ); // Store theme preference in localStorage - localStorage.setItem('log-viewer-theme', newTheme); + localStorage.setItem("log-viewer-theme", newTheme); } // Initialize theme from localStorage or default to light function initializeTheme() { - const savedTheme = localStorage.getItem('log-viewer-theme') || 'light'; - document.documentElement.setAttribute('data-theme', savedTheme); + const savedTheme = localStorage.getItem("log-viewer-theme") || "light"; + document.documentElement.setAttribute("data-theme", savedTheme); // Update theme toggle button icon and accessibility attributes - const themeToggle = document.querySelector('.theme-toggle'); - themeToggle.textContent = savedTheme === 'dark' ? '☀️' : '🌙'; - themeToggle.setAttribute('aria-label', savedTheme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'); - themeToggle.setAttribute('title', savedTheme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'); + const themeToggle = document.querySelector(".theme-toggle"); + themeToggle.textContent = savedTheme === "dark" ? "☀️" : "🌙"; + themeToggle.setAttribute( + "aria-label", + savedTheme === "dark" ? "Switch to light theme" : "Switch to dark theme", + ); + themeToggle.setAttribute( + "title", + savedTheme === "dark" ? "Switch to light theme" : "Switch to dark theme", + ); } // Initialize theme on page load initializeTheme(); -// Initialize timeline collapse state -initializeTimelineState(); - // Initialize connection status updateConnectionStatus(false); // Initialize event listeners when DOM is ready function initializeEventListeners() { // Theme toggle button - const themeToggleBtn = document.getElementById('themeToggleBtn'); + const themeToggleBtn = document.getElementById("themeToggleBtn"); if (themeToggleBtn) { - themeToggleBtn.addEventListener('click', toggleTheme); + themeToggleBtn.addEventListener("click", toggleTheme); } // Control buttons - const connectBtn = document.getElementById('connectBtn'); + const connectBtn = document.getElementById("connectBtn"); if (connectBtn) { - connectBtn.addEventListener('click', connectWebSocket); + connectBtn.addEventListener("click", connectWebSocket); } - const disconnectBtn = document.getElementById('disconnectBtn'); + const disconnectBtn = document.getElementById("disconnectBtn"); if (disconnectBtn) { - disconnectBtn.addEventListener('click', disconnectWebSocket); + disconnectBtn.addEventListener("click", disconnectWebSocket); } - const refreshBtn = document.getElementById('refreshBtn'); + const refreshBtn = document.getElementById("refreshBtn"); if (refreshBtn) { - refreshBtn.addEventListener('click', loadHistoricalLogs); + refreshBtn.addEventListener("click", loadHistoricalLogs); } - const clearFiltersBtn = document.getElementById('clearFiltersBtn'); + const clearFiltersBtn = document.getElementById("clearFiltersBtn"); if (clearFiltersBtn) { - clearFiltersBtn.addEventListener('click', clearFilters); + clearFiltersBtn.addEventListener("click", clearFilters); } - const clearLogsBtn = document.getElementById('clearLogsBtn'); + const clearLogsBtn = document.getElementById("clearLogsBtn"); if (clearLogsBtn) { - clearLogsBtn.addEventListener('click', clearLogs); + clearLogsBtn.addEventListener("click", clearLogs); } - const exportBtn = document.getElementById('exportBtn'); + const exportBtn = document.getElementById("exportBtn"); if (exportBtn) { - exportBtn.addEventListener('click', () => exportLogs('json')); + exportBtn.addEventListener("click", () => exportLogs("json")); } - // Timeline header and toggle button - const timelineHeader = document.getElementById('timelineHeader'); - if (timelineHeader) { - timelineHeader.addEventListener('click', toggleTimeline); + // Flow modal event listeners + const closeModalBtn = document.getElementById("closeFlowModal"); + if (closeModalBtn) { + closeModalBtn.addEventListener("click", closeFlowModal); } - const timelineToggle = document.getElementById('timelineToggle'); - if (timelineToggle) { - timelineToggle.addEventListener('click', (event) => { - event.stopPropagation(); - toggleTimeline(); + const flowModal = document.getElementById("flowModal"); + if (flowModal) { + flowModal.addEventListener("click", (e) => { + if (e.target === flowModal) { + closeFlowModal(); + } + }); + } + + // PR modal event listeners + const closePrModalBtn = document.getElementById("closePrModal"); + if (closePrModalBtn) { + closePrModalBtn.addEventListener("click", closePrModal); + } + + const prModal = document.getElementById("prModal"); + if (prModal) { + prModal.addEventListener("click", (e) => { + if (e.target === prModal) { + closePrModal(); + } }); } } @@ -573,332 +695,932 @@ initializeEventListeners(); // Load initial data loadHistoricalLogs(); -// Timeline functionality -let currentTimelineData = null; +// Flow Modal functionality +let currentFlowData = null; +let currentFlowController = null; +let flowModalKeydownHandler = null; +let flowModalPreviousFocus = null; +let currentStepLogsController = null; +// eslint-disable-next-line no-unused-vars function showTimeline(hookId) { + // Redirect old timeline calls to new modal (backward compatibility shim) + showFlowModal(hookId); +} + +function showFlowModal(hookId) { if (!hookId) { - hideTimeline(); + closeFlowModal(); return; } + // Hide step logs section when opening new modal + const flowLogsSection = document.getElementById("flowLogs"); + if (flowLogsSection) { + flowLogsSection.style.display = "none"; + } + + // Cancel previous fetch if still in progress + if (currentFlowController) { + currentFlowController.abort(); + } + + // Create new AbortController for this fetch + currentFlowController = new AbortController(); + + // Show modal with loading indicator + const modal = document.getElementById("flowModal"); + modal.style.display = "flex"; + showFlowModalLoading(); // Fetch workflow steps data - fetch(`/logs/api/workflow-steps/${hookId}`) - .then(response => { + fetch(`/logs/api/workflow-steps/${hookId}`, { + signal: currentFlowController.signal, + }) + .then((response) => { if (!response.ok) { if (response.status === 404) { - hideTimeline(); + console.log("No flow data found for hook ID:", hookId); + showFlowModalError("No workflow data found for this hook"); return; } - throw new Error('Failed to fetch workflow steps'); + throw new Error(`HTTP ${response.status}: ${response.statusText}`); } return response.json(); }) - .then(data => { - currentTimelineData = data; - renderTimeline(data); - document.getElementById('timelineSection').style.display = 'block'; - - // Ensure the correct collapse state is maintained when showing timeline - initializeTimelineState(); + .then((data) => { + if (data) { + currentFlowData = data; + renderFlowModal(data); + setupFlowModalAccessibility(); + } }) - .catch(error => { - hideTimeline(); + .catch((error) => { + if (error.name === "AbortError") { + // Request was cancelled, ignore silently + return; + } + console.error("Error fetching flow data:", error); + showFlowModalError("Failed to load workflow data. Please try again."); }); } -function hideTimeline() { - document.getElementById('timelineSection').style.display = 'none'; - currentTimelineData = null; -} +function closeFlowModal() { + const modal = document.getElementById("flowModal"); + if (modal) { + modal.style.display = "none"; + } + currentFlowData = null; -function toggleTimeline() { - const content = document.getElementById('timelineContent'); - const toggle = document.getElementById('timelineToggle'); + // Remove keyboard event listener + if (flowModalKeydownHandler) { + document.removeEventListener("keydown", flowModalKeydownHandler); + flowModalKeydownHandler = null; + } - if (content.classList.contains('expanded')) { - // Collapse - content.classList.remove('expanded'); - content.classList.add('collapsed'); - toggle.textContent = '▶ Expand'; + // Restore focus to the element that opened the modal + if (flowModalPreviousFocus) { + flowModalPreviousFocus.focus(); + flowModalPreviousFocus = null; + } +} - // Store collapse state in localStorage - localStorage.setItem('timeline-collapsed', 'true'); - } else { - // Expand - content.classList.remove('collapsed'); - content.classList.add('expanded'); - toggle.textContent = '▼ Collapse'; +// PR Modal functionality +let currentPrController = null; +let prModalKeydownHandler = null; +let prModalPreviousFocus = null; - // Store expand state in localStorage - localStorage.setItem('timeline-collapsed', 'false'); +function showPrModal(prNumber) { + if (!prNumber) { + closePrModal(); + return; + } + + // Cancel previous fetch if still in progress + if (currentPrController) { + currentPrController.abort(); } + + // Create new AbortController for this fetch + currentPrController = new AbortController(); + + // Show modal with loading indicator + const modal = document.getElementById("prModal"); + modal.style.display = "flex"; + showPrModalLoading(); + + // Fetch all log entries for this PR number + const params = new URLSearchParams({ + pr_number: prNumber, + limit: CONFIG.PR_FETCH_LIMIT.toString(), + }); + + fetch(`/logs/api/entries?${params}`, { signal: currentPrController.signal }) + .then((response) => { + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + return response.json(); + }) + .then((data) => { + if (data.entries && data.entries.length > 0) { + // Extract unique hook IDs (deduplicate) + const hookIds = data.entries + .map((e) => e.hook_id) + .filter((id) => id !== null && id !== undefined); + const uniqueHookIds = [...new Set(hookIds)]; + + if (uniqueHookIds.length === 0) { + console.log("No hook IDs found for PR:", prNumber); + showPrModalError(`No workflow events found for PR #${prNumber}`); + return; + } + + renderPrModal(prNumber, uniqueHookIds, data.entries[0].repository); + setupPrModalAccessibility(); + } else { + showPrModalError(`No log entries found for PR #${prNumber}`); + } + }) + .catch((error) => { + if (error.name === "AbortError") { + // Request was cancelled, ignore silently + return; + } + console.error("Error fetching PR data:", error); + showPrModalError("Failed to load PR data. Please try again."); + }); } -function initializeTimelineState() { - // Initialize timeline collapse state from localStorage - default to collapsed - const timelineState = localStorage.getItem('timeline-collapsed'); - const isCollapsed = timelineState === null ? true : timelineState === 'true'; // Default collapsed if no preference set - const content = document.getElementById('timelineContent'); - const toggle = document.getElementById('timelineToggle'); +function closePrModal() { + const modal = document.getElementById("prModal"); + if (modal) { + modal.style.display = "none"; + } - if (isCollapsed) { - content.classList.remove('expanded'); - content.classList.add('collapsed'); - toggle.textContent = '▶ Expand'; - } else { - content.classList.remove('collapsed'); - content.classList.add('expanded'); - toggle.textContent = '▼ Collapse'; + // Remove keyboard event listener + if (prModalKeydownHandler) { + document.removeEventListener("keydown", prModalKeydownHandler); + prModalKeydownHandler = null; + } + + // Restore focus to the element that opened the modal + if (prModalPreviousFocus) { + prModalPreviousFocus.focus(); + prModalPreviousFocus = null; } } -function updateTimelineInfo(data) { - const info = document.getElementById('timelineInfo'); - const duration = data.total_duration_ms > 0 ? `${(data.total_duration_ms / 1000).toFixed(2)}s` : '< 1s'; - info.innerHTML = ` -
Hook ID: ${data.hook_id}
-
Steps: ${data.step_count}
-
Duration: ${duration}
- `; +// Keyboard accessibility for Flow Modal +function setupFlowModalAccessibility() { + const modal = document.getElementById("flowModal"); + if (!modal) return; + + // Set ARIA attributes for screen reader support + modal.setAttribute("role", "dialog"); + modal.setAttribute("aria-modal", "true"); + modal.setAttribute("aria-labelledby", "flowModalTitle"); + modal.setAttribute("aria-describedby", "flowSummary"); + + // Save the element that had focus before modal opened + flowModalPreviousFocus = document.activeElement; + + // Find all focusable elements in the modal + const focusableElements = modal.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + const firstFocusable = focusableElements[0]; + const lastFocusable = focusableElements[focusableElements.length - 1]; + + // Move focus to first interactive element in modal + if (firstFocusable) { + firstFocusable.focus(); + } + + // Create and attach keyboard handler + flowModalKeydownHandler = function (e) { + // Close modal on Escape key + if (e.key === "Escape") { + e.preventDefault(); + closeFlowModal(); + return; + } + + // Trap focus within modal using Tab + if (e.key === "Tab") { + if (e.shiftKey) { + // Shift+Tab: moving backwards + if (document.activeElement === firstFocusable) { + e.preventDefault(); + lastFocusable.focus(); + } + } else { + // Tab: moving forwards + if (document.activeElement === lastFocusable) { + e.preventDefault(); + firstFocusable.focus(); + } + } + } + }; + + document.addEventListener("keydown", flowModalKeydownHandler); } -function renderEmptyTimeline() { - const svg = document.getElementById('timelineSvg'); - svg.innerHTML = 'No workflow steps found'; +// Keyboard accessibility for PR Modal +function setupPrModalAccessibility() { + const modal = document.getElementById("prModal"); + if (!modal) return; + + // Set ARIA attributes for screen reader support + modal.setAttribute("role", "dialog"); + modal.setAttribute("aria-modal", "true"); + modal.setAttribute("aria-labelledby", "prModalTitle"); + modal.setAttribute("aria-describedby", "prSummary"); + + // Save the element that had focus before modal opened + prModalPreviousFocus = document.activeElement; + + // Find all focusable elements in the modal + const focusableElements = modal.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + const firstFocusable = focusableElements[0]; + const lastFocusable = focusableElements[focusableElements.length - 1]; + + // Move focus to first interactive element in modal + if (firstFocusable) { + firstFocusable.focus(); + } + + // Create and attach keyboard handler + prModalKeydownHandler = function (e) { + // Close modal on Escape key + if (e.key === "Escape") { + e.preventDefault(); + closePrModal(); + return; + } + + // Trap focus within modal using Tab + if (e.key === "Tab") { + if (e.shiftKey) { + // Shift+Tab: moving backwards + if (document.activeElement === firstFocusable) { + e.preventDefault(); + lastFocusable.focus(); + } + } else { + // Tab: moving forwards + if (document.activeElement === lastFocusable) { + e.preventDefault(); + firstFocusable.focus(); + } + } + } + }; + + document.addEventListener("keydown", prModalKeydownHandler); } -function renderTimelineVisualization(layout, data) { - const svg = document.getElementById('timelineSvg'); +function renderPrModal(prNumber, hookIds, repository) { + // Render summary section + const summaryElement = document.getElementById("prSummary"); + if (!summaryElement) return; // Clear existing content - svg.innerHTML = ''; - - // SVG dimensions - much larger and adaptive - const width = Math.max(1400, layout.totalWidth + 200); - const height = layout.totalHeight + 150; - const margin = { left: 75, right: 75, top: 75, bottom: 75 }; - - // Update SVG size - svg.setAttribute('width', width); - svg.setAttribute('height', height); - - // Draw timeline lines and steps - layout.lines.forEach((line, lineIndex) => { - const lineY = margin.top + (lineIndex * layout.lineHeight) + layout.lineHeight / 2; - - // Draw horizontal timeline line for this row - if (line.steps.length > 0) { - const lineElement = document.createElementNS('http://www.w3.org/2000/svg', 'line'); - lineElement.setAttribute('class', 'step-line'); - lineElement.setAttribute('x1', margin.left); - lineElement.setAttribute('y1', lineY); - lineElement.setAttribute('x2', margin.left + layout.lineWidth); - lineElement.setAttribute('y2', lineY); - svg.appendChild(lineElement); - } + while (summaryElement.firstChild) { + summaryElement.removeChild(summaryElement.firstChild); + } + + const title = document.createElement("h3"); + title.textContent = `PR #${prNumber} Workflow Overview`; + summaryElement.appendChild(title); + + const info = document.createElement("p"); + info.textContent = `Found ${hookIds.length} unique webhook event${ + hookIds.length !== 1 ? "s" : "" + }${repository ? ` for ${repository}` : ""}`; + info.style.margin = "8px 0 0 0"; + info.style.color = "var(--timestamp-color)"; + summaryElement.appendChild(info); + + // Render hook ID list + const listElement = document.getElementById("prHookList"); + if (!listElement) return; - // Draw steps for this line - line.steps.forEach((step, stepIndex) => { - const stepX = margin.left + (stepIndex * layout.stepSpacing) + layout.stepSpacing / 2; - - const group = document.createElementNS('http://www.w3.org/2000/svg', 'g'); - group.setAttribute('class', 'timeline-step'); - group.setAttribute('data-step-index', step.originalIndex); - - // Step circle - larger - const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); - circle.setAttribute('class', `step-circle ${getStepType(step.message)}`); - circle.setAttribute('cx', stepX); - circle.setAttribute('cy', lineY); - circle.setAttribute('r', 12); // Larger circle - svg.appendChild(circle); - group.appendChild(circle); - - // Step label - with multi-line text wrapping - const labelLines = wrapTextToLines(step.message, 25); // Longer text allowed - labelLines.forEach((line, lineIndex) => { - const label = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - label.setAttribute('class', 'step-label'); - label.setAttribute('x', stepX); - label.setAttribute('y', lineY - 35 + (lineIndex * 14)); // Multi-line spacing - label.setAttribute('text-anchor', 'middle'); - label.setAttribute('font-size', '12'); // Larger font - label.textContent = line; - svg.appendChild(label); - group.appendChild(label); - }); - - // Time label - larger and positioned better - const timeLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - timeLabel.setAttribute('class', 'step-time'); - timeLabel.setAttribute('x', stepX); - timeLabel.setAttribute('y', lineY + 35); - timeLabel.setAttribute('text-anchor', 'middle'); - timeLabel.setAttribute('font-size', '11'); // Larger time font - timeLabel.textContent = `+${(step.relative_time_ms / 1000).toFixed(1)}s`; - svg.appendChild(timeLabel); - group.appendChild(timeLabel); - - // Step index number - larger and better positioned - const indexLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text'); - indexLabel.setAttribute('class', 'step-index'); - indexLabel.setAttribute('x', stepX); - indexLabel.setAttribute('y', lineY + 5); - indexLabel.setAttribute('text-anchor', 'middle'); - indexLabel.setAttribute('font-size', '13'); // Larger index font - indexLabel.setAttribute('font-weight', 'bold'); - indexLabel.setAttribute('fill', 'white'); // White text for better contrast - indexLabel.textContent = (step.originalIndex + 1).toString(); - svg.appendChild(indexLabel); - group.appendChild(indexLabel); - - // Add hover events - group.addEventListener('mouseenter', (e) => showTooltip(e, step)); - group.addEventListener('mouseleave', hideTooltip); - group.addEventListener('click', () => filterByStep(step)); - - svg.appendChild(group); + // Clear existing content + while (listElement.firstChild) { + listElement.removeChild(listElement.firstChild); + } + + if (hookIds.length === 0) { + const emptyMsg = document.createElement("p"); + emptyMsg.style.textAlign = "center"; + emptyMsg.style.color = "var(--timestamp-color)"; + emptyMsg.textContent = "No webhook events found"; + listElement.appendChild(emptyMsg); + return; + } + + // Create clickable list items for each hook ID + hookIds.forEach((hookId, index) => { + const hookItem = document.createElement("div"); + hookItem.className = "pr-hook-item"; + hookItem.addEventListener("click", () => { + closePrModal(); + showFlowModal(hookId); }); + + const icon = document.createElement("span"); + icon.className = "pr-hook-icon"; + icon.textContent = "🔗"; + + const hookIdSpan = document.createElement("span"); + hookIdSpan.className = "pr-hook-id"; + hookIdSpan.textContent = `Event ${index + 1}: ${hookId}`; + + hookItem.appendChild(icon); + hookItem.appendChild(hookIdSpan); + listElement.appendChild(hookItem); }); } -function renderTimeline(data) { - // Update timeline information - updateTimelineInfo(data); +// Flow Modal loading and error helper functions +function showFlowModalLoading() { + const summaryElement = document.getElementById("flowSummary"); + const vizElement = document.getElementById("flowVisualization"); - // Handle empty state - if (data.steps.length === 0) { - renderEmptyTimeline(); - return; + if (summaryElement) { + while (summaryElement.firstChild) { + summaryElement.removeChild(summaryElement.firstChild); + } + const loadingDiv = document.createElement("div"); + loadingDiv.className = "modal-loading"; + loadingDiv.style.textAlign = "center"; + loadingDiv.style.padding = "24px"; + loadingDiv.style.color = "var(--timestamp-color)"; + + const spinner = document.createElement("div"); + spinner.className = "loading-spinner"; + spinner.textContent = "⏳"; + spinner.style.fontSize = "32px"; + spinner.style.marginBottom = "12px"; + + const text = document.createElement("div"); + text.textContent = "Loading workflow data..."; + + loadingDiv.appendChild(spinner); + loadingDiv.appendChild(text); + summaryElement.appendChild(loadingDiv); } - // Calculate layout for multi-line timeline - const layout = calculateMultiLineLayout(data.steps, data.total_duration_ms); - - // Render the timeline visualization - renderTimelineVisualization(layout, data); + if (vizElement) { + while (vizElement.firstChild) { + vizElement.removeChild(vizElement.firstChild); + } + } } -function getStepType(message) { - if (message.includes('completed successfully') || message.includes('success')) { - return 'success'; - } else if (message.includes('failed') || message.includes('error')) { - return 'failure'; - } else if (message.includes('Starting') || message.includes('Executing')) { - return 'progress'; - } else { - return 'info'; +function showFlowModalError(errorMessage) { + const summaryElement = document.getElementById("flowSummary"); + const vizElement = document.getElementById("flowVisualization"); + + if (summaryElement) { + while (summaryElement.firstChild) { + summaryElement.removeChild(summaryElement.firstChild); + } + const errorDiv = document.createElement("div"); + errorDiv.className = "modal-error"; + errorDiv.style.textAlign = "center"; + errorDiv.style.padding = "24px"; + + const icon = document.createElement("div"); + icon.style.fontSize = "48px"; + icon.style.marginBottom = "12px"; + icon.textContent = "⚠️"; + + const message = document.createElement("div"); + message.style.color = "var(--error-color, #dc3545)"; + message.style.fontSize = "16px"; + message.style.marginBottom = "16px"; + message.textContent = errorMessage; + + const closeBtn = document.createElement("button"); + closeBtn.textContent = "Close"; + closeBtn.className = "btn-secondary"; + closeBtn.style.padding = "8px 16px"; + closeBtn.style.cursor = "pointer"; + closeBtn.addEventListener("click", closeFlowModal); + + errorDiv.appendChild(icon); + errorDiv.appendChild(message); + errorDiv.appendChild(closeBtn); + summaryElement.appendChild(errorDiv); + } + + if (vizElement) { + while (vizElement.firstChild) { + vizElement.removeChild(vizElement.firstChild); + } } } -function truncateText(text, maxLength) { - return text.length > maxLength ? text.substring(0, maxLength) + '...' : text; +// PR Modal loading and error helper functions +function showPrModalLoading() { + const summaryElement = document.getElementById("prSummary"); + const listElement = document.getElementById("prHookList"); + + if (summaryElement) { + while (summaryElement.firstChild) { + summaryElement.removeChild(summaryElement.firstChild); + } + const loadingDiv = document.createElement("div"); + loadingDiv.className = "modal-loading"; + loadingDiv.style.textAlign = "center"; + loadingDiv.style.padding = "24px"; + loadingDiv.style.color = "var(--timestamp-color)"; + + const spinner = document.createElement("div"); + spinner.className = "loading-spinner"; + spinner.textContent = "⏳"; + spinner.style.fontSize = "32px"; + spinner.style.marginBottom = "12px"; + + const text = document.createElement("div"); + text.textContent = "Loading PR data..."; + + loadingDiv.appendChild(spinner); + loadingDiv.appendChild(text); + summaryElement.appendChild(loadingDiv); + } + + if (listElement) { + while (listElement.firstChild) { + listElement.removeChild(listElement.firstChild); + } + } } -function calculateMultiLineLayout(steps, totalDuration) { - // Layout configuration - much larger for better readability - const stepsPerLine = 6; // Fewer steps per line for more space - const stepSpacing = 200; // Much larger horizontal space between steps - const lineHeight = 120; // Much larger vertical space between lines - const lineWidth = stepsPerLine * stepSpacing; +function showPrModalError(errorMessage) { + const summaryElement = document.getElementById("prSummary"); + const listElement = document.getElementById("prHookList"); - // Organize steps into lines - const lines = []; - for (let i = 0; i < steps.length; i += stepsPerLine) { - const lineSteps = steps.slice(i, i + stepsPerLine).map((step, index) => ({ - ...step, - originalIndex: i + index - })); - lines.push({ steps: lineSteps }); + if (summaryElement) { + while (summaryElement.firstChild) { + summaryElement.removeChild(summaryElement.firstChild); + } + const errorDiv = document.createElement("div"); + errorDiv.className = "modal-error"; + errorDiv.style.textAlign = "center"; + errorDiv.style.padding = "24px"; + + const icon = document.createElement("div"); + icon.style.fontSize = "48px"; + icon.style.marginBottom = "12px"; + icon.textContent = "⚠️"; + + const message = document.createElement("div"); + message.style.color = "var(--error-color, #dc3545)"; + message.style.fontSize = "16px"; + message.style.marginBottom = "16px"; + message.textContent = errorMessage; + + const closeBtn = document.createElement("button"); + closeBtn.textContent = "Close"; + closeBtn.className = "btn-secondary"; + closeBtn.style.padding = "8px 16px"; + closeBtn.style.cursor = "pointer"; + closeBtn.addEventListener("click", closePrModal); + + errorDiv.appendChild(icon); + errorDiv.appendChild(message); + errorDiv.appendChild(closeBtn); + summaryElement.appendChild(errorDiv); } - return { - lines, - lineHeight, - lineWidth, - stepSpacing, - totalWidth: lineWidth, - totalHeight: lines.length * lineHeight - }; + if (listElement) { + while (listElement.firstChild) { + listElement.removeChild(listElement.firstChild); + } + } } +function groupStepsByTaskId(steps) { + // Show all steps by default - don't filter aggressively + // Only filter out truly redundant internal steps + const filteredSteps = steps.filter((step) => { + // Filter out only very specific internal messages that add no value + const message = step.message ? step.message.toLowerCase() : ""; -function wrapTextToLines(text, maxCharacters) { - // Smart text wrapping for timeline labels - const words = text.split(' '); - const lines = []; - let currentLine = ''; + // Keep all steps except these specific redundant ones + const redundantPatterns = [ + "signature verification successful", + "processing webhook for repository:", + ]; - for (const word of words) { - const testLine = currentLine ? `${currentLine} ${word}` : word; - if (testLine.length <= maxCharacters) { - currentLine = testLine; - } else { - if (currentLine) { - lines.push(currentLine); - currentLine = word; - } else { - // Single word is too long, truncate it - lines.push(word.substring(0, maxCharacters - 3) + '...'); - currentLine = ''; + return !redundantPatterns.some((pattern) => message.includes(pattern)); + }); + + const groups = []; + const ungrouped = []; + const taskMap = new Map(); + + filteredSteps.forEach((step, index) => { + if (step.task_id) { + if (!taskMap.has(step.task_id)) { + taskMap.set(step.task_id, { + task_id: step.task_id, + task_title: step.task_title || step.task_id, + steps: [], + start_time: step.timestamp, + end_time: step.timestamp, + start_index: index, + }); + } + const group = taskMap.get(step.task_id); + group.steps.push({ ...step, original_index: index }); + if (new Date(step.timestamp) > new Date(group.end_time)) { + group.end_time = step.timestamp; } + } else { + ungrouped.push({ ...step, original_index: index }); } + }); + + // Calculate duration and status for each group + taskMap.forEach((group) => { + const startMs = new Date(group.start_time).getTime(); + const endMs = new Date(group.end_time).getTime(); + group.duration_ms = endMs - startMs; + + // Determine group status based on step levels + if (group.steps.some((s) => s.level === "ERROR")) { + group.status = "error"; + } else if (group.steps.some((s) => s.level === "SUCCESS")) { + group.status = "success"; + } else { + group.status = "in_progress"; + } + + groups.push(group); + }); + + // Sort groups by start index to maintain chronological order + groups.sort((a, b) => a.start_index - b.start_index); + + return { groups, ungrouped }; +} + +function renderFlowModal(data) { + // Render summary section using safe DOM methods + const summaryElement = document.getElementById("flowSummary"); + if (!summaryElement) return; + + // Clear existing content + while (summaryElement.firstChild) { + summaryElement.removeChild(summaryElement.firstChild); } - if (currentLine) { - lines.push(currentLine); + const title = document.createElement("h3"); + title.textContent = "Flow Overview"; + summaryElement.appendChild(title); + + const grid = document.createElement("div"); + grid.className = "flow-summary-grid"; + + // Helper to create summary items safely + const createSummaryItem = (label, value) => { + const item = document.createElement("div"); + item.className = "flow-summary-item"; + + const labelDiv = document.createElement("div"); + labelDiv.className = "flow-summary-label"; + labelDiv.textContent = label; + + const valueDiv = document.createElement("div"); + valueDiv.className = "flow-summary-value"; + valueDiv.textContent = value; + + item.appendChild(labelDiv); + item.appendChild(valueDiv); + return item; + }; + + const duration = + data.total_duration_ms > 0 + ? `${(data.total_duration_ms / 1000).toFixed(2)}s` + : "< 1s"; + + grid.appendChild(createSummaryItem("Hook ID", data.hook_id)); + grid.appendChild( + createSummaryItem("Total Steps", data.step_count.toString()), + ); + grid.appendChild(createSummaryItem("Duration", duration)); + + if (data.steps[0] && data.steps[0].repository) { + grid.appendChild(createSummaryItem("Repository", data.steps[0].repository)); } - // Return max 2 lines to prevent overcrowding - return lines.slice(0, 2); -} + summaryElement.appendChild(grid); -function showTooltip(event, step) { - const tooltip = document.getElementById('timelineTooltip'); - const timeFromStart = `+${(step.relative_time_ms / 1000).toFixed(2)}s`; + // Render vertical flow visualization using safe DOM methods + const vizElement = document.getElementById("flowVisualization"); + if (!vizElement) return; - tooltip.innerHTML = ` -
Step: ${step.message}
-
Time: ${timeFromStart}
-
Timestamp: ${new Date(step.timestamp).toLocaleTimeString()}
- ${step.pr_number ? `
PR: #${step.pr_number}
` : ''} -
Click to filter logs by this step
- `; + // Clear existing content + while (vizElement.firstChild) { + vizElement.removeChild(vizElement.firstChild); + } + + if (data.steps.length === 0) { + const emptyMsg = document.createElement("p"); + emptyMsg.style.textAlign = "center"; + emptyMsg.style.color = "var(--timestamp-color)"; + emptyMsg.textContent = "No workflow steps found"; + vizElement.appendChild(emptyMsg); + return; + } - const rect = event.target.getBoundingClientRect(); - const containerRect = document.getElementById('timelineSection').getBoundingClientRect(); + // Group steps by task_id + const { groups, ungrouped } = groupStepsByTaskId(data.steps); - tooltip.style.left = (rect.left - containerRect.left + rect.width / 2) + 'px'; - tooltip.style.top = (rect.top - containerRect.top - tooltip.offsetHeight - 10) + 'px'; - tooltip.style.display = 'block'; + // Render grouped steps + groups.forEach((group) => { + renderTaskGroup(group, vizElement); + }); + + // Render ungrouped steps + ungrouped.forEach((step) => { + renderSingleStep(step, vizElement); + }); + + // Add final status + const hasErrors = data.steps.some((step) => step.level === "ERROR"); + const finalStatus = document.createElement("div"); + finalStatus.className = hasErrors ? "flow-error" : "flow-success"; + + const statusTitle = document.createElement("h3"); + statusTitle.textContent = hasErrors + ? "⚠️ Flow Completed with Errors" + : "✓ Flow Completed Successfully"; + finalStatus.appendChild(statusTitle); + + if (hasErrors) { + const errorMsg = document.createElement("div"); + errorMsg.className = "flow-error-message"; + errorMsg.textContent = + "Some steps encountered errors. Check the logs for details."; + finalStatus.appendChild(errorMsg); + } + + vizElement.appendChild(finalStatus); } -function hideTooltip() { - document.getElementById('timelineTooltip').style.display = 'none'; +function renderTaskGroup(group, parentElement) { + const taskGroupContainer = document.createElement("div"); + taskGroupContainer.className = "task-group"; + + // Create group header + const groupHeader = document.createElement("div"); + groupHeader.className = "task-group-header"; + groupHeader.style.cursor = "pointer"; + + // Collapse arrow + const arrow = document.createElement("span"); + arrow.className = "task-group-arrow collapsed"; + arrow.textContent = "►"; + + // Status icon + const statusIcon = document.createElement("span"); + statusIcon.className = `task-group-status task-group-${group.status}`; + if (group.status === "success") { + statusIcon.textContent = "✓"; + } else if (group.status === "error") { + statusIcon.textContent = "✗"; + } else { + statusIcon.textContent = "◷"; + } + + // Task title + const taskTitle = document.createElement("span"); + taskTitle.className = "task-group-title"; + taskTitle.textContent = group.task_title; + + // Duration + const duration = document.createElement("span"); + duration.className = "task-group-duration"; + duration.textContent = `${(group.duration_ms / 1000).toFixed(2)}s`; + + groupHeader.appendChild(arrow); + groupHeader.appendChild(statusIcon); + groupHeader.appendChild(taskTitle); + groupHeader.appendChild(duration); + + // Create nested steps container + const stepsContainer = document.createElement("div"); + stepsContainer.className = "task-group-steps"; + stepsContainer.style.display = "none"; // Start collapsed + + group.steps.forEach((step) => { + renderSingleStep(step, stepsContainer, true); + }); + + // Toggle expand/collapse + groupHeader.addEventListener("click", () => { + const isCollapsed = stepsContainer.style.display === "none"; + stepsContainer.style.display = isCollapsed ? "block" : "none"; + arrow.className = isCollapsed + ? "task-group-arrow expanded" + : "task-group-arrow collapsed"; + }); + + taskGroupContainer.appendChild(groupHeader); + taskGroupContainer.appendChild(stepsContainer); + parentElement.appendChild(taskGroupContainer); } -function filterByStep(step) { - // Set search filter to find this specific step message - document.getElementById('searchFilter').value = step.message.substring(0, 30); - debounceFilter(); +function renderSingleStep(step, parentElement, isNested = false) { + const stepType = getStepType(step.level); + const timeFromStart = `+${(step.relative_time_ms / 1000).toFixed(2)}s`; + const timestamp = new Date(step.timestamp).toLocaleTimeString(); + + const flowStepContainer = document.createElement("div"); + flowStepContainer.className = isNested + ? "flow-step-container nested" + : "flow-step-container"; + + const flowStep = document.createElement("div"); + flowStep.className = `flow-step ${stepType}`; + flowStep.setAttribute("data-step-index", step.original_index.toString()); + flowStep.style.cursor = "pointer"; + flowStep.addEventListener("click", () => filterByStep(step.original_index)); + + const stepNumber = document.createElement("div"); + stepNumber.className = "flow-step-number"; + stepNumber.textContent = (step.original_index + 1).toString(); + + const stepContent = document.createElement("div"); + stepContent.className = "flow-step-content"; + + const stepTitle = document.createElement("div"); + stepTitle.className = "flow-step-title"; + stepTitle.textContent = step.message; + + const stepTime = document.createElement("div"); + stepTime.className = "flow-step-time"; + + const timestampSpan = document.createElement("span"); + timestampSpan.textContent = timestamp; + + const durationSpan = document.createElement("span"); + durationSpan.className = "flow-step-duration"; + durationSpan.textContent = timeFromStart; + + stepTime.appendChild(timestampSpan); + stepTime.appendChild(durationSpan); + + stepContent.appendChild(stepTitle); + stepContent.appendChild(stepTime); + + flowStep.appendChild(stepNumber); + flowStep.appendChild(stepContent); + + // Create logs container for this step (hidden by default) + const stepLogsContainer = document.createElement("div"); + stepLogsContainer.className = "step-logs-container"; + stepLogsContainer.style.display = "none"; + stepLogsContainer.setAttribute( + "data-step-logs", + step.original_index.toString(), + ); + + flowStepContainer.appendChild(flowStep); + flowStepContainer.appendChild(stepLogsContainer); + + parentElement.appendChild(flowStepContainer); } -// Auto-show timeline when hook ID filter is applied -function checkForTimelineDisplay() { - const hookId = document.getElementById('hookIdFilter').value.trim(); - if (hookId) { - showTimeline(hookId); +function getStepType(level) { + // Accept level parameter to determine step type based on log level + const levelUpper = typeof level === "string" ? level.toUpperCase() : ""; + + if (levelUpper === "SUCCESS") { + return "success"; + } else if (levelUpper === "ERROR") { + return "error"; + } else if (levelUpper === "WARNING") { + return "warning"; } else { - hideTimeline(); + return "info"; } } -// Add timeline check to hook ID filter specifically -document.getElementById('hookIdFilter').addEventListener('input', () => { - setTimeout(checkForTimelineDisplay, 300); // Small delay to let the value settle -}); +async function filterByStep(stepIndex) { + if (!currentFlowData || !currentFlowData.steps[stepIndex]) return; + + const step = currentFlowData.steps[stepIndex]; + const logsContainer = document.querySelector( + `[data-step-logs="${stepIndex}"]`, + ); + + if (!logsContainer) return; + + // Toggle: if this step's logs are already showing, hide them + if (logsContainer.style.display === "block") { + logsContainer.style.display = "none"; + logsContainer.innerHTML = ""; + return; + } -// Also check on initial load -setTimeout(checkForTimelineDisplay, 1000); + // Hide all other step logs + document.querySelectorAll(".step-logs-container").forEach((container) => { + container.style.display = "none"; + container.innerHTML = ""; + }); + + // Show logs for this step + await showStepLogsInModal(step, logsContainer); +} + +async function showStepLogsInModal(step, logsContainer) { + if (!logsContainer) return; + + // Show loading state + logsContainer.style.display = "block"; + logsContainer.textContent = "Loading logs..."; + + // Cancel previous fetch if still in progress + if (currentStepLogsController) { + currentStepLogsController.abort(); + } + + // Create new AbortController for this fetch + currentStepLogsController = new AbortController(); + + try { + // Using full message for precision to avoid ambiguous matches + const searchText = step.message; + const hookId = currentFlowData.hook_id; + + const params = new URLSearchParams({ + hook_id: hookId, + search: searchText, + limit: "100", + }); + + const response = await fetch(`/logs/api/entries?${params}`, { + signal: currentStepLogsController.signal, + }); + if (!response.ok) throw new Error("Failed to fetch logs"); + + const data = await response.json(); + + // Clear and display logs using safe DOM methods + logsContainer.textContent = ""; + + if (data.entries.length === 0) { + const emptyMsg = document.createElement("div"); + emptyMsg.textContent = "No logs found for this step"; + emptyMsg.style.textAlign = "center"; + emptyMsg.style.color = "var(--timestamp-color)"; + emptyMsg.style.padding = "12px"; + logsContainer.appendChild(emptyMsg); + return; + } + + // Render log entries + data.entries.forEach((entry) => { + const logEntry = document.createElement("div"); + logEntry.className = `log-entry ${entry.level}`; + + 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); + + logsContainer.appendChild(logEntry); + }); + + // Scroll to the logs container + logsContainer.scrollIntoView({ behavior: "smooth", block: "nearest" }); + } catch (error) { + if (error.name === "AbortError") { + // Request was cancelled, ignore silently + return; + } + console.error("Error fetching step logs:", error); + logsContainer.textContent = "Error loading logs"; + } +} diff --git a/webhook_server/web/templates/log_viewer.html b/webhook_server/web/templates/log_viewer.html index 4fbcab23f..315a3a349 100644 --- a/webhook_server/web/templates/log_viewer.html +++ b/webhook_server/web/templates/log_viewer.html @@ -1,117 +1,153 @@ - + - - - + + + GitHub Webhook Server - Log Viewer - - - + + +
-
-
-

GitHub Webhook Server - Log Viewer

-

Real-time log monitoring and filtering for webhook events

-
- +
+
+

GitHub Webhook Server - Log Viewer

+

Real-time log monitoring and filtering for webhook events

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