diff --git a/.gitignore b/.gitignore index 3832553..1ab4ce2 100644 --- a/.gitignore +++ b/.gitignore @@ -42,6 +42,23 @@ wheels/ pip-log.txt pip-delete-this-directory.txt +# ============================================ +# Node.js / Frontend +# ============================================ +node_modules/ +ui/node_modules/ +.npm/ +.pnpm-store/ +.yarn/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +ui/dist/ +ui/.vite/ +ui/coverage/ +ui/tsconfig.tsbuildinfo + # ============================================ # IDEs & Editors # ============================================ @@ -98,6 +115,7 @@ Thumbs.db # ============================================ GITHUB_TOKEN_SETUP.md docs/*.tmp +config.overrides.yaml # ============================================ # CI/CD diff --git a/config.yaml b/config.yaml index 0980339..786d04a 100644 --- a/config.yaml +++ b/config.yaml @@ -72,3 +72,8 @@ logging: level: "INFO" # DEBUG | INFO | WARNING | ERROR format: "json" # json | console +# UI canlΔ± log dashboard ayarlarΔ± +ui: + logs: + poll_interval_seconds: 3 # 1-30 arasΔ± polling interval + max_events_per_poll: 200 # 20-500 arasΔ± event limiti diff --git a/docker/Dockerfile b/docker/Dockerfile index 71c1fd5..f0d6125 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -1,5 +1,16 @@ # Multi-stage build for MCP Code Review Server +FROM node:20-alpine as ui-builder + +WORKDIR /app/ui + +# Install UI dependencies and build artifacts +COPY ui/package*.json ./ +RUN npm ci +COPY ui/ ./ +RUN npm run build + + FROM python:3.11-slim as builder WORKDIR /app @@ -25,6 +36,7 @@ COPY --from=builder /root/.local /root/.local # Copy application code COPY . . +COPY --from=ui-builder /app/ui/dist ./ui/dist # Make sure scripts are in PATH ENV PATH=/root/.local/bin:$PATH @@ -38,4 +50,3 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ # Run server CMD ["python", "server.py"] - diff --git a/server.py b/server.py index 9c75fc6..054c700 100644 --- a/server.py +++ b/server.py @@ -2,27 +2,41 @@ MCP Code Review Server Platform-agnostic AI-powered code review via webhooks and MCP tools """ +from __future__ import annotations + import os import yaml import structlog from contextlib import asynccontextmanager +from copy import deepcopy +from pathlib import Path +from typing import Any from fastapi import FastAPI, Request, HTTPException, Query -from fastapi.responses import JSONResponse +from fastapi.responses import JSONResponse, FileResponse +from fastapi.staticfiles import StaticFiles from dotenv import load_dotenv # MCP SDK -from mcp.server import Server -from mcp.server.sse import SseServerTransport -from mcp.types import Tool, TextContent +try: + from mcp.server import Server as MCPServer + from mcp.server.sse import SseServerTransport + from mcp.types import Tool, TextContent + MCP_AVAILABLE = True +except ImportError: + MCP_AVAILABLE = False + MCPServer = None + SseServerTransport = None + Tool = Any # type: ignore[assignment] + TextContent = Any # type: ignore[assignment] # Local imports from models import Platform, ReviewRequest from webhook import WebhookHandler from services import AIReviewer, DiffAnalyzer, CommentService -from adapters import GitHubAdapter, GitLabAdapter, BitbucketAdapter, AzureAdapter from tools import ReviewTools from services.rules_service import RulesHelper + # Load environment variables load_dotenv() @@ -35,9 +49,102 @@ ) logger = structlog.get_logger() +ALLOWED_COMMENT_STRATEGIES = {"summary", "inline", "both"} +AI_PROVIDER_MODELS: dict[str, list[str]] = { + "openai": ["gpt-4o-mini", "gpt-4o", "gpt-4-turbo-preview"], + "anthropic": ["claude-3-5-sonnet-20241022"], + "groq": ["llama-3.3-70b-versatile", "llama-3.1-70b-versatile", "mixtral-8x7b-32768"], +} +ALLOWED_AI_PROVIDERS = set(AI_PROVIDER_MODELS.keys()) +CONFIG_FILE_PATH = Path(os.getenv("CONFIG_FILE_PATH", "config.yaml")) +CONFIG_OVERRIDES_PATH = Path(os.getenv("CONFIG_OVERRIDES_PATH", "config.overrides.yaml")) + + +def _read_yaml_file(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + with path.open("r", encoding="utf-8") as f: + loaded = yaml.safe_load(f) or {} + if not isinstance(loaded, dict): + return {} + return loaded + + +def _deep_merge_dict(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + result = deepcopy(base) + for key, value in override.items(): + if isinstance(value, dict) and isinstance(result.get(key), dict): + result[key] = _deep_merge_dict(result[key], value) + else: + result[key] = deepcopy(value) + return result + + +def load_runtime_config() -> dict[str, Any]: + base = _read_yaml_file(CONFIG_FILE_PATH) + overrides = _read_yaml_file(CONFIG_OVERRIDES_PATH) + return _deep_merge_dict(base, overrides) + + +def extract_editable_config(raw_config: dict[str, Any]) -> dict[str, Any]: + review_cfg = raw_config.get("review") or {} + ui_cfg = raw_config.get("ui") or {} + logs_cfg = ui_cfg.get("logs") or {} + ai_cfg = raw_config.get("ai") or {} + + provider = str(ai_cfg.get("provider", "")).lower() + providers_cfg = ai_cfg.get("providers") + if (not provider or provider not in ALLOWED_AI_PROVIDERS) and isinstance(providers_cfg, list) and providers_cfg: + primary = str(ai_cfg.get("primary") or "").lower() + if primary in ALLOWED_AI_PROVIDERS: + provider = primary + else: + first = providers_cfg[0] + if isinstance(first, dict): + provider = str(first.get("name", "")).lower() + if not provider: + provider = "openai" + if provider not in ALLOWED_AI_PROVIDERS: + provider = "openai" + model = str(ai_cfg.get("model") or "") + if not model and isinstance(providers_cfg, list): + for item in providers_cfg: + if not isinstance(item, dict): + continue + if str(item.get("name", "")).lower() == provider: + model = str(item.get("model") or "") + break + if not model: + model = AI_PROVIDER_MODELS[provider][0] + if model not in AI_PROVIDER_MODELS[provider]: + model = AI_PROVIDER_MODELS[provider][0] + + return { + "ui": { + "logs": { + "poll_interval_seconds": int(logs_cfg.get("poll_interval_seconds", 3)), + "max_events_per_poll": int(logs_cfg.get("max_events_per_poll", 200)), + } + }, + "review": { + "comment_strategy": review_cfg.get("comment_strategy", "summary"), + "focus": list(review_cfg.get("focus") or []), + }, + "ai": { + "provider": provider, + "model": model, + }, + } + + +def persist_editable_overrides(editable_config: dict[str, Any]) -> None: + CONFIG_OVERRIDES_PATH.parent.mkdir(parents=True, exist_ok=True) + with CONFIG_OVERRIDES_PATH.open("w", encoding="utf-8") as f: + yaml.safe_dump(editable_config, f, allow_unicode=True, sort_keys=False) + + # Load configuration -with open("config.yaml", "r") as f: - config = yaml.safe_load(f) +config = load_runtime_config() class CodeReviewServer: @@ -52,10 +159,12 @@ def __init__(self): self.ai_reviewer = AIReviewer(ai_config=ai_config) self.diff_analyzer = DiffAnalyzer() - - # Initialize comment service with template from config + + self.ui_logs_config = parse_ui_logs_config(self.config) + self.live_logs = LiveLogStore(max_events_per_run=self.ui_logs_config.max_events_per_poll) template_config = config.get("review", {}).get("template") self.comment_service = CommentService(template_config=template_config) + # Initialize platform adapters self.adapters = {} @@ -65,6 +174,16 @@ def __init__(self): self.review_tools = ReviewTools(self.ai_reviewer, self.diff_analyzer) logger.info("code_review_server_initialized") + + def update_runtime_config(self, updated_config: dict[str, Any]) -> dict[str, Any]: + global config + self.config = updated_config + config = deepcopy(updated_config) + self.ui_logs_config = parse_ui_logs_config(self.config) + self.live_logs.set_max_events_per_run(self.ui_logs_config.max_events_per_poll) + self.ai_reviewer = AIReviewer(ai_config=self.config.get("ai", {})) + self.review_tools = ReviewTools(self.ai_reviewer, self.diff_analyzer) + return extract_editable_config(self.config) def _init_adapters(self): """Initialize enabled platform adapters""" @@ -72,94 +191,184 @@ def _init_adapters(self): if platforms_config.get('github', {}).get('enabled'): try: + from adapters.github_adapter import GitHubAdapter self.adapters[Platform.GITHUB] = GitHubAdapter() except Exception as e: logger.warning("github_adapter_init_failed", error=str(e)) if platforms_config.get('gitlab', {}).get('enabled'): try: + from adapters.gitlab_adapter import GitLabAdapter self.adapters[Platform.GITLAB] = GitLabAdapter() except Exception as e: logger.warning("gitlab_adapter_init_failed", error=str(e)) if platforms_config.get('bitbucket', {}).get('enabled'): try: + from adapters.bitbucket_adapter import BitbucketAdapter self.adapters[Platform.BITBUCKET] = BitbucketAdapter() except Exception as e: logger.warning("bitbucket_adapter_init_failed", error=str(e)) if platforms_config.get('azure', {}).get('enabled'): try: + from adapters.azure_adapter import AzureAdapter self.adapters[Platform.AZURE] = AzureAdapter() except Exception as e: logger.warning("azure_adapter_init_failed", error=str(e)) + + def _start_live_run(self, pr_data) -> str | None: + try: + return self.live_logs.start_run( + platform=pr_data.platform.value, + pr_id=str(pr_data.pr_id), + title=pr_data.title, + author=pr_data.author, + source_branch=pr_data.source_branch, + target_branch=pr_data.target_branch, + repo=pr_data.repo_full_name, + ) + except Exception as e: + logger.warning("live_run_start_failed", error=str(e)) + return None + + def _emit_live_event( + self, + run_id: str | None, + *, + step: str, + message: str, + level: str = "info", + meta: dict[str, Any] | None = None, + ) -> None: + if not run_id: + return + try: + self.live_logs.append_event( + run_id, + step=step, + message=message, + level=level, + meta=meta, + ) + except Exception as e: + logger.warning("live_event_append_failed", run_id=run_id, error=str(e)) + + def _complete_live_run(self, run_id: str | None, *, score: int, issues: int, critical: int) -> None: + if not run_id: + return + try: + self.live_logs.complete_run(run_id, score=score, issues=issues, critical=critical) + except Exception as e: + logger.warning("live_run_complete_failed", run_id=run_id, error=str(e)) + + def _fail_live_run(self, run_id: str | None, *, error: str) -> None: + if not run_id: + return + try: + self.live_logs.fail_run(run_id, error=error) + except Exception as e: + logger.warning("live_run_fail_failed", run_id=run_id, error=str(e)) async def process_webhook(self, request: Request) -> dict: """ Process incoming webhook from any platform - + Args: request: FastAPI request - + Returns: Response dict """ - print("\n" + "="*80) - print("πŸ”” WEBHOOK RECEIVED") - print("="*80) - + run_id: str | None = None + + def out( + message: str, + *, + step: str = "console", + level: str = "info", + meta: dict[str, Any] | None = None, + ) -> None: + print(message) + self._emit_live_event(run_id, step=step, message=message, level=level, meta=meta) + + out("\n" + "=" * 80, step="console_banner") + out("πŸ”” WEBHOOK RECEIVED", step="console_banner") + out("=" * 80, step="console_banner") + # Parse webhook pr_data = await self.webhook_handler.handle(request) - + if not pr_data: - print("⚠️ Ignored: Not a PR event or unsupported platform") - print("="*80 + "\n") + out("⚠️ Ignored: Not a PR event or unsupported platform", step="ignored", level="warning") + out("=" * 80 + "\n", step="console_banner") return {"status": "ignored", "message": "Not a PR event or unsupported platform"} - - print(f"πŸ“¦ Platform: {pr_data.platform.value.upper()}") - print(f"πŸ”— PR #{pr_data.pr_id}: {pr_data.title}") - print(f"πŸ‘€ Author: {pr_data.author}") - print(f"🌿 {pr_data.source_branch} β†’ {pr_data.target_branch}") - print("-"*80) - + + # Start live run before detailed prints so console lines are streamed to UI. + run_id = self._start_live_run(pr_data) + + out(f"πŸ“¦ Platform: {pr_data.platform.value.upper()}", step="console_header") + out(f"πŸ”— PR #{pr_data.pr_id}: {pr_data.title}", step="console_header") + out(f"πŸ‘€ Author: {pr_data.author}", step="console_header") + out(f"🌿 {pr_data.source_branch} β†’ {pr_data.target_branch}", step="console_header") + out("-" * 80, step="console_header") + logger.info("processing_webhook", platform=pr_data.platform.value, pr_id=pr_data.pr_id) - + # Get platform adapter adapter = self.adapters.get(pr_data.platform) if not adapter: - print(f"❌ ERROR: No adapter available for {pr_data.platform.value}") - print("="*80 + "\n") + out(f"❌ ERROR: No adapter available for {pr_data.platform.value}", step="adapter", level="error") + out("=" * 80 + "\n", step="console_banner") logger.error("no_adapter_available", platform=pr_data.platform.value) - return {"status": "error", "message": "Platform adapter not available"} - + self._fail_live_run(run_id, error="Platform adapter not available") + return { + "status": "error", + "message": "Platform adapter not available", + "run_id": run_id, + "pr_id": pr_data.pr_id, + "platform": pr_data.platform.value, + } + try: # Fetch actual diff - print("πŸ“₯ Step 1/5: Fetching diff from platform...") + out("πŸ“₯ Step 1/5: Fetching diff from platform...", step="step_1") diff = await adapter.fetch_diff(pr_data) if not diff: - print("❌ Failed to fetch diff") - print("="*80 + "\n") + out("❌ Failed to fetch diff", step="step_1", level="error") + out("=" * 80 + "\n", step="console_banner") logger.warning("no_diff_fetched", pr_id=pr_data.pr_id) - return {"status": "error", "message": "Failed to fetch diff"} - print(f"βœ… Diff fetched successfully ({len(diff)} bytes)") + self._fail_live_run(run_id, error="Failed to fetch diff") + return { + "status": "error", + "message": "Failed to fetch diff", + "run_id": run_id, + "pr_id": pr_data.pr_id, + "platform": pr_data.platform.value, + } + out(f"βœ… Diff fetched successfully ({len(diff)} bytes)", step="step_1", meta={"bytes": len(diff)}) print() - + pr_data.diff = diff - + # Analyze diff - print("πŸ” Step 2/5: Analyzing diff...") + out("πŸ” Step 2/5: Analyzing diff...", step="step_2") diff_info = self.diff_analyzer.parse_diff(diff) - pr_data.files_changed = [f['path'] for f in diff_info['files']] - print(f"βœ… Found {len(pr_data.files_changed)} changed file(s):") - for file in pr_data.files_changed[:5]: # Show first 5 - print(f" πŸ“„ {file}") + pr_data.files_changed = [f["path"] for f in diff_info["files"]] + out( + f"βœ… Found {len(pr_data.files_changed)} changed file(s):", + step="step_2", + meta={"files_count": len(pr_data.files_changed)}, + ) + for file in pr_data.files_changed[:5]: + out(f" πŸ“„ {file}", step="step_2_file") if len(pr_data.files_changed) > 5: - print(f" ... and {len(pr_data.files_changed) - 5} more") + out(f" ... and {len(pr_data.files_changed) - 5} more", step="step_2_file") print() - + # Perform AI review - print("πŸ€– Step 3/5: Starting AI code review...") - review_config = self.config['review'] + out("πŸ€– Step 3/5: Starting AI code review...", step="step_3") + review_config = self.config["review"] ai_cfg = self.config.get("ai", {}) if isinstance(ai_cfg.get("providers"), list) and ai_cfg["providers"]: primary = ai_cfg.get("primary") or ai_cfg["providers"][0].get("name") @@ -168,122 +377,128 @@ async def process_webhook(self, request: Request) -> dict: if (p.get("name") or "").lower() == (primary or "").lower(): model = p.get("model") break - print(f" Provider: {str(primary).upper() if primary else 'UNKNOWN'}") - print(f" Model: {model}") + out(f" Provider: {str(primary).upper() if primary else 'UNKNOWN'}", step="step_3") + out(f" Model: {model}", step="step_3") else: - print(f" Provider: {ai_cfg.get('provider', 'groq').upper()}") - print(f" Model: {ai_cfg.get('model')}") - print(f" Focus areas: {', '.join(review_config.get('focus', []))}") + out(f" Provider: {ai_cfg.get('provider', 'groq').upper()}", step="step_3") + out(f" Model: {ai_cfg.get('model')}", step="step_3") + out(f" Focus areas: {', '.join(review_config.get('focus', []))}", step="step_3") print() - + review_result = await self.ai_reviewer.review( diff=diff, files_changed=pr_data.files_changed, - focus_areas=review_config.get('focus', []) + focus_areas=review_config.get("focus", []), ) - - print(f"βœ… AI Review completed!") - print(f" Score: {review_result.score}/10") - print(f" Issues: {review_result.total_issues} total") + + out("βœ… AI Review completed!", step="step_3") + out(f" Score: {review_result.score}/10", step="step_3") + out(f" Issues: {review_result.total_issues} total", step="step_3") if review_result.critical_count > 0: - print(f" πŸ”΄ Critical: {review_result.critical_count}") + out(f" πŸ”΄ Critical: {review_result.critical_count}", step="step_3") if review_result.high_count > 0: - print(f" 🟠 High: {review_result.high_count}") + out(f" 🟠 High: {review_result.high_count}", step="step_3") if review_result.medium_count > 0: - print(f" 🟑 Medium: {review_result.medium_count}") + out(f" 🟑 Medium: {review_result.medium_count}", step="step_3") print() - + # Post comments based on strategy - print("πŸ’¬ Step 4/5: Posting review comments...") - strategy = review_config.get('comment_strategy', 'both') - print(f" Strategy: {strategy}") - + out("πŸ’¬ Step 4/5: Posting review comments...", step="step_4") + strategy = review_config.get("comment_strategy", "both") + out(f" Strategy: {strategy}", step="step_4") + # Check if detailed table should be shown for this branch - detailed_branches = review_config.get('detailed_analysis_branches', []) + detailed_branches = review_config.get("detailed_analysis_branches", []) show_detailed_table = pr_data.target_branch in detailed_branches - + if show_detailed_table: - print(f" πŸ“Š Detailed analysis table enabled for target branch: {pr_data.target_branch}") - - if strategy in ['summary', 'both']: - demo_all = review_config.get('template', {}).get('demo_all', False) - - if demo_all: - from review_templates import BUILTIN_TEMPLATES, get_template - for idx, name in enumerate(BUILTIN_TEMPLATES, 1): - tmpl = get_template({"name": name}) - header = f"### πŸ“‹ Template {idx}/{len(BUILTIN_TEMPLATES)}: **{name.upper()}**\n---\n" - body = tmpl.render_summary(review_result, show_detailed_table=show_detailed_table) - await adapter.post_summary_comment(pr_data, header + body) - print(f" βœ… Template '{name}' comment posted") - else: - print(" πŸ“ Posting summary comment...") - summary_comment = self.comment_service.format_summary_comment( - review_result, - show_detailed_table=show_detailed_table - ) - await adapter.post_summary_comment(pr_data, summary_comment) - print(" βœ… Summary comment posted") - - if strategy in ['inline', 'both']: + out( + f" πŸ“Š Detailed analysis table enabled for target branch: {pr_data.target_branch}", + step="step_4", + ) + + if strategy in ["summary", "both"]: + out(" πŸ“ Posting summary comment...", step="step_4") + summary_comment = self.comment_service.format_summary_comment( + review_result, + show_detailed_table=show_detailed_table, + ) + await adapter.post_summary_comment(pr_data, summary_comment) + out(" βœ… Summary comment posted", step="step_4") + + if strategy in ["inline", "both"]: inline_comments = self.comment_service.format_inline_comments(review_result) if inline_comments: - print(f" πŸ’­ Posting {len(inline_comments)} inline comment(s)...") + out(f" πŸ’­ Posting {len(inline_comments)} inline comment(s)...", step="step_4") await adapter.post_inline_comments(pr_data, inline_comments) - print(f" βœ… Inline comments posted") + out(" βœ… Inline comments posted", step="step_4") print() - + # Update status - print("πŸ“Š Step 5/5: Updating PR status...") + out("πŸ“Š Step 5/5: Updating PR status...", step="step_5") if review_result.block_merge: status_msg = "Critical issues found - merge blocked" - print(f" ❌ Status: FAILURE") - print(f" Message: {status_msg}") + out(" ❌ Status: FAILURE", step="step_5", level="error") + out(f" Message: {status_msg}", step="step_5", level="error") await adapter.update_status(pr_data, "failure", status_msg) elif review_result.score >= 8: status_msg = f"Code quality: {review_result.score}/10" - print(f" βœ… Status: SUCCESS") - print(f" Message: {status_msg}") + out(" βœ… Status: SUCCESS", step="step_5") + out(f" Message: {status_msg}", step="step_5") await adapter.update_status(pr_data, "success", status_msg) else: status_msg = f"Review complete: {review_result.score}/10" - print(f" βœ… Status: SUCCESS") - print(f" Message: {status_msg}") + out(" βœ… Status: SUCCESS", step="step_5") + out(f" Message: {status_msg}", step="step_5") await adapter.update_status(pr_data, "success", status_msg) print() - + logger.info( "review_completed", pr_id=pr_data.pr_id, score=review_result.score, - issues=review_result.total_issues + issues=review_result.total_issues, + ) + + out("πŸŽ‰ REVIEW COMPLETED SUCCESSFULLY", step="summary") + out(f" PR: #{pr_data.pr_id}", step="summary") + out(f" Score: {review_result.score}/10", step="summary") + out(f" Issues: {review_result.total_issues}", step="summary") + out( + f" Status: {'BLOCKED' if review_result.block_merge else 'APPROVED' if review_result.score >= 8 else 'REVIEW NEEDED'}", + step="summary", + ) + out("=" * 80 + "\n", step="console_banner") + self._complete_live_run( + run_id, + score=review_result.score, + issues=review_result.total_issues, + critical=review_result.critical_count, ) - - print("πŸŽ‰ REVIEW COMPLETED SUCCESSFULLY") - print(f" PR: #{pr_data.pr_id}") - print(f" Score: {review_result.score}/10") - print(f" Issues: {review_result.total_issues}") - print(f" Status: {'BLOCKED' if review_result.block_merge else 'APPROVED' if review_result.score >= 8 else 'REVIEW NEEDED'}") - print("="*80 + "\n") - + return { "status": "success", "pr_id": pr_data.pr_id, + "run_id": run_id, "platform": pr_data.platform.value, "ai_provider": self.ai_reviewer.last_provider_used, "ai_model": self.ai_reviewer.last_model_used, "score": review_result.score, "issues": review_result.total_issues, - "critical": review_result.critical_count + "critical": review_result.critical_count, } - + except Exception as e: - print(f"❌ ERROR during review process:") - print(f" {str(e)}") - print("="*80 + "\n") + out("❌ ERROR during review process:", step="error", level="error") + out(f" {str(e)}", step="error", level="error") + out("=" * 80 + "\n", step="console_banner") logger.exception("webhook_processing_failed", error=str(e)) - return {"status": "error", "message": str(e)} - + self._fail_live_run(run_id, error=str(e)) + return { + "status": "error", + "message": str(e), + "run_id": run_id, + } # Create server instance review_server = CodeReviewServer() @@ -328,6 +543,38 @@ async def lifespan(app: FastAPI): lifespan=lifespan ) +UI_DIST_DIR = Path(__file__).resolve().parent / "ui" / "dist" + +if UI_DIST_DIR.exists(): + assets_dir = UI_DIST_DIR / "assets" + if assets_dir.exists(): + app.mount("/ui/assets", StaticFiles(directory=assets_dir), name="ui-assets") + + +def _resolve_ui_file(path: str) -> Path: + target = (UI_DIST_DIR / path).resolve() + if UI_DIST_DIR.resolve() not in target.parents and target != UI_DIST_DIR.resolve(): + raise HTTPException(status_code=404, detail="Not found") + return target + + +@app.get("/ui") +async def ui_root(): + if not UI_DIST_DIR.exists(): + raise HTTPException(status_code=404, detail="UI build not found") + return FileResponse(UI_DIST_DIR / "index.html") + + +@app.get("/ui/{path:path}") +async def ui_path(path: str): + if not UI_DIST_DIR.exists(): + raise HTTPException(status_code=404, detail="UI build not found") + + requested = _resolve_ui_file(path) + if requested.is_file(): + return FileResponse(requested) + return FileResponse(UI_DIST_DIR / "index.html") + @app.get("/") async def root(): @@ -395,6 +642,135 @@ async def switch_template(body: dict): return {"status": "ok", "active": new_tmpl.name} +@app.get("/api/logs/config") +async def logs_config(): + cfg = review_server.ui_logs_config + return { + "poll_interval_seconds": cfg.poll_interval_seconds, + "max_events_per_poll": cfg.max_events_per_poll, + } + + +@app.get("/api/config") +async def get_config(): + return extract_editable_config(review_server.config) + + +@app.put("/api/config") +async def put_config(payload: dict[str, Any]): + current = extract_editable_config(review_server.config) + merged = deepcopy(current) + + payload_ui = payload.get("ui") if isinstance(payload, dict) else None + if isinstance(payload_ui, dict): + payload_logs = payload_ui.get("logs") + if isinstance(payload_logs, dict): + if "poll_interval_seconds" in payload_logs: + merged["ui"]["logs"]["poll_interval_seconds"] = int(payload_logs["poll_interval_seconds"]) + if "max_events_per_poll" in payload_logs: + merged["ui"]["logs"]["max_events_per_poll"] = int(payload_logs["max_events_per_poll"]) + + payload_review = payload.get("review") if isinstance(payload, dict) else None + if isinstance(payload_review, dict): + if "comment_strategy" in payload_review: + strategy = str(payload_review["comment_strategy"]) + if strategy not in ALLOWED_COMMENT_STRATEGIES: + raise HTTPException(status_code=400, detail="Invalid comment strategy") + merged["review"]["comment_strategy"] = strategy + + if "focus" in payload_review: + focus = payload_review["focus"] + if not isinstance(focus, list) or not all(isinstance(item, str) for item in focus): + raise HTTPException(status_code=400, detail="review.focus must be a list of strings") + merged["review"]["focus"] = [item.strip() for item in focus if item.strip()] + + payload_ai = payload.get("ai") if isinstance(payload, dict) else None + provider_changed = False + if isinstance(payload_ai, dict): + if "provider" in payload_ai: + provider = str(payload_ai["provider"]).lower() + if provider not in ALLOWED_AI_PROVIDERS: + raise HTTPException(status_code=400, detail="Invalid AI provider") + provider_changed = provider != merged["ai"]["provider"] + merged["ai"]["provider"] = provider + + if "model" in payload_ai: + model = str(payload_ai["model"]) + if model not in AI_PROVIDER_MODELS[merged["ai"]["provider"]]: + raise HTTPException(status_code=400, detail="Invalid AI model for provider") + merged["ai"]["model"] = model + + if provider_changed and merged["ai"]["model"] not in AI_PROVIDER_MODELS[merged["ai"]["provider"]]: + merged["ai"]["model"] = AI_PROVIDER_MODELS[merged["ai"]["provider"]][0] + + # Clamp UI logs config by existing parser rules. + normalized_ui_logs = parse_ui_logs_config({"ui": {"logs": merged["ui"]["logs"]}}) + merged["ui"]["logs"]["poll_interval_seconds"] = normalized_ui_logs.poll_interval_seconds + merged["ui"]["logs"]["max_events_per_poll"] = normalized_ui_logs.max_events_per_poll + + new_runtime_config = deepcopy(review_server.config) + new_runtime_config.setdefault("ui", {}).setdefault("logs", {}) + new_runtime_config["ui"]["logs"]["poll_interval_seconds"] = merged["ui"]["logs"]["poll_interval_seconds"] + new_runtime_config["ui"]["logs"]["max_events_per_poll"] = merged["ui"]["logs"]["max_events_per_poll"] + + new_runtime_config.setdefault("review", {}) + new_runtime_config["review"]["comment_strategy"] = merged["review"]["comment_strategy"] + new_runtime_config["review"]["focus"] = merged["review"]["focus"] + new_runtime_config.setdefault("ai", {}) + new_runtime_config["ai"]["provider"] = merged["ai"]["provider"] + new_runtime_config["ai"]["model"] = merged["ai"]["model"] + + # Keep multi-provider config in sync when present. + providers_cfg = new_runtime_config["ai"].get("providers") + if isinstance(providers_cfg, list): + matched = False + for item in providers_cfg: + if not isinstance(item, dict): + continue + if str(item.get("name", "")).lower() == merged["ai"]["provider"]: + item["model"] = merged["ai"]["model"] + matched = True + if not matched: + providers_cfg.append({"name": merged["ai"]["provider"], "model": merged["ai"]["model"]}) + new_runtime_config["ai"]["primary"] = merged["ai"]["provider"] + + editable = review_server.update_runtime_config(new_runtime_config) + try: + persist_editable_overrides(editable) + except OSError as e: + raise HTTPException(status_code=500, detail=f"Failed to persist config: {e}") from e + return editable + + +@app.get("/api/logs/active") +async def logs_active(): + runs = review_server.live_logs.list_active_runs() + return {"count": len(runs), "runs": runs} + + +@app.get("/api/logs/runs") +async def logs_runs(): + runs = review_server.live_logs.list_runs() + return {"count": len(runs), "runs": runs} + + +@app.get("/api/logs/active/{run_id}/events") +async def logs_active_events(run_id: str, cursor: int = 0, limit: int = 200): + try: + run, events, next_cursor = review_server.live_logs.get_events_since( + run_id, + cursor=cursor, + limit=limit, + ) + return { + "run": run, + "events": events, + "next_cursor": next_cursor, + } + except KeyError: + raise HTTPException(status_code=404, detail="Run not found") + + @app.post("/webhook") async def webhook_endpoint(request: Request): """ @@ -418,8 +794,11 @@ async def mcp_sse_endpoint(request: Request): """ MCP Server-Sent Events endpoint for MCP clients """ + if not MCP_AVAILABLE: + raise HTTPException(status_code=503, detail="MCP SDK is not installed in this environment") + # Create MCP server - mcp_server = Server("code-review-server") + mcp_server = MCPServer("code-review-server") # Register MCP tools @mcp_server.list_tools() @@ -447,4 +826,3 @@ async def call_tool(name: str, arguments: dict) -> list[TextContent]: port=server_config.get('port', 8000), log_level="info" ) - diff --git a/services/live_log_store.py b/services/live_log_store.py new file mode 100644 index 0000000..9ccf5e3 --- /dev/null +++ b/services/live_log_store.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +from datetime import datetime, timezone +from threading import Lock +from typing import Any +from uuid import uuid4 + + +def _utc_now_iso() -> str: + return datetime.now(timezone.utc).isoformat() + + +class LiveLogStore: + """In-memory buffer for active PR review runs and their events.""" + + def __init__(self, max_events_per_run: int = 200): + self.max_events_per_run = max(1, int(max_events_per_run)) + self._lock = Lock() + self._runs: dict[str, dict[str, Any]] = {} + self._events: dict[str, list[dict[str, Any]]] = {} + self._next_seq: dict[str, int] = {} + + def start_run( + self, + *, + platform: str, + pr_id: str, + title: str, + author: str, + source_branch: str | None = None, + target_branch: str | None = None, + repo: str | None = None, + ) -> str: + with self._lock: + run_id = str(uuid4()) + now = _utc_now_iso() + self._runs[run_id] = { + "run_id": run_id, + "platform": platform, + "pr_id": pr_id, + "title": title, + "author": author, + "source_branch": source_branch, + "target_branch": target_branch, + "repo": repo, + "started_at": now, + "updated_at": now, + "status": "active", + "score": None, + "issues": None, + "critical": None, + "error": None, + } + self._events[run_id] = [] + self._next_seq[run_id] = 1 + return run_id + + def append_event( + self, + run_id: str, + *, + step: str, + message: str, + level: str = "info", + meta: dict[str, Any] | None = None, + ) -> dict[str, Any]: + with self._lock: + run = self._runs.get(run_id) + if not run: + raise KeyError(f"run not found: {run_id}") + + seq = self._next_seq[run_id] + self._next_seq[run_id] = seq + 1 + + event = { + "seq": seq, + "ts": _utc_now_iso(), + "level": level, + "step": step, + "message": message, + "meta": meta or {}, + } + + events = self._events[run_id] + events.append(event) + if len(events) > self.max_events_per_run: + # Keep the most recent events only. + self._events[run_id] = events[-self.max_events_per_run :] + + run["updated_at"] = event["ts"] + return event + + def complete_run(self, run_id: str, *, score: int, issues: int, critical: int) -> None: + with self._lock: + run = self._runs.get(run_id) + if not run: + raise KeyError(f"run not found: {run_id}") + run["status"] = "completed" + run["score"] = score + run["issues"] = issues + run["critical"] = critical + run["updated_at"] = _utc_now_iso() + + def fail_run(self, run_id: str, *, error: str) -> None: + with self._lock: + run = self._runs.get(run_id) + if not run: + raise KeyError(f"run not found: {run_id}") + run["status"] = "error" + run["error"] = error + run["updated_at"] = _utc_now_iso() + + def list_active_runs(self) -> list[dict[str, Any]]: + with self._lock: + active = [r.copy() for r in self._runs.values() if r.get("status") == "active"] + active.sort(key=lambda x: x["updated_at"], reverse=True) + return active + + def list_runs(self) -> list[dict[str, Any]]: + with self._lock: + items = [r.copy() for r in self._runs.values()] + items.sort(key=lambda x: x["updated_at"], reverse=True) + return items + + def set_max_events_per_run(self, value: int) -> None: + with self._lock: + self.max_events_per_run = max(1, int(value)) + for run_id, events in self._events.items(): + if len(events) > self.max_events_per_run: + self._events[run_id] = events[-self.max_events_per_run :] + + def get_run(self, run_id: str) -> dict[str, Any] | None: + with self._lock: + run = self._runs.get(run_id) + return run.copy() if run else None + + def get_events_since( + self, + run_id: str, + *, + cursor: int = 0, + limit: int = 200, + ) -> tuple[dict[str, Any], list[dict[str, Any]], int]: + with self._lock: + run = self._runs.get(run_id) + if not run: + raise KeyError(f"run not found: {run_id}") + + effective_limit = max(1, min(int(limit), 1000)) + events = self._events.get(run_id, []) + filtered = [e for e in events if int(e["seq"]) > int(cursor)] + chunk = filtered[:effective_limit] + next_cursor = int(chunk[-1]["seq"]) if chunk else int(cursor) + return run.copy(), [e.copy() for e in chunk], next_cursor diff --git a/services/ui_logs_config.py b/services/ui_logs_config.py new file mode 100644 index 0000000..6a27705 --- /dev/null +++ b/services/ui_logs_config.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass + + +@dataclass(frozen=True) +class UILogsConfig: + poll_interval_seconds: int = 3 + max_events_per_poll: int = 200 + + +def parse_ui_logs_config(config: dict) -> UILogsConfig: + ui_cfg = config.get("ui") or {} + logs_cfg = ui_cfg.get("logs") or {} + + interval = int(logs_cfg.get("poll_interval_seconds", 3)) + max_events = int(logs_cfg.get("max_events_per_poll", 200)) + + return UILogsConfig( + poll_interval_seconds=max(1, min(interval, 30)), + max_events_per_poll=max(20, min(max_events, 500)), + ) diff --git a/tests/test_config_api.py b/tests/test_config_api.py new file mode 100644 index 0000000..f99e699 --- /dev/null +++ b/tests/test_config_api.py @@ -0,0 +1,91 @@ +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +import server + + +@pytest.fixture(autouse=True) +def use_temp_overrides_file(tmp_path: Path): + old = server.CONFIG_OVERRIDES_PATH + server.CONFIG_OVERRIDES_PATH = tmp_path / "config.overrides.yaml" + try: + yield + finally: + server.CONFIG_OVERRIDES_PATH = old + + +def test_get_config_returns_editable_fields(): + client = TestClient(server.app) + resp = client.get('/api/config') + assert resp.status_code == 200 + + data = resp.json() + assert 'ui' in data + assert 'review' in data + assert 'ai' in data + assert 'logs' in data['ui'] + assert 'poll_interval_seconds' in data['ui']['logs'] + assert 'max_events_per_poll' in data['ui']['logs'] + assert 'comment_strategy' in data['review'] + assert 'focus' in data['review'] + assert 'provider' in data['ai'] + assert 'model' in data['ai'] + + +def test_put_config_updates_runtime_values(): + client = TestClient(server.app) + original = client.get('/api/config').json() + + payload = { + 'ui': {'logs': {'poll_interval_seconds': 5, 'max_events_per_poll': 240}}, + 'review': {'comment_strategy': 'both', 'focus': ['security', 'bugs']}, + 'ai': {'provider': 'openai', 'model': 'gpt-4o'} + } + + try: + update_resp = client.put('/api/config', json=payload) + assert update_resp.status_code == 200 + updated = update_resp.json() + + assert updated['ui']['logs']['poll_interval_seconds'] == 5 + assert updated['ui']['logs']['max_events_per_poll'] == 240 + assert updated['review']['comment_strategy'] == 'both' + assert updated['review']['focus'] == ['security', 'bugs'] + assert updated['ai']['provider'] == 'openai' + assert updated['ai']['model'] == 'gpt-4o' + + logs_cfg = client.get('/api/logs/config').json() + assert logs_cfg['poll_interval_seconds'] == 5 + assert logs_cfg['max_events_per_poll'] == 240 + + persisted = server._read_yaml_file(server.CONFIG_OVERRIDES_PATH) + assert persisted['ui']['logs']['poll_interval_seconds'] == 5 + assert persisted['ui']['logs']['max_events_per_poll'] == 240 + assert persisted['review']['comment_strategy'] == 'both' + assert persisted['review']['focus'] == ['security', 'bugs'] + assert persisted['ai']['provider'] == 'openai' + assert persisted['ai']['model'] == 'gpt-4o' + finally: + client.put('/api/config', json=original) + + +def test_put_config_rejects_invalid_comment_strategy(): + client = TestClient(server.app) + bad_payload = { + 'review': {'comment_strategy': 'invalid-strategy'} + } + + resp = client.put('/api/config', json=bad_payload) + assert resp.status_code == 400 + + +def test_put_config_rejects_invalid_ai_model_for_provider(): + client = TestClient(server.app) + bad_payload = { + 'ai': {'provider': 'openai', 'model': 'llama-3.3-70b-versatile'} + } + + resp = client.put('/api/config', json=bad_payload) + assert resp.status_code == 400 diff --git a/tests/test_live_log_event_flow.py b/tests/test_live_log_event_flow.py new file mode 100644 index 0000000..95496f1 --- /dev/null +++ b/tests/test_live_log_event_flow.py @@ -0,0 +1,54 @@ +from types import SimpleNamespace + +from server import CodeReviewServer + + +def _mock_pr_data(): + return SimpleNamespace( + platform=SimpleNamespace(value="github"), + pr_id="42", + title="feat: auth middleware", + author="mehmet", + source_branch="feature/auth", + target_branch="main", + repo_full_name="acme/backend-api", + ) + + +def test_live_log_lifecycle_hooks_complete_run(): + review_server = CodeReviewServer() + pr_data = _mock_pr_data() + + run_id = review_server._start_live_run(pr_data) + assert run_id + + review_server._emit_live_event(run_id, step="step_1", message="Fetching diff") + review_server._emit_live_event(run_id, step="step_2", message="Analyzing diff") + review_server._complete_live_run(run_id, score=7, issues=5, critical=0) + + assert review_server.live_logs.list_active_runs() == [] + + summary, events, next_cursor = review_server.live_logs.get_events_since(run_id, cursor=0, limit=100) + assert summary["status"] == "completed" + assert summary["score"] == 7 + assert len(events) == 2 + assert events[0]["message"] == "Fetching diff" + assert events[1]["message"] == "Analyzing diff" + assert next_cursor == 2 + + +def test_live_log_lifecycle_hooks_fail_run(): + review_server = CodeReviewServer() + pr_data = _mock_pr_data() + + run_id = review_server._start_live_run(pr_data) + review_server._emit_live_event(run_id, step="step_1", message="Fetching diff") + review_server._fail_live_run(run_id, error="Failed to fetch diff") + + assert review_server.live_logs.list_active_runs() == [] + + summary, events, next_cursor = review_server.live_logs.get_events_since(run_id, cursor=0, limit=100) + assert summary["status"] == "error" + assert summary["error"] == "Failed to fetch diff" + assert len(events) == 1 + assert next_cursor == 1 diff --git a/tests/test_live_log_store.py b/tests/test_live_log_store.py new file mode 100644 index 0000000..c3f6a01 --- /dev/null +++ b/tests/test_live_log_store.py @@ -0,0 +1,75 @@ +import importlib.util +from pathlib import Path + + +def _load_module(): + module_path = Path(__file__).resolve().parents[1] / "services" / "live_log_store.py" + spec = importlib.util.spec_from_file_location("live_log_store", module_path) + if spec is None or spec.loader is None: + raise RuntimeError("Failed to load live_log_store module") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_start_run_and_append_event(): + LiveLogStore = _load_module().LiveLogStore + + store = LiveLogStore(max_events_per_run=10) + run_id = store.start_run( + platform="github", + pr_id="142", + title="feat: auth", + author="mehmet", + source_branch="feature/auth", + target_branch="main", + ) + store.append_event(run_id, step="step_1", message="Fetching diff") + + summary, events, next_cursor = store.get_events_since(run_id, cursor=0, limit=50) + assert summary["pr_id"] == "142" + assert summary["status"] == "active" + assert len(events) == 1 + assert events[0]["message"] == "Fetching diff" + assert next_cursor == 1 + + +def test_complete_run_is_not_listed_as_active(): + LiveLogStore = _load_module().LiveLogStore + + store = LiveLogStore(max_events_per_run=2) + run_id = store.start_run(platform="github", pr_id="1", title="t", author="u") + store.append_event(run_id, step="step_1", message="Start") + store.append_event(run_id, step="step_2", message="Analyze") + store.append_event(run_id, step="step_3", message="Review") + store.complete_run(run_id, score=7, issues=5, critical=0) + + active_runs = store.list_active_runs() + assert active_runs == [] + + summary, events, next_cursor = store.get_events_since(run_id, cursor=0, limit=50) + assert summary["status"] == "completed" + assert summary["score"] == 7 + assert len(events) == 2 + assert [e["message"] for e in events] == ["Analyze", "Review"] + assert next_cursor == 3 + + +def test_list_runs_returns_all_statuses(): + LiveLogStore = _load_module().LiveLogStore + + store = LiveLogStore(max_events_per_run=10) + active_id = store.start_run(platform="github", pr_id="11", title="active", author="u1") + completed_id = store.start_run(platform="github", pr_id="12", title="completed", author="u2") + error_id = store.start_run(platform="github", pr_id="13", title="error", author="u3") + + store.complete_run(completed_id, score=9, issues=1, critical=0) + store.fail_run(error_id, error="fetch failed") + + runs = store.list_runs() + run_ids = {run["run_id"] for run in runs} + assert run_ids == {active_id, completed_id, error_id} + statuses = {run["run_id"]: run["status"] for run in runs} + assert statuses[active_id] == "active" + assert statuses[completed_id] == "completed" + assert statuses[error_id] == "error" diff --git a/tests/test_logs_api.py b/tests/test_logs_api.py new file mode 100644 index 0000000..e048bed --- /dev/null +++ b/tests/test_logs_api.py @@ -0,0 +1,85 @@ +from fastapi.testclient import TestClient + +from server import app, review_server + + +def test_get_logs_config(): + client = TestClient(app) + resp = client.get("/api/logs/config") + assert resp.status_code == 200 + data = resp.json() + assert "poll_interval_seconds" in data + assert "max_events_per_poll" in data + assert data["poll_interval_seconds"] >= 1 + + +def test_get_active_runs_and_incremental_events(): + client = TestClient(app) + + run_id = review_server.live_logs.start_run( + platform="github", + pr_id="314", + title="feat: dashboard logs", + author="mehmet", + source_branch="feature/ui-logs", + target_branch="main", + repo="acme/backend-api", + ) + review_server.live_logs.append_event(run_id, step="step_1", message="Fetching diff") + + active_resp = client.get("/api/logs/active") + assert active_resp.status_code == 200 + active_data = active_resp.json() + assert active_data["count"] >= 1 + assert any(r["run_id"] == run_id for r in active_data["runs"]) + + events_resp = client.get( + f"/api/logs/active/{run_id}/events", + params={"cursor": 0, "limit": 50}, + ) + assert events_resp.status_code == 200 + events_data = events_resp.json() + assert events_data["run"]["run_id"] == run_id + assert len(events_data["events"]) == 1 + assert events_data["events"][0]["message"] == "Fetching diff" + assert events_data["next_cursor"] == 1 + + +def test_get_events_not_found_returns_404(): + client = TestClient(app) + resp = client.get("/api/logs/active/not-found/events") + assert resp.status_code == 404 + + +def test_get_all_runs_includes_completed_and_error(): + client = TestClient(app) + + active_id = review_server.live_logs.start_run( + platform="github", + pr_id="901", + title="feat: active", + author="alice", + ) + completed_id = review_server.live_logs.start_run( + platform="github", + pr_id="902", + title="feat: completed", + author="bob", + ) + error_id = review_server.live_logs.start_run( + platform="github", + pr_id="903", + title="feat: error", + author="charlie", + ) + review_server.live_logs.complete_run(completed_id, score=8, issues=2, critical=0) + review_server.live_logs.fail_run(error_id, error="failed to fetch diff") + + resp = client.get("/api/logs/runs") + assert resp.status_code == 200 + data = resp.json() + assert data["count"] >= 3 + by_id = {item["run_id"]: item for item in data["runs"]} + assert by_id[active_id]["status"] == "active" + assert by_id[completed_id]["status"] == "completed" + assert by_id[error_id]["status"] == "error" diff --git a/tests/test_ui_logs_config.py b/tests/test_ui_logs_config.py new file mode 100644 index 0000000..fe102a7 --- /dev/null +++ b/tests/test_ui_logs_config.py @@ -0,0 +1,35 @@ +import importlib.util +from pathlib import Path + + +def _load_module(): + module_path = Path(__file__).resolve().parents[1] / "services" / "ui_logs_config.py" + spec = importlib.util.spec_from_file_location("ui_logs_config", module_path) + if spec is None or spec.loader is None: + raise RuntimeError("Failed to load ui_logs_config module") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def test_parse_ui_logs_config_defaults_when_missing(): + parse_ui_logs_config = _load_module().parse_ui_logs_config + cfg = parse_ui_logs_config({}) + assert cfg.poll_interval_seconds == 3 + assert cfg.max_events_per_poll == 200 + + +def test_parse_ui_logs_config_clamps_values(): + parse_ui_logs_config = _load_module().parse_ui_logs_config + cfg = parse_ui_logs_config( + { + "ui": { + "logs": { + "poll_interval_seconds": 0, + "max_events_per_poll": 9999, + } + } + } + ) + assert cfg.poll_interval_seconds == 1 + assert cfg.max_events_per_poll == 500 diff --git a/tests/test_ui_routes.py b/tests/test_ui_routes.py new file mode 100644 index 0000000..e647d51 --- /dev/null +++ b/tests/test_ui_routes.py @@ -0,0 +1,20 @@ +from pathlib import Path + +from fastapi.testclient import TestClient + +from server import app + + +def test_ui_route_serves_index_when_dist_exists(): + client = TestClient(app) + + dist_index = Path('ui/dist/index.html') + if not dist_index.exists(): + # If UI is not built in this environment, skip strict assertion. + resp = client.get('/ui') + assert resp.status_code in (200, 404) + return + + resp = client.get('/ui') + assert resp.status_code == 200 + assert 'text/html' in resp.headers.get('content-type', '') diff --git a/tools/review_tools.py b/tools/review_tools.py index fb0669d..023ab4f 100644 --- a/tools/review_tools.py +++ b/tools/review_tools.py @@ -4,7 +4,18 @@ import json import structlog from typing import List -from mcp.types import Tool + +try: + from mcp.types import Tool +except ImportError: + from dataclasses import dataclass + from typing import Any + + @dataclass + class Tool: # type: ignore[override] + name: str + description: str + inputSchema: dict[str, Any] logger = structlog.get_logger() @@ -220,4 +231,3 @@ async def _security_scan(self, args: dict) -> str: } return json.dumps(result, indent=2) - diff --git a/ui/README.md b/ui/README.md new file mode 100644 index 0000000..bff4d6b --- /dev/null +++ b/ui/README.md @@ -0,0 +1,59 @@ +# UI README + +This folder contains the React UI for live logs and runtime config management. + +## Pages + +- `/ui/logs` -> PR runs dashboard (active/completed/error with status dots) +- `/ui/logs/:runId` -> grouped run event timeline +- `/ui/config` -> editable runtime config (polling, review settings, AI provider/model) + +## Stack + +- React 18 + TypeScript +- Vite +- React Router +- Vitest + Testing Library + +## Local Development + +```bash +cd ui +npm install +npm run dev +``` + +Vite dev server starts on the default Vite port (usually `5173`). + +## Build + +```bash +cd ui +npm run build +``` + +Build output is generated under `ui/dist`. + +## Tests + +```bash +cd ui +npm test -- --run +``` + +## Backend Integration + +UI consumes these backend endpoints: + +- `GET /api/logs/config` +- `GET /api/logs/runs` +- `GET /api/logs/active/{run_id}/events` +- `GET /api/config` +- `PUT /api/config` + +When running in Docker, FastAPI serves built UI under `/ui`. + +## Notes + +- `node_modules` and `dist` are ignored by git. +- Config changes made from `/ui/config` are persisted by backend into `config.overrides.yaml` (if writable/mounted). diff --git a/ui/index.html b/ui/index.html new file mode 100644 index 0000000..11700e8 --- /dev/null +++ b/ui/index.html @@ -0,0 +1,12 @@ + + + + + + MCP Live Logs + + +
+ + + diff --git a/ui/package-lock.json b/ui/package-lock.json new file mode 100644 index 0000000..97302cf --- /dev/null +++ b/ui/package-lock.json @@ -0,0 +1,2940 @@ +{ + "name": "mcp-live-logs-ui", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "mcp-live-logs-ui", + "version": "0.1.0", + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.1" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.7.0", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^18.3.24", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.7.0", + "jsdom": "^26.1.0", + "typescript": "^5.8.2", + "vite": "^5.4.19", + "vitest": "^2.1.9" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-3.2.0.tgz", + "integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^2.1.3", + "@csstools/css-color-parser": "^3.0.9", + "@csstools/css-parser-algorithms": "^3.0.4", + "@csstools/css-tokenizer": "^3.0.3", + "lru-cache": "^10.4.3" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", + "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-5.1.0.tgz", + "integrity": "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/css-calc": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-2.1.4.tgz", + "integrity": "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-3.1.0.tgz", + "integrity": "sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^5.1.0", + "@csstools/css-calc": "^2.1.4" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^3.0.5", + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz", + "integrity": "sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^3.0.4" + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz", + "integrity": "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.59.0.tgz", + "integrity": "sha512-upnNBkA6ZH2VKGcBj9Fyl9IGNPULcjXRlg0LLeaioQWueH30p6IXtJEbKAgvyv+mJaMxSm1l6xwDXYjpEMiLMg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.59.0.tgz", + "integrity": "sha512-hZ+Zxj3SySm4A/DylsDKZAeVg0mvi++0PYVceVyX7hemkw7OreKdCvW2oQ3T1FMZvCaQXqOTHb8qmBShoqk69Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.59.0.tgz", + "integrity": "sha512-W2Psnbh1J8ZJw0xKAd8zdNgF9HRLkdWwwdWqubSVk0pUuQkoHnv7rx4GiF9rT4t5DIZGAsConRE3AxCdJ4m8rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.59.0.tgz", + "integrity": "sha512-ZW2KkwlS4lwTv7ZVsYDiARfFCnSGhzYPdiOU4IM2fDbL+QGlyAbjgSFuqNRbSthybLbIJ915UtZBtmuLrQAT/w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.59.0.tgz", + "integrity": "sha512-EsKaJ5ytAu9jI3lonzn3BgG8iRBjV4LxZexygcQbpiU0wU0ATxhNVEpXKfUa0pS05gTcSDMKpn3Sx+QB9RlTTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.59.0.tgz", + "integrity": "sha512-d3DuZi2KzTMjImrxoHIAODUZYoUUMsuUiY4SRRcJy6NJoZ6iIqWnJu9IScV9jXysyGMVuW+KNzZvBLOcpdl3Vg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.59.0.tgz", + "integrity": "sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.59.0.tgz", + "integrity": "sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.59.0.tgz", + "integrity": "sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.59.0.tgz", + "integrity": "sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.59.0.tgz", + "integrity": "sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.59.0.tgz", + "integrity": "sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.59.0.tgz", + "integrity": "sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.59.0.tgz", + "integrity": "sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.59.0.tgz", + "integrity": "sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.59.0.tgz", + "integrity": "sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.59.0.tgz", + "integrity": "sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.59.0.tgz", + "integrity": "sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.59.0.tgz", + "integrity": "sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.59.0.tgz", + "integrity": "sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.59.0.tgz", + "integrity": "sha512-tt9KBJqaqp5i5HUZzoafHZX8b5Q2Fe7UjYERADll83O4fGqJ49O1FsL6LpdzVFQcpwvnyd0i+K/VSwu/o/nWlA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.59.0.tgz", + "integrity": "sha512-V5B6mG7OrGTwnxaNUzZTDTjDS7F75PO1ae6MJYdiMu60sq0CqN5CVeVsbhPxalupvTX8gXVSU9gq+Rx1/hvu6A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.59.0.tgz", + "integrity": "sha512-UKFMHPuM9R0iBegwzKF4y0C4J9u8C6MEJgFuXTBerMk7EJ92GFVFYBfOZaSGLu6COf7FxpQNqhNS4c4icUPqxA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.59.0.tgz", + "integrity": "sha512-laBkYlSS1n2L8fSo1thDNGrCTQMmxjYY5G0WFWjFFYZkKPjsMBsgJfGf4TLxXrF6RyhI60L8TMOjBMvXiTcxeA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.59.0.tgz", + "integrity": "sha512-2HRCml6OztYXyJXAvdDXPKcawukWY2GpR5/nxKp4iBgiO3wcoEGkAaqctIbZcNB6KlUQBIqt8VYkNSj2397EfA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/prop-types": { + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "18.3.28", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz", + "integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/prop-types": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", + "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^18.0.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", + "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001776", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001776.tgz", + "integrity": "sha512-sg01JDPzZ9jGshqKSckOQthXnYwOEP50jeVFhaSFbZcOy05TiuuaffDOfcwtCisJ9kNQuLBFibYywv2Bgm9osw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^3.2.0", + "rrweb-cssom": "^0.8.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-5.0.0.tgz", + "integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.307", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.307.tgz", + "integrity": "sha512-5z3uFKBWjiNR44nFcYdkcXjKMbg5KXNdciu7mhTPo9tB7NbqSNP2sSnGR+fqknZSCwKkBN+oxiiajWs4dT6ORg==", + "dev": true, + "license": "ISC" + }, + "node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-4.0.0.tgz", + "integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-encoding": "^3.1.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "26.1.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-26.1.0.tgz", + "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssstyle": "^4.2.1", + "data-urls": "^5.0.0", + "decimal.js": "^10.5.0", + "html-encoding-sniffer": "^4.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "nwsapi": "^2.2.16", + "parse5": "^7.2.1", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^5.1.1", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^14.1.1", + "ws": "^8.18.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.36", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.36.tgz", + "integrity": "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nwsapi": { + "version": "2.2.23", + "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.23.tgz", + "integrity": "sha512-7wfH4sLbt4M0gCDzGE6vzQBo0bfTKjU7Sfpqy/7gs1qBfYz2vEJH6vXcBKpO3+6Yu1telwd0t9HpyOoLEQQbIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.8", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", + "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/rollup": { + "version": "4.59.0", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", + "integrity": "sha512-2oMpl67a3zCH9H79LeMcbDhXW/UmWG/y2zuqnF2jQq5uq9TbM9TVyXvA4+t+ne2IIkBdrLpAaRQAvo7YI/Yyeg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.59.0", + "@rollup/rollup-android-arm64": "4.59.0", + "@rollup/rollup-darwin-arm64": "4.59.0", + "@rollup/rollup-darwin-x64": "4.59.0", + "@rollup/rollup-freebsd-arm64": "4.59.0", + "@rollup/rollup-freebsd-x64": "4.59.0", + "@rollup/rollup-linux-arm-gnueabihf": "4.59.0", + "@rollup/rollup-linux-arm-musleabihf": "4.59.0", + "@rollup/rollup-linux-arm64-gnu": "4.59.0", + "@rollup/rollup-linux-arm64-musl": "4.59.0", + "@rollup/rollup-linux-loong64-gnu": "4.59.0", + "@rollup/rollup-linux-loong64-musl": "4.59.0", + "@rollup/rollup-linux-ppc64-gnu": "4.59.0", + "@rollup/rollup-linux-ppc64-musl": "4.59.0", + "@rollup/rollup-linux-riscv64-gnu": "4.59.0", + "@rollup/rollup-linux-riscv64-musl": "4.59.0", + "@rollup/rollup-linux-s390x-gnu": "4.59.0", + "@rollup/rollup-linux-x64-gnu": "4.59.0", + "@rollup/rollup-linux-x64-musl": "4.59.0", + "@rollup/rollup-openbsd-x64": "4.59.0", + "@rollup/rollup-openharmony-arm64": "4.59.0", + "@rollup/rollup-win32-arm64-msvc": "4.59.0", + "@rollup/rollup-win32-ia32-msvc": "4.59.0", + "@rollup/rollup-win32-x64-gnu": "4.59.0", + "@rollup/rollup-win32-x64-msvc": "4.59.0", + "fsevents": "~2.3.2" + } + }, + "node_modules/rrweb-cssom": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz", + "integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-6.1.86.tgz", + "integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^6.1.86" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "6.1.86", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz", + "integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-5.1.2.tgz", + "integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^6.1.32" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz", + "integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-url": { + "version": "14.2.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz", + "integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^5.1.0", + "webidl-conversions": "^7.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/ws": { + "version": "8.19.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", + "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/ui/package.json b/ui/package.json new file mode 100644 index 0000000..1a30586 --- /dev/null +++ b/ui/package.json @@ -0,0 +1,29 @@ +{ + "name": "mcp-live-logs-ui", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "vitest" + }, + "dependencies": { + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.30.1" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.7.0", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^18.3.24", + "@types/react-dom": "^18.3.7", + "@vitejs/plugin-react": "^4.7.0", + "jsdom": "^26.1.0", + "typescript": "^5.8.2", + "vite": "^5.4.19", + "vitest": "^2.1.9" + } +} diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx new file mode 100644 index 0000000..69188d4 --- /dev/null +++ b/ui/src/App.test.tsx @@ -0,0 +1,22 @@ +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import App from './App' + +vi.mock('./routes/LogsDashboardPage', () => ({ + default: () =>

Active PR Runs

+})) + +vi.mock('./routes/LogDetailPage', () => ({ + default: () =>

Run Detail

+})) + +describe('App routes', () => { + it('shows Active PR Runs title', () => { + render( + + + + ) + expect(screen.getByText('Active PR Runs')).toBeInTheDocument() + }) +}) diff --git a/ui/src/App.tsx b/ui/src/App.tsx new file mode 100644 index 0000000..b0d714b --- /dev/null +++ b/ui/src/App.tsx @@ -0,0 +1,15 @@ +import { Navigate, Route, Routes } from 'react-router-dom' +import LogsDashboardPage from './routes/LogsDashboardPage' +import LogDetailPage from './routes/LogDetailPage' +import ConfigPage from './routes/ConfigPage' + +export default function App() { + return ( + + } /> + } /> + } /> + } /> + + ) +} diff --git a/ui/src/components/ActiveRunRow.tsx b/ui/src/components/ActiveRunRow.tsx new file mode 100644 index 0000000..0191541 --- /dev/null +++ b/ui/src/components/ActiveRunRow.tsx @@ -0,0 +1,39 @@ +import { Link } from 'react-router-dom' +import type { LiveRunSummary } from '../types/logs' + +interface ActiveRunRowProps { + run: LiveRunSummary +} + +export default function ActiveRunRow({ run }: ActiveRunRowProps) { + const dotClass = + run.status === 'active' + ? 'dot-active' + : run.status === 'error' + ? 'dot-error' + : 'dot-completed' + + return ( +
  • + +
    + + PR #{run.pr_id} + +

    {run.title}

    +
    +
    + {run.platform.toUpperCase()} + {run.author} + {run.status.toUpperCase()} + + {(run.source_branch || '-') + ' -> ' + (run.target_branch || '-')} + +
    +
  • + ) +} diff --git a/ui/src/components/TimelineEvent.tsx b/ui/src/components/TimelineEvent.tsx new file mode 100644 index 0000000..c68b37d --- /dev/null +++ b/ui/src/components/TimelineEvent.tsx @@ -0,0 +1,21 @@ +import type { LiveEvent } from '../types/logs' + +interface TimelineEventProps { + event: LiveEvent +} + +export default function TimelineEvent({ event }: TimelineEventProps) { + const timestamp = new Date(event.ts).toLocaleTimeString('tr-TR', { + hour12: false + }) + + return ( +
  • +
    + {event.step} + {timestamp} +
    +

    {event.message}

    +
  • + ) +} diff --git a/ui/src/main.tsx b/ui/src/main.tsx new file mode 100644 index 0000000..d35307f --- /dev/null +++ b/ui/src/main.tsx @@ -0,0 +1,13 @@ +import React from 'react' +import ReactDOM from 'react-dom/client' +import { BrowserRouter } from 'react-router-dom' +import App from './App' +import './styles.css' + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + + + +) diff --git a/ui/src/routes/ConfigPage.test.tsx b/ui/src/routes/ConfigPage.test.tsx new file mode 100644 index 0000000..c4d4f09 --- /dev/null +++ b/ui/src/routes/ConfigPage.test.tsx @@ -0,0 +1,93 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import App from '../App' + +function jsonResponse(data: unknown): Response { + return new Response(JSON.stringify(data), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) +} + +describe('ConfigPage', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('loads and updates editable config fields', async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url.endsWith('/api/config') && (!init || !init.method || init.method === 'GET')) { + return jsonResponse({ + ui: { logs: { poll_interval_seconds: 3, max_events_per_poll: 200 } }, + review: { comment_strategy: 'summary', focus: ['security', 'bugs'] }, + ai: { provider: 'openai', model: 'gpt-4o-mini' } + }) + } + + if (url.endsWith('/api/config') && init?.method === 'PUT') { + const body = JSON.parse(String(init.body)) + return jsonResponse(body) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) + + vi.stubGlobal('fetch', fetchMock) + + render( + + + } /> + + + ) + + await waitFor(() => { + expect(screen.getByRole('heading', { name: 'Config' })).toBeInTheDocument() + }) + + const intervalInput = screen.getByLabelText('Poll Interval (sec)') as HTMLInputElement + const maxEventsInput = screen.getByLabelText('Max Events Per Poll') as HTMLInputElement + const providerSelect = screen.getByLabelText('LLM Provider') as HTMLSelectElement + const modelSelect = screen.getByLabelText('LLM Model') as HTMLSelectElement + const strategySelect = screen.getByLabelText('Comment Strategy') as HTMLSelectElement + const focusInput = screen.getByLabelText('Focus Areas (comma separated)') as HTMLInputElement + + expect(intervalInput.value).toBe('3') + expect(maxEventsInput.value).toBe('200') + expect(providerSelect.value).toBe('openai') + expect(modelSelect.value).toBe('gpt-4o-mini') + expect(strategySelect.value).toBe('summary') + expect(focusInput.value).toBe('security, bugs') + + await userEvent.clear(intervalInput) + await userEvent.type(intervalInput, '5') + await userEvent.selectOptions(providerSelect, 'groq') + await userEvent.selectOptions(modelSelect, 'llama-3.1-70b-versatile') + await userEvent.selectOptions(strategySelect, 'both') + await userEvent.clear(focusInput) + await userEvent.type(focusInput, 'security, performance') + + await userEvent.click(screen.getByRole('button', { name: 'Save Config' })) + + await waitFor(() => { + expect(screen.getByText('Saved')).toBeInTheDocument() + }) + + const putCall = fetchMock.mock.calls.find((call) => { + const url = String(call[0]) + const init = call[1] as RequestInit | undefined + return url.endsWith('/api/config') && init?.method === 'PUT' + }) + + expect(putCall).toBeTruthy() + const sentBody = JSON.parse(String((putCall?.[1] as RequestInit).body)) + expect(sentBody.ui.logs.poll_interval_seconds).toBe(5) + expect(sentBody.ai.provider).toBe('groq') + expect(sentBody.ai.model).toBe('llama-3.1-70b-versatile') + expect(sentBody.review.comment_strategy).toBe('both') + expect(sentBody.review.focus).toEqual(['security', 'performance']) + }) +}) diff --git a/ui/src/routes/ConfigPage.tsx b/ui/src/routes/ConfigPage.tsx new file mode 100644 index 0000000..4990662 --- /dev/null +++ b/ui/src/routes/ConfigPage.tsx @@ -0,0 +1,266 @@ +import { useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import { getEditableConfig, updateEditableConfig } from '../lib/api' +import type { EditableConfig } from '../types/logs' + +const AI_MODEL_OPTIONS: Record = { + openai: ['gpt-4o-mini', 'gpt-4o', 'gpt-4-turbo-preview'], + anthropic: ['claude-3-5-sonnet-20241022'], + groq: ['llama-3.3-70b-versatile', 'llama-3.1-70b-versatile', 'mixtral-8x7b-32768'] +} + +const DEFAULT_CONFIG: EditableConfig = { + ui: { logs: { poll_interval_seconds: 3, max_events_per_poll: 200 } }, + review: { comment_strategy: 'summary', focus: ['security', 'bugs'] }, + ai: { provider: 'openai', model: 'gpt-4o-mini' } +} + +function normalizeConfig(input: EditableConfig): EditableConfig { + const provider = input.ai?.provider && input.ai.provider in AI_MODEL_OPTIONS + ? input.ai.provider + : DEFAULT_CONFIG.ai.provider + const model = AI_MODEL_OPTIONS[provider].includes(input.ai?.model) + ? input.ai.model + : AI_MODEL_OPTIONS[provider][0] + + return { + ui: { + logs: { + poll_interval_seconds: Number(input.ui?.logs?.poll_interval_seconds || DEFAULT_CONFIG.ui.logs.poll_interval_seconds), + max_events_per_poll: Number(input.ui?.logs?.max_events_per_poll || DEFAULT_CONFIG.ui.logs.max_events_per_poll) + } + }, + review: { + comment_strategy: input.review?.comment_strategy || DEFAULT_CONFIG.review.comment_strategy, + focus: Array.isArray(input.review?.focus) ? input.review.focus : DEFAULT_CONFIG.review.focus + }, + ai: { provider, model } + } +} + +export default function ConfigPage() { + const [config, setConfig] = useState(DEFAULT_CONFIG) + const [focusText, setFocusText] = useState('') + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const [toast, setToast] = useState<{ type: 'success' | 'error'; message: string } | null>(null) + + useEffect(() => { + let mounted = true + const load = async () => { + try { + const data = await getEditableConfig() + if (!mounted) { + return + } + const normalized = normalizeConfig(data) + setConfig(normalized) + setFocusText((normalized.review.focus || []).join(', ')) + setError(null) + } catch (err) { + if (mounted) { + setError(err instanceof Error ? err.message : 'Failed to load config') + } + } finally { + if (mounted) { + setLoading(false) + } + } + } + + void load() + return () => { + mounted = false + } + }, []) + + const parsedFocus = useMemo( + () => focusText.split(',').map((item) => item.trim()).filter(Boolean), + [focusText] + ) + + useEffect(() => { + if (!toast) { + return + } + const timer = window.setTimeout(() => { + setToast(null) + }, 2200) + return () => window.clearTimeout(timer) + }, [toast]) + + const onSave = async () => { + setSaving(true) + setError(null) + + try { + const payload: EditableConfig = { + ui: { + logs: { + poll_interval_seconds: Math.max(1, Number(config.ui.logs.poll_interval_seconds) || 1), + max_events_per_poll: Math.max(20, Number(config.ui.logs.max_events_per_poll) || 20) + } + }, + review: { + comment_strategy: config.review.comment_strategy, + focus: parsedFocus + }, + ai: { + provider: config.ai.provider, + model: config.ai.model + }, + } + + const updated = await updateEditableConfig(payload) + setConfig(updated) + setFocusText((updated.review.focus || []).join(', ')) + setToast({ type: 'success', message: 'Saved' }) + } catch (err) { + const msg = err instanceof Error ? err.message : 'Failed to save config' + setError(msg) + setToast({ type: 'error', message: msg }) + } finally { + setSaving(false) + } + } + + return ( +
    +
    +

    Config

    +

    + Back to dashboard +

    + + {loading ?

    Loading config...

    : null} + {error ?

    {error}

    : null} + {toast ? ( +
    + {toast.message} +
    + ) : null} + +
    + + + + + + + + + + + +
    + + +
    +
    + ) +} diff --git a/ui/src/routes/LogDetailPage.test.tsx b/ui/src/routes/LogDetailPage.test.tsx new file mode 100644 index 0000000..cd68a44 --- /dev/null +++ b/ui/src/routes/LogDetailPage.test.tsx @@ -0,0 +1,96 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import LogDetailPage from './LogDetailPage' + +function jsonResponse(data: unknown): Response { + return new Response(JSON.stringify(data), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) +} + +describe('LogDetailPage', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('groups header and step events while appending with cursor', async () => { + let eventsCallCount = 0 + + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/api/logs/config')) { + return jsonResponse({ poll_interval_seconds: 1, max_events_per_poll: 200 }) + } + + if (url.includes('/api/logs/active/run-1/events')) { + eventsCallCount += 1 + if (eventsCallCount === 1) { + return jsonResponse({ + run: { + run_id: 'run-1', + status: 'active', + pr_id: '5', + title: 'PR #5 webhook test', + author: 'gocenalper' + }, + events: [ + { seq: 1, ts: '2026-03-04T11:51:40.427495+00:00', level: 'info', step: 'console_header', message: 'πŸ“¦ Platform: GITHUB', meta: {} }, + { seq: 2, ts: '2026-03-04T11:51:40.427613+00:00', level: 'info', step: 'console_header', message: 'πŸ”— PR #5: PR #5 webhook test', meta: {} }, + { seq: 3, ts: '2026-03-04T11:51:40.427700+00:00', level: 'info', step: 'console_header', message: '--------------------------------------------------------------------------------', meta: {} }, + { seq: 4, ts: '2026-03-04T11:51:40.427701+00:00', level: 'info', step: 'step_1', message: 'πŸ“₯ Step 1/5: Fetching diff from platform...', meta: {} }, + { seq: 5, ts: '2026-03-04T11:51:40.427702+00:00', level: 'info', step: 'step_1', message: 'βœ… Diff fetched successfully (25876 bytes)', meta: {} } + ], + next_cursor: 5 + }) + } + + return jsonResponse({ + run: { + run_id: 'run-1', + status: 'active', + pr_id: '5', + title: 'PR #5 webhook test', + author: 'gocenalper' + }, + events: [ + { seq: 6, ts: '2026-03-04T11:51:41.427613+00:00', level: 'info', step: 'step_2', message: 'πŸ” Step 2/5: Analyzing diff...', meta: {} }, + { seq: 7, ts: '2026-03-04T11:51:41.427700+00:00', level: 'info', step: 'step_2_file', message: 'πŸ“„ src/main.ts', meta: {} } + ], + next_cursor: 7 + }) + } + + throw new Error(`Unexpected fetch URL: ${url}`) + }) + + vi.stubGlobal('fetch', fetchMock) + + render( + + + } /> + + + ) + + await waitFor(() => { + expect(screen.getByText('PR Header')).toBeInTheDocument() + expect(screen.getByText('Step 1')).toBeInTheDocument() + expect(screen.getByText('πŸ“¦ Platform: GITHUB')).toBeInTheDocument() + }) + + await waitFor( + () => { + expect(screen.getByText('Step 2')).toBeInTheDocument() + expect(screen.getByText('πŸ“„ src/main.ts')).toBeInTheDocument() + }, + { timeout: 2500 } + ) + + expect(screen.queryByText('--------------------------------------------------------------------------------')).not.toBeInTheDocument() + expect(screen.getByTestId('group-card-header')).toBeInTheDocument() + expect(screen.getByTestId('group-card-step_1')).toBeInTheDocument() + expect(screen.getByTestId('group-card-step_2')).toBeInTheDocument() + }) +}) diff --git a/ui/src/routes/LogDetailPage.tsx b/ui/src/routes/LogDetailPage.tsx new file mode 100644 index 0000000..21ac0c5 --- /dev/null +++ b/ui/src/routes/LogDetailPage.tsx @@ -0,0 +1,251 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Link, useParams } from 'react-router-dom' +import { getLogsConfig, getRunEvents } from '../lib/api' +import { usePolling } from '../lib/usePolling' +import type { LiveEvent, LiveRunSummary } from '../types/logs' + +const DEFAULT_POLL_MS = 3000 +const DEFAULT_LIMIT = 200 + +type GroupStatus = 'neutral' | 'running' | 'success' | 'error' +type GroupKind = 'header' | 'step' | 'result' | 'misc' + +interface EventGroup { + id: string + kind: GroupKind + title: string + ts: string + status: GroupStatus + events: LiveEvent[] +} + +const SEPARATOR_RE = /^[-=]{20,}$/ + +function formatTime(ts: string): string { + return new Date(ts).toLocaleTimeString('tr-TR', { hour12: false }) +} + +function normalizeStep(step: string): string { + const match = step.match(/^step_(\d+)/) + if (!match) { + return step + } + return `step_${match[1]}` +} + +function stepTitle(step: string): string { + const match = step.match(/^step_(\d+)$/) + if (!match) { + return step + } + return `Step ${match[1]}` +} + +function inferStatus(event: LiveEvent, current: GroupStatus): GroupStatus { + if (current === 'error') { + return current + } + if (event.level === 'error' || event.message.includes('❌')) { + return 'error' + } + if (event.message.includes('βœ…')) { + return 'success' + } + if (current === 'success') { + return current + } + return 'running' +} + +function shouldSkip(event: LiveEvent): boolean { + if (event.step === 'console_banner') { + return true + } + const msg = event.message.trim() + if (!msg || SEPARATOR_RE.test(msg)) { + return true + } + return false +} + +function groupEvents(events: LiveEvent[]): EventGroup[] { + const groups: EventGroup[] = [] + const byId = new Map() + + const getOrCreateGroup = ( + id: string, + kind: GroupKind, + title: string, + ts: string, + initialStatus: GroupStatus + ): EventGroup => { + const existing = byId.get(id) + if (existing) { + return existing + } + const created: EventGroup = { + id, + kind, + title, + ts, + status: initialStatus, + events: [] + } + byId.set(id, created) + groups.push(created) + return created + } + + for (const event of events) { + if (shouldSkip(event)) { + continue + } + + if (event.step === 'console_header') { + const group = getOrCreateGroup('header', 'header', 'PR Header', event.ts, 'neutral') + group.events.push(event) + group.ts = event.ts + continue + } + + if (event.step === 'summary' || event.step === 'error') { + const group = getOrCreateGroup('result', 'result', 'Result', event.ts, 'running') + group.events.push(event) + group.ts = event.ts + group.status = inferStatus(event, group.status) + continue + } + + if (event.step.startsWith('step_')) { + const normalized = normalizeStep(event.step) + const group = getOrCreateGroup( + normalized, + 'step', + stepTitle(normalized), + event.ts, + 'running' + ) + group.events.push(event) + group.ts = event.ts + group.status = inferStatus(event, group.status) + continue + } + + const group = getOrCreateGroup(`misc-${event.seq}`, 'misc', event.step, event.ts, 'running') + group.events.push(event) + group.ts = event.ts + group.status = inferStatus(event, group.status) + } + + return groups +} + +export default function LogDetailPage() { + const { runId } = useParams() + const [run, setRun] = useState(null) + const [events, setEvents] = useState([]) + const [error, setError] = useState(null) + const [pollMs, setPollMs] = useState(DEFAULT_POLL_MS) + const [limit, setLimit] = useState(DEFAULT_LIMIT) + const cursorRef = useRef(0) + const groupedEvents = useMemo(() => groupEvents(events), [events]) + + useEffect(() => { + let mounted = true + const loadConfig = async () => { + try { + const cfg = await getLogsConfig() + if (!mounted) { + return + } + setPollMs(Math.max(1000, cfg.poll_interval_seconds * 1000)) + setLimit(Math.max(20, cfg.max_events_per_poll)) + } catch { + if (mounted) { + setPollMs(DEFAULT_POLL_MS) + setLimit(DEFAULT_LIMIT) + } + } + } + void loadConfig() + return () => { + mounted = false + } + }, []) + + const loadEvents = useCallback(async () => { + if (!runId) { + return + } + + try { + const data = await getRunEvents(runId, cursorRef.current, limit) + setRun(data.run) + if (data.events.length > 0) { + setEvents((prev) => { + const seen = new Set(prev.map((item) => item.seq)) + const fresh = data.events.filter((item) => !seen.has(item.seq)) + return fresh.length > 0 ? [...prev, ...fresh] : prev + }) + } + cursorRef.current = data.next_cursor + setError(null) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load events') + } + }, [limit, runId]) + + usePolling(loadEvents, pollMs, Boolean(runId)) + + return ( +
    +
    +

    Run Detail

    +

    + Back to dashboard +

    +

    + Open config +

    +

    Run ID: {runId}

    + + {run ? ( +
    + + PR #{run.pr_id} - {run.title} + +

    + Status: {run.status} + {run.score != null ? ` | Score: ${run.score}/10` : ''} + {run.issues != null ? ` | Issues: ${run.issues}` : ''} +

    +
    + ) : null} + + {error ?

    {error}

    : null} + +
      + {groupedEvents.map((group) => ( +
    • +
      + {group.title} + {formatTime(group.ts)} +
      +
        + {group.events.map((event) => ( +
      • {event.message}
      • + ))} +
      +
    • + ))} +
    + + {events.length === 0 && !error ?

    Waiting for events...

    : null} +
    +
    + ) +} diff --git a/ui/src/routes/LogsDashboardPage.test.tsx b/ui/src/routes/LogsDashboardPage.test.tsx new file mode 100644 index 0000000..e31b0fa --- /dev/null +++ b/ui/src/routes/LogsDashboardPage.test.tsx @@ -0,0 +1,84 @@ +import { render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import LogsDashboardPage from './LogsDashboardPage' + +function jsonResponse(data: unknown): Response { + return new Response(JSON.stringify(data), { + status: 200, + headers: { 'Content-Type': 'application/json' } + }) +} + +describe('LogsDashboardPage', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('renders active run row and pulsing green indicator', async () => { + const fetchMock = vi.fn(async (input: RequestInfo | URL) => { + const url = String(input) + if (url.endsWith('/api/logs/config')) { + return jsonResponse({ poll_interval_seconds: 3, max_events_per_poll: 200 }) + } + if (url.endsWith('/api/logs/runs')) { + return jsonResponse({ + count: 3, + runs: [ + { + run_id: 'run-1', + platform: 'github', + pr_id: '142', + title: 'feat: auth middleware', + author: 'mehmet', + source_branch: 'feature/auth', + target_branch: 'main', + status: 'active', + updated_at: '2026-03-04T11:51:40.427466+00:00' + }, + { + run_id: 'run-2', + platform: 'github', + pr_id: '143', + title: 'feat: cache', + author: 'ali', + source_branch: 'feature/cache', + target_branch: 'main', + status: 'error', + updated_at: '2026-03-04T11:51:39.427466+00:00' + }, + { + run_id: 'run-3', + platform: 'github', + pr_id: '144', + title: 'feat: metrics', + author: 'ayse', + source_branch: 'feature/metrics', + target_branch: 'main', + status: 'completed', + updated_at: '2026-03-04T11:51:38.427466+00:00' + } + ] + }) + } + throw new Error(`Unexpected fetch URL: ${url}`) + }) + + vi.stubGlobal('fetch', fetchMock) + + render( + + + + ) + + await waitFor(() => { + expect(screen.getByText('PR #142')).toBeInTheDocument() + }) + + expect(screen.getByText('feat: auth middleware')).toBeInTheDocument() + expect(screen.getByText('mehmet')).toBeInTheDocument() + expect(screen.getByTestId('status-dot-run-1')).toHaveClass('dot-active') + expect(screen.getByTestId('status-dot-run-2')).toHaveClass('dot-error') + expect(screen.getByTestId('status-dot-run-3')).toHaveClass('dot-completed') + }) +}) diff --git a/ui/src/routes/LogsDashboardPage.tsx b/ui/src/routes/LogsDashboardPage.tsx new file mode 100644 index 0000000..96bbc05 --- /dev/null +++ b/ui/src/routes/LogsDashboardPage.tsx @@ -0,0 +1,76 @@ +import { useCallback, useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import ActiveRunRow from '../components/ActiveRunRow' +import { getLogsConfig, getRuns } from '../lib/api' +import { usePolling } from '../lib/usePolling' +import type { LiveRunSummary } from '../types/logs' + +const DEFAULT_POLL_MS = 3000 + +export default function LogsDashboardPage() { + const [runs, setRuns] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + const [pollMs, setPollMs] = useState(DEFAULT_POLL_MS) + + useEffect(() => { + let mounted = true + const loadConfig = async () => { + try { + const cfg = await getLogsConfig() + if (mounted) { + setPollMs(Math.max(1000, cfg.poll_interval_seconds * 1000)) + } + } catch { + if (mounted) { + setPollMs(DEFAULT_POLL_MS) + } + } + } + + void loadConfig() + return () => { + mounted = false + } + }, []) + + const loadRuns = useCallback(async () => { + try { + const data = await getRuns() + setRuns(data.runs) + setError(null) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load runs') + } finally { + setLoading(false) + } + }, []) + + usePolling(loadRuns, pollMs, true) + + return ( +
    +
    +

    PR Runs

    +

    Polling every {Math.round(pollMs / 1000)}s

    +

    + Open config +

    + + {error ?

    {error}

    : null} + + {loading ?

    Loading runs...

    : null} + + {!loading && runs.length === 0 ?

    No runs right now.

    : null} + + {runs.length > 0 ? ( +
      + {runs.map((run) => ( + + ))} +
    + ) : null} +
    +
    + ) +} diff --git a/ui/src/styles.css b/ui/src/styles.css new file mode 100644 index 0000000..0b8cc6c --- /dev/null +++ b/ui/src/styles.css @@ -0,0 +1,256 @@ +:root { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + color: #0f172a; + background: radial-gradient(circle at top, #e0f2fe 0%, #f8fafc 40%, #f8fafc 100%); + --card-bg: #ffffff; + --card-border: #cbd5e1; + --muted: #475569; + --danger: #b91c1c; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + min-height: 100vh; +} + +.page { + max-width: 1100px; + margin: 0 auto; + padding: 32px 20px; +} + +.panel { + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 16px; + padding: 24px; + box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08); +} + +h1 { + margin-top: 0; +} + +a { + color: #0c4a6e; +} + +.error-text { + color: var(--danger); +} + +.success-text { + color: #166534; +} + +.toast { + position: fixed; + top: 18px; + right: 18px; + z-index: 1000; + border-radius: 10px; + padding: 10px 14px; + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.18); + color: #fff; + font-size: 14px; + font-weight: 600; +} + +.toast-success { + background: #166534; +} + +.toast-error { + background: #b91c1c; +} + +.run-list, +.timeline { + list-style: none; + margin: 16px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} + +.run-row, +.timeline-event { + border: 1px solid var(--card-border); + border-radius: 12px; + padding: 12px; + background: #f8fafc; +} + +.run-row { + display: grid; + gap: 12px; + grid-template-columns: 16px 1fr auto; + align-items: center; +} + +.run-main { + min-width: 0; +} + +.run-link { + font-weight: 700; +} + +.run-title { + margin: 4px 0 0; + color: var(--muted); +} + +.run-meta { + display: flex; + gap: 12px; + font-size: 13px; + color: var(--muted); +} + +.dot-active, +.dot-error, +.dot-completed { + width: 10px; + height: 10px; + border-radius: 50%; + display: inline-block; +} + +.dot-active { + background: #16a34a; + animation: pulse 1.3s infinite; +} + +.dot-error { + background: #dc2626; +} + +.dot-completed { + background: #2563eb; +} + +@keyframes pulse { + 0% { + opacity: 0.3; + transform: scale(0.95); + } + 60% { + opacity: 1; + transform: scale(1); + } + 100% { + opacity: 0.4; + transform: scale(0.95); + } +} + +.timeline-head { + display: flex; + justify-content: space-between; + gap: 12px; + font-size: 13px; + color: var(--muted); +} + +.timeline-event p { + margin: 8px 0 0; +} + +.level-error { + border-color: #fecaca; + background: #fef2f2; +} + +.group-lines { + list-style: none; + margin: 8px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 6px; +} + +.group-lines li { + color: #0f172a; +} + +.group-card.status-running { + border-color: #bfdbfe; + background: #eff6ff; +} + +.group-card.status-success { + border-color: #bbf7d0; + background: #f0fdf4; +} + +.group-card.status-error { + border-color: #fecaca; + background: #fef2f2; +} + +.run-summary { + margin: 12px 0; + border: 1px solid var(--card-border); + border-radius: 10px; + padding: 10px; + background: #ffffff; +} + +.config-grid { + display: grid; + grid-template-columns: repeat(2, minmax(220px, 1fr)); + gap: 12px; + margin: 14px 0; +} + +.config-grid label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 14px; + color: var(--muted); +} + +.config-grid input, +.config-grid select { + border: 1px solid var(--card-border); + border-radius: 8px; + padding: 8px 10px; + font-size: 14px; +} + +button { + border: 1px solid #0c4a6e; + background: #0c4a6e; + color: #fff; + border-radius: 8px; + padding: 8px 14px; + cursor: pointer; +} + +button:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +@media (max-width: 780px) { + .run-row { + grid-template-columns: 16px 1fr; + } + + .run-meta { + grid-column: 1 / -1; + flex-wrap: wrap; + } + + .config-grid { + grid-template-columns: 1fr; + } +} diff --git a/ui/src/test/setup.ts b/ui/src/test/setup.ts new file mode 100644 index 0000000..a9d0dd3 --- /dev/null +++ b/ui/src/test/setup.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom/vitest' diff --git a/ui/src/types/logs.ts b/ui/src/types/logs.ts new file mode 100644 index 0000000..417655c --- /dev/null +++ b/ui/src/types/logs.ts @@ -0,0 +1,61 @@ +export type RunStatus = 'active' | 'completed' | 'error' + +export interface LiveRunSummary { + run_id: string + platform: string + pr_id: string + title: string + author: string + source_branch?: string | null + target_branch?: string | null + repo?: string | null + started_at?: string + updated_at?: string + status: RunStatus + score?: number | null + issues?: number | null + critical?: number | null + error?: string | null +} + +export interface LiveEvent { + seq: number + ts: string + level: string + step: string + message: string + meta: Record +} + +export interface LogsConfig { + poll_interval_seconds: number + max_events_per_poll: number +} + +export interface ActiveRunsResponse { + count: number + runs: LiveRunSummary[] +} + +export interface RunEventsResponse { + run: LiveRunSummary + events: LiveEvent[] + next_cursor: number +} + +export interface EditableConfig { + ui: { + logs: { + poll_interval_seconds: number + max_events_per_poll: number + } + } + review: { + comment_strategy: 'summary' | 'inline' | 'both' + focus: string[] + } + ai: { + provider: 'openai' | 'anthropic' | 'groq' + model: string + } +} diff --git a/ui/src/vite-env.d.ts b/ui/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/ui/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/ui/tsconfig.json b/ui/tsconfig.json new file mode 100644 index 0000000..8602c7d --- /dev/null +++ b/ui/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "Bundler", + "resolveJsonModule": true, + "allowImportingTsExtensions": false, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "types": ["vite/client", "vitest/globals"] + }, + "include": ["src"] +} diff --git a/ui/vite.config.ts b/ui/vite.config.ts new file mode 100644 index 0000000..25e517a --- /dev/null +++ b/ui/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +export default defineConfig({ + base: '/ui/', + plugins: [react()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: './src/test/setup.ts' + } +})