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 (
+
+
+
+ )
+}
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 (
+
+
+
+ )
+}
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'
+ }
+})