diff --git a/.github/workflows/run_tests.yml b/.github/workflows/run_tests.yml index 5e7bb19..6b3b087 100644 --- a/.github/workflows/run_tests.yml +++ b/.github/workflows/run_tests.yml @@ -62,9 +62,12 @@ jobs: with: node-version: 22 cache: npm + cache-dependency-path: frontend/package-lock.json - name: Install dependencies run: npm ci + working-directory: frontend - name: Lint, type check, and test run: npm run check + working-directory: frontend diff --git a/.gitignore b/.gitignore index 447eb83..0716996 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ settings.json modaq_upload_cache.db # Log files -logs/ +/logs/ # Python __pycache__/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 924d336..24add70 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -9,6 +9,7 @@ repos: - id: check-merge-conflict - id: check-yaml - id: check-json + exclude: ^frontend/tsconfig\. - id: no-commit-to-branch args: [--branch, main] diff --git a/app/__init__.py b/app/__init__.py index 7c69fc9..d418c3a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,11 +1,56 @@ """Flask application factory for MCAP S3 Uploader.""" +import atexit import os +import threading from flask import Flask from app.config import get_package_version, get_settings +# Background cleanup thread control +_cleanup_thread: threading.Thread | None = None +_cleanup_stop_event = threading.Event() + + +def _sse_cleanup_worker() -> None: + """Background worker that periodically cleans up stale SSE queues.""" + from app.routes.upload import _cleanup_old_sse_queues + + while not _cleanup_stop_event.wait(timeout=300): # Check every 5 minutes + try: + removed = _cleanup_old_sse_queues() + if removed > 0: + from app.services.log_service import get_log_service + + log = get_log_service() + log.info( + "sse", + "sse_cleanup", + f"Cleaned up {removed} stale SSE queues", + {"queues_removed": removed}, + ) + except Exception: + # Don't crash the cleanup thread on errors + pass + + +def _start_sse_cleanup() -> None: + """Start the background SSE cleanup thread.""" + global _cleanup_thread + if _cleanup_thread is None: + _cleanup_thread = threading.Thread( + target=_sse_cleanup_worker, daemon=True, name="SSECleanup" + ) + _cleanup_thread.start() + + +def _stop_sse_cleanup() -> None: + """Stop the background SSE cleanup thread.""" + _cleanup_stop_event.set() + if _cleanup_thread: + _cleanup_thread.join(timeout=2.0) + def create_app() -> Flask: """Create and configure the Flask application.""" @@ -25,6 +70,7 @@ def inject_display_name() -> dict[str, str]: return {"display_name": settings.display_name} # Register blueprints + from app.routes.delete import delete_bp from app.routes.files import files_bp from app.routes.logs import logs_bp from app.routes.main import main_bp @@ -36,6 +82,13 @@ def inject_display_name() -> dict[str, str]: app.register_blueprint(files_bp, url_prefix="/api/files") app.register_blueprint(settings_bp, url_prefix="/api/settings") app.register_blueprint(logs_bp, url_prefix="/api/logs") + app.register_blueprint(delete_bp, url_prefix="/api/delete") + + # Start background SSE cleanup thread + _start_sse_cleanup() + + # Register cleanup on shutdown + atexit.register(_stop_sse_cleanup) # Log application startup from app.services.log_service import get_log_service diff --git a/app/config.py b/app/config.py index 432314c..e79d22e 100644 --- a/app/config.py +++ b/app/config.py @@ -58,6 +58,7 @@ class Settings: _instance: "Settings | None" = None _settings: dict[str, Any] + _provenance: dict[str, dict[str, str]] def __new__(cls) -> "Settings": """Singleton pattern to ensure only one settings instance exists.""" @@ -83,34 +84,58 @@ def _load_settings(self) -> None: "default_upload_folder": "", "display_name": "MODAQ Uploader", "log_directory": "logs", + "batch_processing": { + "enabled": True, + "batch_size": 100, + "auto_tune_workers": True, + "max_workers": 4, + "target_cpu_percent": 70.0, + "skip_mcap_validation": False, + "use_database_for_large_jobs": True, + "large_job_threshold": 1000, + }, } + # Track the source of each setting value as it is applied layer by layer. + provenance: dict[str, dict[str, str]] = {k: {"source": "builtin"} for k in defaults} + # Load from settings.default.json if it exists if SETTINGS_DEFAULT_FILE.exists(): with open(SETTINGS_DEFAULT_FILE, encoding="utf-8") as f: - defaults.update(json.load(f)) + default_data = json.load(f) + defaults.update(default_data) + for k in default_data: + provenance[k] = {"source": "default_file", "path": str(SETTINGS_DEFAULT_FILE)} # Load from settings.json if it exists if SETTINGS_FILE.exists(): with open(SETTINGS_FILE, encoding="utf-8") as f: - defaults.update(json.load(f)) + user_data = json.load(f) + defaults.update(user_data) + for k in user_data: + provenance[k] = {"source": "settings_file", "path": str(SETTINGS_FILE)} # Override with environment variables (highest priority) - env_overrides = { - "aws_profile": os.environ.get(ENV_AWS_PROFILE), - "aws_region": os.environ.get(ENV_AWS_REGION), - "s3_bucket": os.environ.get(ENV_S3_BUCKET), - "default_upload_folder": os.environ.get(ENV_DEFAULT_UPLOAD_FOLDER), - "display_name": os.environ.get(ENV_DISPLAY_NAME), - "log_directory": os.environ.get(ENV_LOG_DIRECTORY), + env_overrides: dict[str, tuple[str | None, str]] = { + "aws_profile": (os.environ.get(ENV_AWS_PROFILE), ENV_AWS_PROFILE), + "aws_region": (os.environ.get(ENV_AWS_REGION), ENV_AWS_REGION), + "s3_bucket": (os.environ.get(ENV_S3_BUCKET), ENV_S3_BUCKET), + "default_upload_folder": ( + os.environ.get(ENV_DEFAULT_UPLOAD_FOLDER), + ENV_DEFAULT_UPLOAD_FOLDER, + ), + "display_name": (os.environ.get(ENV_DISPLAY_NAME), ENV_DISPLAY_NAME), + "log_directory": (os.environ.get(ENV_LOG_DIRECTORY), ENV_LOG_DIRECTORY), } # Only apply non-None environment values - for key, value in env_overrides.items(): + for key, (value, env_var) in env_overrides.items(): if value is not None: defaults[key] = value + provenance[key] = {"source": "env", "env_var": env_var} self._settings = defaults + self._provenance = provenance # Save settings.json if it doesn't exist if not SETTINGS_FILE.exists(): @@ -139,6 +164,10 @@ def all(self) -> dict[str, Any]: """Get all settings as a dictionary.""" return self._settings.copy() + def to_response(self) -> dict[str, Any]: + """Return settings with value_sources provenance for API responses.""" + return {**self._settings, "value_sources": self._provenance} + def reload(self) -> None: """Reload settings from file.""" self._load_settings() @@ -177,6 +206,15 @@ def log_directory(self) -> Path: path = BASE_DIR / path return path + @property + def batch_processing(self) -> dict[str, Any]: + """Get batch processing configuration.""" + return dict(self._settings.get("batch_processing", {})) + + def get_batch_config(self) -> dict[str, Any]: + """Get batch processing configuration (alias for batch_processing property).""" + return self.batch_processing + def get_settings() -> Settings: """Get the singleton Settings instance.""" @@ -241,11 +279,19 @@ def update_application(self) -> dict[str, Any]: "git_pull": {"success": False, "output": ""}, "pip_install": {"success": False, "output": ""}, "modaq_toolkit": {"success": False, "output": ""}, + "npm_install": {"success": False, "output": ""}, + "frontend_build": {"success": False, "output": ""}, } - steps: list[tuple[str, list[str]]] = [ - ("git_pull", ["git", "pull"]), - ("pip_install", [sys.executable, "-m", "pip", "install", "-r", "requirements.txt"]), + frontend_dir = str(self.base_dir / "frontend") + + steps: list[tuple[str, list[str], str | None]] = [ + ("git_pull", ["git", "pull"], None), + ( + "pip_install", + [sys.executable, "-m", "pip", "install", "-r", "requirements.txt"], + None, + ), ( "modaq_toolkit", [ @@ -257,14 +303,17 @@ def update_application(self) -> dict[str, Any]: "--force-reinstall", "git+https://github.com/MODAQ2/MODAQ_toolkit.git", ], + None, ), + ("npm_install", ["npm", "install"], frontend_dir), + ("frontend_build", ["npm", "run", "build"], frontend_dir), ] - for step_name, cmd in steps: + for step_name, cmd, cwd in steps: try: result = subprocess.run( cmd, - cwd=self.base_dir, + cwd=cwd or self.base_dir, capture_output=True, text=True, check=True, diff --git a/app/routes/delete.py b/app/routes/delete.py new file mode 100644 index 0000000..7f4b518 --- /dev/null +++ b/app/routes/delete.py @@ -0,0 +1,368 @@ +"""Delete API routes for local file cleanup after S3 upload.""" + +import getpass +import json +import subprocess +import threading +import time +from collections import deque +from collections.abc import Generator +from pathlib import Path +from typing import Any + +from flask import Blueprint, Response, jsonify, request + +from app.config import get_settings +from app.services.delete_manager import DeleteJob, get_delete_manager + +delete_bp = Blueprint("delete", __name__) + +# SSE client queues for delete jobs +_sse_queues: dict[str, list[deque[dict[str, Any]]]] = {} +_sse_lock = threading.Lock() + + +def _send_sse_event(job_id: str, data: dict[str, Any]) -> None: + """Send an SSE event to all clients listening for a delete job.""" + with _sse_lock: + queues = _sse_queues.get(job_id, []) + for q in queues: + q.append(data) + + +@delete_bp.route("/scan", methods=["POST"]) +def scan_folder() -> tuple[Response, int]: + """Scan a folder for deletable MCAP files. + + Cross-references local .mcap files with the upload cache to find + files that have been uploaded to S3. + + Request body: + folder_path: Path to scan for MCAP files + + Returns: + JSON with job_id, matched files, and stats + """ + if not request.is_json: + return jsonify({"error": "JSON body required"}), 400 + + data = request.get_json() + if not data or "folder_path" not in data: + return jsonify({"error": "folder_path is required"}), 400 + + folder_path = Path(data["folder_path"]) + + if not folder_path.exists(): + return jsonify({"error": f"Folder not found: {folder_path}"}), 404 + + if not folder_path.is_dir(): + return jsonify({"error": f"Path is not a directory: {folder_path}"}), 400 + + excluded_subfolders: list[str] = data.get("excluded_subfolders", []) + excluded_files: list[str] = data.get("excluded_files", []) + + settings = get_settings() + manager = get_delete_manager() + + try: + job = manager.scan_folder( + str(folder_path.absolute()), + settings.s3_bucket, + excluded_subfolders=excluded_subfolders, + excluded_files=excluded_files, + ) + except PermissionError as e: + return jsonify({"error": f"Permission denied: {e}"}), 403 + + total_size = sum(f.file_size for f in job.files) + has_permission_issues = any(not f.writable for f in job.files) + + return jsonify( + { + "success": True, + "job_id": job.job_id, + "folder_path": str(folder_path.absolute()), + "files": [f.to_dict() for f in job.files], + "total_files": len(job.files), + "total_size": total_size, + "permission_warning": has_permission_issues, + } + ), 200 + + +@delete_bp.route("/start/", methods=["POST"]) +def start_delete(job_id: str) -> tuple[Response, int]: + """Start verification and deletion for a delete job. + + Args: + job_id: The delete job to start + + Returns: + JSON with job status + """ + settings = get_settings() + manager = get_delete_manager() + + job = manager.get_job(job_id) + if not job: + return jsonify({"error": "Job not found"}), 404 + + def progress_callback(job: DeleteJob) -> None: + """Send progress updates via SSE.""" + if job.status in ("completed", "failed", "cancelled"): + _send_sse_event(job.job_id, {"type": "delete_complete", **job.to_dict()}) + else: + _send_sse_event(job.job_id, {"type": "delete_progress", **job.to_progress_dict()}) + + def batch_callback(batch_event: dict[str, Any]) -> None: + """Send batch-level events via SSE.""" + _send_sse_event(job_id, batch_event) + + def run_delete() -> None: + manager.start_delete_job( + job_id, + settings.aws_profile, + settings.aws_region, + progress_callback=progress_callback, + batch_callback=batch_callback, + ) + + thread = threading.Thread(target=run_delete, daemon=True) + thread.start() + + return jsonify({"job_id": job_id, "status": "started"}), 200 + + +@delete_bp.route("/progress/", methods=["GET"]) +def get_progress(job_id: str) -> Response: + """Stream progress updates for a delete job via SSE. + + Args: + job_id: The delete job to monitor + + Returns: + SSE stream of progress updates + """ + manager = get_delete_manager() + + def generate() -> Generator[str, None, None]: + queue: deque[dict[str, Any]] = deque() + with _sse_lock: + if job_id not in _sse_queues: + _sse_queues[job_id] = [] + _sse_queues[job_id].append(queue) + + try: + # Send initial state + job = manager.get_job(job_id) + if job: + yield f"data: {json.dumps(job.to_progress_dict())}\n\n" + + while True: + while queue: + data = queue.popleft() + yield f"data: {json.dumps(data)}\n\n" + + # Terminal events + if data.get("type") == "delete_complete": + return + + time.sleep(0.1) + + # Check if job still exists + job = manager.get_job(job_id) + if not job: + yield 'data: {"error": "Job not found"}\n\n' + return + + # Handle race: job completed before client connected + if job.status in ("completed", "failed", "cancelled"): + yield f"data: {json.dumps({'type': 'delete_complete', **job.to_dict()})}\n\n" + return + + finally: + with _sse_lock: + if job_id in _sse_queues and queue in _sse_queues[job_id]: + _sse_queues[job_id].remove(queue) + if not _sse_queues[job_id]: + del _sse_queues[job_id] + + return Response( + generate(), + mimetype="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) + + +@delete_bp.route("/status/", methods=["GET"]) +def get_status(job_id: str) -> tuple[Response, int]: + """Get current status of a delete job (non-streaming). + + Args: + job_id: The delete job to check + + Returns: + JSON with job status + """ + manager = get_delete_manager() + + job = manager.get_job(job_id) + if not job: + return jsonify({"error": "Job not found"}), 404 + + return jsonify(job.to_dict()), 200 + + +@delete_bp.route("/results/", methods=["GET"]) +def get_results(job_id: str) -> tuple[Response, int]: + """Get paginated results for a completed delete job. + + For large jobs (>1000 files), results can be retrieved in pages + to prevent memory overload and large response payloads. + + Args: + job_id: The delete job to get results for + + Query parameters: + page: Page number (default: 1) + per_page: Results per page (default: 100, max: 500) + + Returns: + JSON response with paginated file results and job metadata + """ + manager = get_delete_manager() + + # Get the job + job = manager.get_job(job_id) + if not job: + return jsonify({"error": "Job not found"}), 404 + + # Parse pagination parameters + page = request.args.get("page", 1, type=int) + per_page = min(request.args.get("per_page", 100, type=int), 500) + + # Calculate pagination + total_files = len(job.files) + total_pages = (total_files + per_page - 1) // per_page + offset = (page - 1) * per_page + paginated_files = job.files[offset : offset + per_page] + + # Build response + return jsonify( + { + "job_id": job_id, + "files": [f.to_dict() for f in paginated_files], + "pagination": { + "page": page, + "per_page": per_page, + "total_files": total_files, + "total_pages": total_pages, + "has_next": page < total_pages, + "has_prev": page > 1, + }, + "job_metadata": { + "job_id": job.job_id, + "status": job.status, + "total_files": total_files, + "status_counts": job.to_progress_dict().get("status_counts", {}), + "total_deleted_size": job.to_progress_dict().get("total_deleted_size", 0), + }, + } + ), 200 + + +@delete_bp.route("/cancel/", methods=["POST"]) +def cancel_delete(job_id: str) -> tuple[Response, int]: + """Cancel a delete job. + + Args: + job_id: The delete job to cancel + + Returns: + JSON with cancellation status + """ + manager = get_delete_manager() + + if manager.cancel_job(job_id): + job = manager.get_job(job_id) + return jsonify( + { + "success": True, + "job_id": job_id, + "job": job.to_dict() if job else None, + } + ), 200 + + return jsonify({"error": "Job not found"}), 404 + + +@delete_bp.route("/fix-permissions", methods=["POST"]) +def fix_permissions() -> tuple[Response, int]: + """Fix file permissions on an ext4 external drive using sudo chown. + + Runs ``sudo -S chown -R `` with the + supplied password piped via stdin (never logged or stored). + + Request body: + folder_path: Directory whose ownership should be fixed + password: The user's sudo password + + Returns: + JSON with success status or error details + """ + if not request.is_json: + return jsonify({"error": "JSON body required"}), 400 + + data = request.get_json() + if not data or "folder_path" not in data or "password" not in data: + return jsonify({"error": "folder_path and password are required"}), 400 + + folder_path = Path(data["folder_path"]) + password: str = data["password"] + + if not folder_path.exists(): + return jsonify({"error": f"Folder not found: {folder_path}"}), 404 + + if not folder_path.is_dir(): + return jsonify({"error": f"Path is not a directory: {folder_path}"}), 400 + + # Security: only allow paths under /media/ to prevent abuse + try: + resolved = folder_path.resolve() + if not str(resolved).startswith("/media/"): + return jsonify({"error": "Permission fix is only allowed for paths under /media/"}), 403 + except (OSError, ValueError): + return jsonify({"error": "Invalid path"}), 400 + + current_user = getpass.getuser() + + try: + result = subprocess.run( # noqa: S603 + ["sudo", "-S", "chown", "-R", current_user, str(resolved)], + input=f"{password}\n", + capture_output=True, + text=True, + timeout=60, + ) + + if result.returncode != 0: + stderr = result.stderr.strip() + # Strip sudo password prompt from error output + error_lines = [ + line + for line in stderr.splitlines() + if not line.startswith("[sudo]") and "password" not in line.lower() + ] + error_msg = "\n".join(error_lines).strip() or "Permission fix failed" + return jsonify({"error": error_msg}), 500 + + return jsonify({"success": True}), 200 + + except subprocess.TimeoutExpired: + return jsonify({"error": "Operation timed out"}), 500 + except Exception as e: + return jsonify({"error": str(e)}), 500 diff --git a/app/routes/files.py b/app/routes/files.py index 3b122e0..3237814 100644 --- a/app/routes/files.py +++ b/app/routes/files.py @@ -1,11 +1,13 @@ """File browsing API routes for modaq_upload""" +import os from pathlib import Path from flask import Blueprint, Response, g, jsonify, request from app.config import get_settings from app.services import s3_service +from app.services.cache_service import get_cache_service files_bp = Blueprint("files", __name__) @@ -101,58 +103,6 @@ def get_file_info() -> tuple[Response, int]: return jsonify({"error": str(e)}), 500 -@files_bp.route("/search", methods=["GET"]) -def search_files() -> tuple[Response, int]: - """Search for files in S3 bucket by name pattern. - - Query parameters: - query: Search query string - prefix: S3 prefix to search within (default: "") - - Returns: - JSON response with matching files - """ - query = request.args.get("query", "").lower() - prefix = request.args.get("prefix", "") - - if not query: - return jsonify({"error": "Search query required"}), 400 - - try: - client = s3_service.create_s3_client( - g.settings.aws_profile, - g.settings.aws_region, - ) - - # List all objects with prefix (no delimiter to get all files) - result = s3_service.list_bucket_objects( - client, - g.settings.s3_bucket, - prefix=prefix, - delimiter="", # No delimiter to get all nested files - max_keys=10000, - ) - - if not result["success"]: - return jsonify({"error": result["error"]}), 500 - - # Filter files by query - matching_files = [f for f in result["files"] if query in f["name"].lower()] - - return jsonify( - { - "success": True, - "query": query, - "prefix": prefix, - "files": matching_files[:100], # Limit results - "total_matches": len(matching_files), - } - ), 200 - - except Exception as e: - return jsonify({"error": str(e)}), 500 - - @files_bp.route("/browse", methods=["GET"]) def browse_local() -> tuple[Response, int]: """Browse local filesystem for folder selection. @@ -163,12 +113,16 @@ def browse_local() -> tuple[Response, int]: Returns: JSON response with folders, files, and navigation info """ - # Get requested path, default to home directory + # Get requested path, default to configured upload folder or home directory requested_path = request.args.get("path", "") if not requested_path: - # Default to home directory - requested_path = str(Path.home()) + settings = get_settings() + default_folder = settings.default_upload_folder + if default_folder and Path(default_folder).is_dir(): + requested_path = default_folder + else: + requested_path = str(Path.home()) path = Path(requested_path) @@ -189,51 +143,89 @@ def browse_local() -> tuple[Response, int]: if not path.is_dir(): return jsonify({"error": f"Not a directory: {path}"}), 400 - # Build response - folders: list[dict[str, str | int]] = [] - files: list[dict[str, str | int | float]] = [] + # Build response — single-pass walk for recursive MCAP counts + cache checks. + # os.walk with onerror skips unreadable subdirectories instead of aborting, + # which is important on Linux where permission errors are common. + cache = get_cache_service() + bucket = g.settings.s3_bucket + + folder_mcap_counts: dict[str, int] = {} + folder_uploaded_counts: dict[str, int] = {} + files: list[dict[str, str | int | float | bool]] = [] mcap_count = 0 + direct_uploaded = 0 + + def _walk_error(err: OSError) -> None: + pass # Skip unreadable directories silently + + for dirpath, dirnames, filenames in os.walk(str(path), onerror=_walk_error): + # Skip hidden directories in-place so os.walk won't descend into them + dirnames[:] = [d for d in dirnames if not d.startswith(".")] + + for fname in filenames: + if not fname.endswith(".mcap") or fname.startswith("."): + continue + + mcap_path = Path(dirpath) / fname + rel = mcap_path.relative_to(path) + parts = rel.parts + + try: + file_stat = mcap_path.stat() + except OSError: + continue + uploaded = ( + cache.check_exists_by_filename(bucket, mcap_path.name, file_stat.st_size) is True + ) + + if len(parts) == 1: + # Direct child MCAP file + mcap_count += 1 + if uploaded: + direct_uploaded += 1 + files.append( + { + "name": mcap_path.name, + "path": str(mcap_path), + "size": file_stat.st_size, + "mtime": file_stat.st_mtime, + "already_uploaded": uploaded, + } + ) + else: + # Nested — attribute to the immediate subfolder + folder_name = parts[0] + folder_mcap_counts[folder_name] = folder_mcap_counts.get(folder_name, 0) + 1 + if uploaded: + folder_uploaded_counts[folder_name] = ( + folder_uploaded_counts.get(folder_name, 0) + 1 + ) + + # Build folder list from direct children (non-hidden directories) + folders: list[dict[str, str | int]] = [] try: - for entry in sorted(path.iterdir(), key=lambda x: (not x.is_dir(), x.name.lower())): - # Skip hidden files/folders (starting with .) + for entry in sorted(path.iterdir(), key=lambda x: x.name.lower()): if entry.name.startswith("."): continue - try: if entry.is_dir(): - # Count MCAP files in this folder (non-recursive, for preview) - try: - mcap_in_folder = sum(1 for f in entry.iterdir() if f.suffix == ".mcap") - except PermissionError: - mcap_in_folder = 0 - folders.append( { "name": entry.name, "path": str(entry), - "mcap_count": mcap_in_folder, + "mcap_count": folder_mcap_counts.get(entry.name, 0), + "already_uploaded": folder_uploaded_counts.get(entry.name, 0), } ) - elif entry.is_file(): - if entry.suffix == ".mcap": - mcap_count += 1 - file_stat = entry.stat() - files.append( - { - "name": entry.name, - "path": str(entry), - "size": file_stat.st_size, - "mtime": file_stat.st_mtime, - } - ) except PermissionError: - # Skip entries we can't access continue - except PermissionError: return jsonify({"error": f"Permission denied: {path}"}), 403 + # Sort files by name + files.sort(key=lambda f: str(f["name"]).lower()) + # Build breadcrumbs for navigation breadcrumbs: list[dict[str, str]] = [] current = path @@ -249,16 +241,56 @@ def browse_local() -> tuple[Response, int]: {"name": "Home", "path": str(Path.home())}, ] + # Add SURFWEC SSD as the top priority quick link + surfwec_ssd = Path("/media/m2/SURFWEC_SSD") + if surfwec_ssd.exists() and surfwec_ssd.is_dir(): + quick_links.append({"name": "SURFWEC_SSD", "path": str(surfwec_ssd)}) + # Add Volumes on macOS volumes_path = Path("/Volumes") if volumes_path.exists(): try: - for vol in volumes_path.iterdir(): + for vol in sorted(volumes_path.iterdir(), key=lambda x: x.name.lower()): if vol.is_dir() and not vol.name.startswith("."): quick_links.append({"name": vol.name, "path": str(vol)}) except PermissionError: pass + # Add /media/m2 as a priority quick link on Linux if it exists + media_m2 = Path("/media/m2") + if media_m2.exists() and media_m2.is_dir(): + quick_links.append({"name": "m2", "path": str(media_m2)}) + + # Add /media itself and its subdirectories on Linux (removable drives, USB, etc.) + media_path = Path("/media") + if media_path.exists() and not volumes_path.exists(): + quick_links.append({"name": "media", "path": str(media_path)}) + try: + for entry in sorted(media_path.iterdir(), key=lambda x: x.name.lower()): + if not entry.is_dir() or entry.name.startswith("."): + continue + entry_str = str(entry) + # Already added /media/m2 above + if entry_str == str(media_m2): + continue + # If it's a user directory (e.g. /media/username), list its children + try: + children = [ + c for c in entry.iterdir() if c.is_dir() and not c.name.startswith(".") + ] + except PermissionError: + children = [] + if children: + for child in sorted(children, key=lambda x: x.name.lower()): + # Already added SURFWEC_SSD above + if child == surfwec_ssd: + continue + quick_links.append({"name": child.name, "path": str(child)}) + else: + quick_links.append({"name": entry.name, "path": entry_str}) + except PermissionError: + pass + return jsonify( { "success": True, @@ -269,5 +301,7 @@ def browse_local() -> tuple[Response, int]: "folders": folders, "files": files, # Only MCAP files "mcap_count": mcap_count, + "total_mcap_count": mcap_count + sum(folder_mcap_counts.values()), + "already_uploaded": direct_uploaded + sum(folder_uploaded_counts.values()), } ), 200 diff --git a/app/routes/logs.py b/app/routes/logs.py index 6017f08..394783f 100644 --- a/app/routes/logs.py +++ b/app/routes/logs.py @@ -79,6 +79,18 @@ def get_log_stats() -> tuple[Response, int]: return jsonify(stats), 200 +@logs_bp.route("/upload-stats", methods=["GET"]) +def get_upload_stats() -> tuple[Response, int]: + """Get aggregated upload statistics from CSV summaries. + + Returns: + JSON with global totals and per-session detail including file rows. + """ + log = get_log_service() + stats = log.get_upload_stats() + return jsonify(stats), 200 + + @logs_bp.route("/sync", methods=["POST"]) def sync_logs() -> tuple[Response, int]: """Trigger S3 sync of log files. diff --git a/app/routes/main.py b/app/routes/main.py index 700e234..76107ba 100644 --- a/app/routes/main.py +++ b/app/routes/main.py @@ -1,29 +1,36 @@ -"""Main page routes for modaq_upload""" +"""Main page routes for modaq_upload — serves the React SPA.""" -from flask import Blueprint, render_template +import os -main_bp = Blueprint("main", __name__) +from flask import Blueprint, Response, send_from_directory +main_bp = Blueprint("main", __name__) -@main_bp.route("/") -def index() -> str: - """Render the upload page.""" - return render_template("index.html") +# Path to the React production build +FRONTEND_DIST = os.path.join( + os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), + "frontend", + "dist", +) +@main_bp.route("/") +@main_bp.route("/delete") @main_bp.route("/files") -def files() -> str: - """Render the S3 file browser page.""" - return render_template("files.html") +@main_bp.route("/settings") +@main_bp.route("/logs") +def serve_spa() -> Response: + """Serve React's index.html for all client-side routes.""" + return send_from_directory(FRONTEND_DIST, "index.html") -@main_bp.route("/settings") -def settings() -> str: - """Render the settings page.""" - return render_template("settings.html") +@main_bp.route("/assets/") +def serve_assets(filename: str) -> Response: + """Serve Vite-built static assets (JS, CSS).""" + return send_from_directory(os.path.join(FRONTEND_DIST, "assets"), filename) -@main_bp.route("/logs") -def logs() -> str: - """Render the logs viewer page.""" - return render_template("logs.html") +@main_bp.route("/images/") +def serve_images(filename: str) -> Response: + """Serve image assets from the public directory.""" + return send_from_directory(os.path.join(FRONTEND_DIST, "images"), filename) diff --git a/app/routes/settings.py b/app/routes/settings.py index bc1e1a6..f58fb9a 100644 --- a/app/routes/settings.py +++ b/app/routes/settings.py @@ -1,5 +1,11 @@ """Settings API routes for modaq_upload""" +import os +import signal +import sys +import threading +from typing import Any + from flask import Blueprint, Response, jsonify, request from app.config import get_package_version, get_settings, get_updater @@ -18,7 +24,7 @@ def get_all_settings() -> tuple[Response, int]: JSON response with all settings """ settings = get_settings() - return jsonify(settings.all()), 200 + return jsonify(settings.to_response()), 200 @settings_bp.route("", methods=["PUT"]) @@ -40,12 +46,17 @@ def update_settings() -> tuple[Response, int]: settings = get_settings() - # Validate settings - allowed_keys = { - "aws_profile", "aws_region", "s3_bucket", "default_upload_folder", "display_name", - "log_directory", - } - filtered_data = {k: v for k, v in data.items() if k in allowed_keys} + # Accept any key present in the current settings schema; deep-merge nested dicts + # (e.g. batch_processing) so callers can send partial sub-objects. + current = settings.all() + filtered_data: dict[str, Any] = {} + for k, v in data.items(): + if k not in current: + continue + if isinstance(current[k], dict) and isinstance(v, dict): + filtered_data[k] = {**current[k], **v} + else: + filtered_data[k] = v if not filtered_data: return jsonify({"error": "No valid settings provided"}), 400 @@ -60,7 +71,7 @@ def update_settings() -> tuple[Response, int]: {"changed_keys": list(filtered_data.keys())}, ) - return jsonify(settings.all()), 200 + return jsonify(settings.to_response()), 200 @settings_bp.route("/profiles", methods=["GET"]) @@ -192,10 +203,7 @@ def run_update() -> tuple[Response, int]: result = updater.update_application() # Determine overall success - all_success = all( - step["success"] - for step in [result["git_pull"], result["pip_install"], result["modaq_toolkit"]] - ) + all_success = all(step["success"] for step in result.values()) return jsonify( { @@ -250,6 +258,40 @@ def invalidate_cache() -> tuple[Response, int]: ), 200 +@settings_bp.route("/shutdown", methods=["POST"]) +def shutdown_server() -> tuple[Response, int]: + """Gracefully shut down the application server. + + Detects whether we're running under gunicorn or the Flask dev server + and sends the appropriate signal after a brief delay so the HTTP + response can be returned to the client first. + + - Gunicorn: sends SIGTERM to the master (parent) process, which + triggers a graceful shutdown of all workers. + - Flask dev server: sends SIGINT to the current process. + + Returns: + JSON response confirming shutdown was initiated + """ + log = get_log_service() + log.info("app", "shutdown_requested", "Graceful shutdown requested via settings UI") + + def _shutdown() -> None: + if "gunicorn" in sys.modules: + # Under gunicorn, the worker's parent is the master process. + # SIGTERM tells the master to finish active requests and exit. + os.kill(os.getppid(), signal.SIGTERM) + else: + os.kill(os.getpid(), signal.SIGINT) + + # Delay slightly so the response reaches the client + timer = threading.Timer(0.5, _shutdown) + timer.daemon = True + timer.start() + + return jsonify({"success": True, "message": "Server is shutting down..."}), 200 + + @settings_bp.route("/cache/sync", methods=["POST"]) def sync_cache_with_s3() -> tuple[Response, int]: """Sync cache with S3, marking deleted files as non-existent. diff --git a/app/routes/upload.py b/app/routes/upload.py index 0010c50..cd4cb74 100644 --- a/app/routes/upload.py +++ b/app/routes/upload.py @@ -23,16 +23,55 @@ # Store for SSE clients per job _sse_queues: dict[str, list[deque[dict[str, Any]]]] = {} +_sse_events: dict[str, threading.Event] = {} # Events to signal new data (replaces polling) +_sse_timestamps: dict[str, float] = {} # Track last activity for TTL cleanup _sse_lock = threading.Lock() +# Configuration +SSE_QUEUE_TTL_SECONDS = 3600 # Clean up queues after 1 hour of inactivity +SSE_HEARTBEAT_INTERVAL_SECONDS = 15 # Send heartbeat every 15 seconds + + +def _cleanup_old_sse_queues() -> int: + """Clean up SSE queues that haven't been accessed recently. + + Returns: + Number of queues removed + """ + now = time.time() + removed = 0 + with _sse_lock: + expired = [ + job_id + for job_id, timestamp in _sse_timestamps.items() + if now - timestamp > SSE_QUEUE_TTL_SECONDS + ] + for job_id in expired: + _sse_queues.pop(job_id, None) + _sse_events.pop(job_id, None) + _sse_timestamps.pop(job_id, None) + removed += 1 + return removed + def send_sse_event(job_id: str, data: dict[str, Any]) -> None: - """Send an SSE event to all clients listening for a job.""" + """Send an SSE event to all clients listening for a job. + + This wakes up all waiting SSE generators via the event signal, + avoiding busy-wait polling. + """ with _sse_lock: queues = _sse_queues.get(job_id, []) for q in queues: q.append(data) + # Update last activity timestamp + _sse_timestamps[job_id] = time.time() + + # Signal waiting threads that new data is available + if job_id in _sse_events: + _sse_events[job_id].set() + def _make_analysis_callback( job_id: str, @@ -128,7 +167,6 @@ def run_analysis() -> None: "job_id": job.job_id, "status": "analyzing", "total_files": len(job.files), - "files": [f.to_dict() for f in job.files], } ), 202 @@ -161,8 +199,11 @@ def start_upload(job_id: str) -> tuple[Response, int]: skip_duplicates = data.get("skip_duplicates", True) def progress_callback(job: UploadJob) -> None: - """Send progress updates via SSE.""" - send_sse_event(job.job_id, job.to_dict()) + """Send progress updates via SSE — lightweight during upload, full at completion.""" + if job.status in (UploadStatus.COMPLETED, UploadStatus.FAILED, UploadStatus.CANCELLED): + send_sse_event(job.job_id, job.to_dict()) + else: + send_sse_event(job.job_id, job.to_progress_dict()) # Start upload in background thread def run_upload() -> None: @@ -185,6 +226,9 @@ def run_upload() -> None: def get_progress(job_id: str) -> Response: """Stream progress updates for a job via Server-Sent Events. + Uses event-driven signaling (instead of polling) and periodic heartbeats + to efficiently detect client disconnects. + Args: job_id: The job ID to monitor @@ -194,46 +238,132 @@ def get_progress(job_id: str) -> Response: manager = get_upload_manager() def generate() -> Generator[str, None, None]: - # Create a queue for this client + # Create a queue for this client and an event for signaling queue: deque[dict[str, Any]] = deque() + event = threading.Event() + with _sse_lock: if job_id not in _sse_queues: _sse_queues[job_id] = [] _sse_queues[job_id].append(queue) + _sse_events[job_id] = event + _sse_timestamps[job_id] = time.time() + + # Periodic cleanup of old queues + _cleanup_old_sse_queues() try: # Send initial state job = manager.get_job(job_id) + scan_job = manager.get_scan_job(job_id) if not job else None if job: - yield f"data: {json.dumps(job.to_dict())}\n\n" - - # Stream updates + if job.status in ( + UploadStatus.COMPLETED, + UploadStatus.FAILED, + UploadStatus.CANCELLED, + ): + yield f"data: {json.dumps(job.to_dict())}\n\n" + else: + yield f"data: {json.dumps(job.to_progress_dict())}\n\n" + # Replay per-file states for files already past PENDING. + # Covers the race window where ANALYZING events fired + # before the EventSource connected. + analysis_complete = job.status.value in ("ready", "failed") + for fs in job.files: + if fs.status != UploadStatus.PENDING: + replay = { + "type": "analysis_progress", + "job_id": job.job_id, + "job_status": job.status.value, + "file": fs.to_dict(), + "total_files": len(job.files), + "analysis_complete": analysis_complete, + } + yield f"data: {json.dumps(replay)}\n\n" + elif scan_job: + yield f"data: {json.dumps({'type': 'scan_initial', 'status': scan_job.status})}\n\n" + + last_heartbeat_time = time.time() + + # Stream updates with event-driven waiting (no polling) while True: - # Check for updates + # Process all queued events while queue: data = queue.popleft() yield f"data: {json.dumps(data)}\n\n" + last_heartbeat_time = time.time() - # Check if job is complete + # Check if job is complete (upload jobs) if data.get("status") in ("completed", "failed", "cancelled"): - return - - # Small delay to prevent busy waiting - time.sleep(0.1) - - # Check if job still exists + # For scan events, check the type field + if data.get("type") == "scan_complete": + return + # For upload jobs (no type field) + if not data.get("type"): + return + + # Send heartbeat if no activity for a while + now = time.time() + if now - last_heartbeat_time > SSE_HEARTBEAT_INTERVAL_SECONDS: + yield ": heartbeat\n\n" # Comment line, ignored by EventSource + last_heartbeat_time = now + + # Wait for signal (blocking, no CPU waste) with timeout for heartbeat + event.wait(timeout=SSE_HEARTBEAT_INTERVAL_SECONDS) + event.clear() + + # Check if job still exists (upload or scan) job = manager.get_job(job_id) - if not job: + scan_job = manager.get_scan_job(job_id) if not job else None + if not job and not scan_job: yield 'data: {"error": "Job not found"}\n\n' return + # Check if scan job reached terminal state before client connected + # (race condition: fast scans finish before EventSource opens, + # so events were sent to empty queues and dropped) + if scan_job and scan_job.status in ( + "completed", + "failed", + "cancelled", + ): + # Replay missed folder results so frontend gets the data + for folder_data in scan_job.scanned_folders: + replay_event = { + "type": "scan_folder_complete", + "folder": folder_data, + "folders_scanned": scan_job.folders_scanned, + "folders_total": scan_job.folders_total, + "running_totals": { + "total_files_found": scan_job.total_files_found, + "total_already_uploaded": scan_job.total_already_uploaded, + "total_size": scan_job.total_size, + }, + } + yield f"data: {json.dumps(replay_event)}\n\n" + + terminal_data = { + "type": "scan_complete", + "status": scan_job.status, + "folders_scanned": scan_job.folders_scanned, + "folders_total": scan_job.folders_total, + "total_files_found": scan_job.total_files_found, + "total_already_uploaded": scan_job.total_already_uploaded, + "total_size": scan_job.total_size, + } + yield f"data: {json.dumps(terminal_data)}\n\n" + return + finally: - # Clean up queue + # Clean up queue and event with _sse_lock: if job_id in _sse_queues and queue in _sse_queues[job_id]: _sse_queues[job_id].remove(queue) if not _sse_queues[job_id]: - del _sse_queues[job_id] + # Last client disconnected, remove event too + _sse_queues.pop(job_id, None) + _sse_events.pop(job_id, None) + # Keep timestamp for TTL cleanup return Response( generate(), @@ -265,6 +395,68 @@ def get_status(job_id: str) -> tuple[Response, int]: return jsonify(job.to_dict()), 200 +@upload_bp.route("/results/", methods=["GET"]) +def get_results(job_id: str) -> tuple[Response, int]: + """Get paginated results for a completed job. + + For large jobs (>1000 files), results are stored in database and retrieved + in pages to prevent memory overload and large response payloads. + + Args: + job_id: The job ID to get results for + + Query parameters: + page: Page number (default: 1) + per_page: Results per page (default: 100, max: 500) + + Returns: + JSON response with paginated file results and job metadata + """ + from app.services.job_storage import get_job_storage + + manager = get_upload_manager() + storage = get_job_storage() + + # Try to get from database first (for large jobs) + db_job = storage.get_job(job_id) + if db_job: + page = request.args.get("page", 1, type=int) + per_page = min(request.args.get("per_page", 100, type=int), 500) + + results = storage.get_job_results(job_id, page=page, per_page=per_page) + results["job_metadata"] = db_job + return jsonify(results), 200 + + # Fall back to in-memory job (for small jobs) + job = manager.get_job(job_id) + if not job: + return jsonify({"error": "Job not found"}), 404 + + # Return in-memory job with all files (small jobs only) + return jsonify( + { + "job_id": job_id, + "files": [f.to_dict() for f in job.files], + "pagination": { + "page": 1, + "per_page": len(job.files), + "total_files": len(job.files), + "total_pages": 1, + "has_next": False, + "has_prev": False, + }, + "job_metadata": { + "job_id": job.job_id, + "status": job.status.value, + "total_files": len(job.files), + "files_uploaded": sum(1 for f in job.files if f.status == UploadStatus.COMPLETED), + "files_failed": job.files_failed, + "total_bytes": job.total_bytes, + }, + } + ), 200 + + @upload_bp.route("/active", methods=["GET"]) def get_active_job() -> tuple[Response, int]: """Get the most recent active job (for state restoration on page refresh). @@ -292,6 +484,27 @@ def get_active_job() -> tuple[Response, int]: return jsonify({"job_id": None, "job": None}), 200 +@upload_bp.route("/cleanup-sse", methods=["POST"]) +def cleanup_sse_queues() -> tuple[Response, int]: + """Clean up stale SSE queues and events. + + This can be called periodically or manually to reclaim memory from + abandoned connections. Returns the number of queues removed. + + Returns: + JSON response with cleanup statistics + """ + removed = _cleanup_old_sse_queues() + return jsonify( + { + "success": True, + "queues_removed": removed, + "active_queues": len(_sse_queues), + "ttl_seconds": SSE_QUEUE_TTL_SECONDS, + } + ), 200 + + @upload_bp.route("/cancel/", methods=["POST"]) def cancel_upload(job_id: str) -> tuple[Response, int]: """Cancel an upload job. @@ -313,6 +526,8 @@ def cancel_upload(job_id: str) -> tuple[Response, int]: "job": job.to_dict() if job else None, } ), 200 + elif manager.cancel_scan_job(job_id): + return jsonify({"success": True, "job_id": job_id}), 200 else: return jsonify({"error": "Job not found"}), 404 @@ -374,6 +589,68 @@ def scan_folder() -> tuple[Response, int]: ), 200 +@upload_bp.route("/scan-folder-async", methods=["POST"]) +def scan_folder_async() -> tuple[Response, int]: + """Start an async folder scan that streams results via SSE. + + Request body: + folder_path: Path to the folder to scan + + Returns: + JSON response with job_id (202 Accepted) + """ + if not request.is_json: + return jsonify({"error": "JSON body required"}), 400 + + data = request.get_json() + if not data or "folder_path" not in data: + return jsonify({"error": "folder_path is required"}), 400 + + folder_path = Path(data["folder_path"]) + + if not folder_path.exists(): + return jsonify({"error": f"Folder not found: {folder_path}"}), 404 + + if not folder_path.is_dir(): + return jsonify({"error": f"Path is not a directory: {folder_path}"}), 400 + + cache_only: bool = data.get("cache_only", False) + excluded_subfolders: list[str] = data.get("excluded_subfolders", []) + excluded_files: list[str] = data.get("excluded_files", []) + + settings = get_settings() + manager = get_upload_manager() + + scan_job = manager.create_scan_job( + str(folder_path.absolute()), + excluded_subfolders=excluded_subfolders, + excluded_files=excluded_files, + ) + + def scan_progress_callback(job_id: str, event_data: dict[str, Any]) -> None: + send_sse_event(job_id, event_data) + + def run_scan() -> None: + manager.scan_folder_async( + scan_job.job_id, + settings.s3_bucket, + settings.aws_profile, + settings.aws_region, + progress_callback=scan_progress_callback, + cache_only=cache_only, + ) + + thread = threading.Thread(target=run_scan, daemon=True) + thread.start() + + return jsonify( + { + "job_id": scan_job.job_id, + "status": "scanning", + } + ), 202 + + @upload_bp.route("/bulk-analyze", methods=["POST"]) def bulk_analyze() -> tuple[Response, int]: """Bulk analyze files with pre-filtering and optional auto-upload. @@ -421,8 +698,11 @@ def bulk_analyze() -> tuple[Response, int]: } ), 200 - # When force-reuploading, analyze ALL files (not just non-duplicates) - job_files = file_paths if not skip_duplicates else files_to_analyze + # Always include all user-selected files in the job. The pipeline marks + # already-uploaded files as "skipped" when skip_duplicates=True rather than + # silently dropping them — an empty job_files here causes a 0-file job that + # completes instantly and bypasses the upload screen entirely. + job_files = file_paths # Create job with files that need analysis (no temp_dir - direct file access) job = manager.create_job(job_files, auto_upload=auto_upload) @@ -430,53 +710,47 @@ def bulk_analyze() -> tuple[Response, int]: analysis_progress_callback = _make_analysis_callback(job.job_id) def upload_progress_callback(job: UploadJob) -> None: - """Send upload progress updates via SSE.""" - send_sse_event(job.job_id, job.to_dict()) - - # Start analysis in background thread - def run_bulk_analysis() -> None: - manager.analyze_job_async( - job.job_id, - settings.aws_profile, - settings.aws_region, - settings.s3_bucket, - progress_callback=analysis_progress_callback, - ) - - # Send analysis complete event - final_job = manager.get_job(job.job_id) - if final_job: - send_sse_event( + """Send upload progress updates via SSE — lightweight during upload, full at completion.""" + if job.status in (UploadStatus.COMPLETED, UploadStatus.FAILED, UploadStatus.CANCELLED): + send_sse_event(job.job_id, job.to_dict()) + else: + send_sse_event(job.job_id, job.to_progress_dict()) + + # Start in background thread + def run_bulk_job() -> None: + if auto_upload: + # Pipeline: analyze each file and upload immediately as it's ready. + # Uploads start flowing while remaining files are still being parsed. + manager.analyze_and_upload_pipeline( job.job_id, - { - "type": "analysis_complete", - "job": final_job.to_dict(), - "auto_upload": final_job.auto_upload, - }, + settings.aws_profile, + settings.aws_region, + settings.s3_bucket, + skip_duplicates=skip_duplicates, + analysis_callback=analysis_progress_callback, + upload_callback=upload_progress_callback, ) - - # Auto-upload if enabled and analysis succeeded - if final_job.auto_upload and final_job.status == UploadStatus.READY: + else: + # Analysis only — user will review results before starting upload. + manager.analyze_job_async( + job.job_id, + settings.aws_profile, + settings.aws_region, + settings.s3_bucket, + progress_callback=analysis_progress_callback, + ) + final_job = manager.get_job(job.job_id) + if final_job: send_sse_event( job.job_id, { - "type": "auto_upload_starting", - "job_id": job.job_id, + "type": "analysis_complete", + "job": final_job.to_dict(), + "auto_upload": False, }, ) - manager.start_upload( - job.job_id, - settings.aws_profile, - settings.aws_region, - settings.s3_bucket, - skip_duplicates=skip_duplicates, - progress_callback=upload_progress_callback, - ) - elif final_job.auto_upload: - # All files failed analysis — send terminal status so frontend doesn't hang - send_sse_event(job.job_id, final_job.to_dict()) - thread = threading.Thread(target=run_bulk_analysis, daemon=True) + thread = threading.Thread(target=run_bulk_job, daemon=True) thread.start() return jsonify( @@ -486,6 +760,5 @@ def run_bulk_analysis() -> None: "total_files": len(job.files), "pre_filter_stats": pre_filter_stats, "auto_upload": auto_upload, - "files": [f.to_dict() for f in job.files], } ), 202 diff --git a/app/services/batch_processor.py b/app/services/batch_processor.py new file mode 100644 index 0000000..a2c161e --- /dev/null +++ b/app/services/batch_processor.py @@ -0,0 +1,433 @@ +"""Batch processing infrastructure for handling large upload/delete operations.""" + +import logging +import os +import time +from collections.abc import Callable +from dataclasses import dataclass +from datetime import UTC, datetime +from enum import Enum +from typing import Any + +import psutil + +logger = logging.getLogger(__name__) + + +@dataclass +class BatchConfig: + """Configuration for batch processing behavior.""" + + enabled: bool = True + batch_size: int = 100 + auto_tune_workers: bool = True + max_workers: int = 4 + target_cpu_percent: float = 70.0 + skip_mcap_validation: bool = False + use_database_for_large_jobs: bool = True + large_job_threshold: int = 1000 + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "BatchConfig": + """Create BatchConfig from dictionary.""" + return cls( + enabled=data.get("enabled", True), + batch_size=data.get("batch_size", 100), + auto_tune_workers=data.get("auto_tune_workers", True), + max_workers=data.get("max_workers", 4), + target_cpu_percent=data.get("target_cpu_percent", 70.0), + skip_mcap_validation=data.get("skip_mcap_validation", False), + use_database_for_large_jobs=data.get("use_database_for_large_jobs", True), + large_job_threshold=data.get("large_job_threshold", 1000), + ) + + +class BatchStatus(Enum): + """Status of a batch within a job.""" + + PENDING = "pending" + PROCESSING = "processing" + COMPLETED = "completed" + FAILED = "failed" + CANCELLED = "cancelled" + + +@dataclass +class BatchState: + """State tracking for an individual batch within a job.""" + + batch_id: int + total_batches: int + files_in_batch: int + status: BatchStatus = BatchStatus.PENDING + files_processed: int = 0 + files_uploaded: int = 0 + files_failed: int = 0 + bytes_uploaded: int = 0 + started_at: datetime | None = None + completed_at: datetime | None = None + error_message: str = "" + + @property + def duration_seconds(self) -> float | None: + """Calculate batch processing duration in seconds.""" + if self.started_at and self.completed_at: + return (self.completed_at - self.started_at).total_seconds() + return None + + def to_dict(self) -> dict[str, Any]: + """Convert to dictionary for JSON serialization.""" + return { + "batch_id": self.batch_id, + "total_batches": self.total_batches, + "files_in_batch": self.files_in_batch, + "status": self.status.value, + "files_processed": self.files_processed, + "files_uploaded": self.files_uploaded, + "files_failed": self.files_failed, + "bytes_uploaded": self.bytes_uploaded, + "started_at": self.started_at.isoformat() if self.started_at else None, + "completed_at": self.completed_at.isoformat() if self.completed_at else None, + "duration_seconds": self.duration_seconds, + "error_message": self.error_message, + } + + +class WorkerAutoTuner: + """Monitors system resources and adjusts worker count dynamically.""" + + def __init__( + self, + target_cpu_percent: float = 70.0, + check_interval_seconds: float = 30.0, + max_workers: int = 16, + ) -> None: + """Initialize the auto-tuner. + + Args: + target_cpu_percent: Target CPU utilization (0-100) + check_interval_seconds: Time between adjustment checks + max_workers: Maximum allowed workers (hard ceiling) + """ + self.target_cpu_percent = target_cpu_percent + self.check_interval_seconds = check_interval_seconds + self.max_workers = max_workers + self.cpu_count = os.cpu_count() or 4 + self.last_check_time = 0.0 + self.history: list[dict[str, Any]] = [] + + def should_check(self) -> bool: + """Check if enough time has passed for another adjustment.""" + now = time.time() + return (now - self.last_check_time) >= self.check_interval_seconds + + def adjust_if_needed(self, current_workers: int) -> int: + """Adjust worker count based on current system utilization. + + Args: + current_workers: Current number of workers + + Returns: + Recommended worker count (may be same as current) + """ + if not self.should_check(): + return current_workers + + self.last_check_time = time.time() + + # Measure CPU and memory + try: + cpu_percent = psutil.cpu_percent(interval=1.0) + memory_info = psutil.virtual_memory() + memory_percent = memory_info.percent + + # Record metrics + self.history.append( + { + "timestamp": time.time(), + "cpu_percent": cpu_percent, + "memory_percent": memory_percent, + "workers": current_workers, + } + ) + + # Keep only last 10 measurements + if len(self.history) > 10: + self.history = self.history[-10:] + + logger.info( + f"Auto-tuner: CPU={cpu_percent:.1f}%, Memory={memory_percent:.1f}%, " + f"Workers={current_workers}" + ) + + # Start conservative on first check + if len(self.history) == 1: + recommended = min(4, self.cpu_count - 1, self.max_workers) + logger.info(f"Auto-tuner: Initial worker count: {recommended}") + return max(2, recommended) + + # Decrease if overloaded + if cpu_percent > self.target_cpu_percent + 10 or memory_percent > 85: + new_workers = max(2, current_workers - 1) + logger.info( + f"Auto-tuner: Decreasing workers {current_workers} → {new_workers} " + f"(CPU overload or low memory)" + ) + return new_workers + + # Increase if underutilized + if cpu_percent < self.target_cpu_percent - 10: + new_workers = min( + current_workers + 2, + self.cpu_count, + self.max_workers, + ) + if new_workers > current_workers: + logger.info( + f"Auto-tuner: Increasing workers {current_workers} → {new_workers} " + f"(CPU underutilized)" + ) + return new_workers + + # No change needed + return current_workers + + except Exception as e: + logger.warning(f"Auto-tuner: Error checking system resources: {e}") + return current_workers + + def get_metrics(self) -> dict[str, Any]: + """Get current tuning metrics.""" + if not self.history: + return { + "cpu_percent": None, + "memory_percent": None, + "workers": None, + "history_size": 0, + } + + latest = self.history[-1] + return { + "cpu_percent": latest["cpu_percent"], + "memory_percent": latest["memory_percent"], + "workers": latest["workers"], + "history_size": len(self.history), + "target_cpu_percent": self.target_cpu_percent, + } + + +def split_into_batches(items: list[Any], batch_size: int) -> list[list[Any]]: + """Split a list of items into batches of specified size. + + Args: + items: List of items to split + batch_size: Maximum items per batch + + Returns: + List of batches (each batch is a list of items) + + Example: + >>> split_into_batches([1, 2, 3, 4, 5], 2) + [[1, 2], [3, 4], [5]] + """ + if batch_size <= 0: + raise ValueError("batch_size must be positive") + + batches = [] + for i in range(0, len(items), batch_size): + batches.append(items[i : i + batch_size]) + + return batches + + +class BatchProcessor: + """Orchestrates batch-by-batch processing for large jobs.""" + + def __init__(self, config: BatchConfig) -> None: + """Initialize batch processor. + + Args: + config: Batch processing configuration + """ + self.config = config + self.tuner: WorkerAutoTuner | None = None + + if config.auto_tune_workers: + self.tuner = WorkerAutoTuner( + target_cpu_percent=config.target_cpu_percent, + check_interval_seconds=30.0, + max_workers=min(config.max_workers, 16), + ) + + def should_use_batch_processing(self, total_files: int) -> bool: + """Determine if batch processing should be used for this job. + + Args: + total_files: Total number of files in job + + Returns: + True if batch processing should be used + """ + if not self.config.enabled: + return False + + # Use batch processing for jobs exceeding threshold + return total_files >= self.config.large_job_threshold + + def create_batches(self, items: list[Any], batch_size: int | None = None) -> list[list[Any]]: + """Create batches from a list of items. + + Args: + items: Items to batch + batch_size: Override default batch size (optional) + + Returns: + List of batches + """ + size = batch_size if batch_size is not None else self.config.batch_size + return split_into_batches(items, size) + + def process_batches( + self, + items: list[Any], + process_fn: Callable[[list[Any], int, int], dict[str, Any]], + progress_callback: Callable[[BatchState], None] | None = None, + check_cancelled: Callable[[], bool] | None = None, + ) -> dict[str, Any]: + """Process items in batches with progress tracking. + + Args: + items: Items to process + process_fn: Function to process each batch, receives: + - batch items + - batch_id (0-indexed) + - total_batches + Returns dict with 'success', 'processed', 'failed', 'bytes_uploaded' + progress_callback: Optional callback for batch progress updates + check_cancelled: Optional function to check if job was cancelled + + Returns: + Summary dict with total stats: { + 'success': bool, + 'total_processed': int, + 'total_uploaded': int, + 'total_failed': int, + 'total_bytes': int, + 'batches_completed': int, + 'batches_failed': int, + 'duration_seconds': float + } + """ + batches = self.create_batches(items) + total_batches = len(batches) + + logger.info( + f"Batch processor: Processing {len(items)} items in {total_batches} batches " + f"(batch_size={self.config.batch_size})" + ) + + # Tracking stats + total_processed = 0 + total_uploaded = 0 + total_failed = 0 + total_bytes = 0 + batches_completed = 0 + batches_failed = 0 + + start_time = time.time() + + for batch_id, batch_items in enumerate(batches): + # Check for cancellation + if check_cancelled and check_cancelled(): + logger.info("Batch processor: Job cancelled by user") + break + + # Create batch state + batch_state = BatchState( + batch_id=batch_id, + total_batches=total_batches, + files_in_batch=len(batch_items), + status=BatchStatus.PROCESSING, + started_at=datetime.now(UTC), + ) + + # Notify progress callback + if progress_callback: + progress_callback(batch_state) + + try: + # Process this batch + result = process_fn(batch_items, batch_id, total_batches) + + # Update batch state with results + batch_state.files_processed = result.get("processed", 0) + batch_state.files_uploaded = result.get("uploaded", 0) + batch_state.files_failed = result.get("failed", 0) + batch_state.bytes_uploaded = result.get("bytes_uploaded", 0) + batch_state.status = ( + BatchStatus.COMPLETED if result.get("success") else BatchStatus.FAILED + ) + batch_state.completed_at = datetime.now(UTC) + + # Update totals + total_processed += batch_state.files_processed + total_uploaded += batch_state.files_uploaded + total_failed += batch_state.files_failed + total_bytes += batch_state.bytes_uploaded + + if batch_state.status == BatchStatus.COMPLETED: + batches_completed += 1 + else: + batches_failed += 1 + + logger.info( + f"Batch {batch_id + 1}/{total_batches} completed: " + f"{batch_state.files_uploaded} uploaded, {batch_state.files_failed} failed" + ) + + except Exception as e: + logger.error(f"Batch {batch_id + 1}/{total_batches} failed: {e}", exc_info=True) + batch_state.status = BatchStatus.FAILED + batch_state.error_message = str(e) + batch_state.completed_at = datetime.now(UTC) + batches_failed += 1 + + # Notify progress callback with final state + if progress_callback: + progress_callback(batch_state) + + # Auto-tune workers between batches + if self.tuner: + # This would be used by the caller to adjust ThreadPoolExecutor + new_workers = self.tuner.adjust_if_needed(self.config.max_workers) + if new_workers != self.config.max_workers: + logger.info(f"Auto-tuner recommends {new_workers} workers") + + # Calculate final stats + duration = time.time() - start_time + + summary = { + "success": batches_failed == 0, + "total_processed": total_processed, + "total_uploaded": total_uploaded, + "total_failed": total_failed, + "total_bytes": total_bytes, + "batches_completed": batches_completed, + "batches_failed": batches_failed, + "total_batches": total_batches, + "duration_seconds": duration, + } + + logger.info( + f"Batch processing complete: {batches_completed}/{total_batches} batches succeeded, " + f"{total_uploaded} items uploaded, {total_failed} failed, " + f"{duration:.1f}s elapsed" + ) + + return summary + + def get_tuner_metrics(self) -> dict[str, Any]: + """Get current auto-tuner metrics.""" + if self.tuner: + return self.tuner.get_metrics() + return {"enabled": False} diff --git a/app/services/cache_service.py b/app/services/cache_service.py index e925709..4a7dfcf 100644 --- a/app/services/cache_service.py +++ b/app/services/cache_service.py @@ -240,6 +240,47 @@ def bulk_update_cache( conn.commit() + def get_uploaded_file_info( + self, + bucket: str, + filename: str, + file_size: int, + ) -> dict[str, str | int] | None: + """Look up a cached upload entry by filename and size. + + Returns the S3 path and file metadata for files where file_exists=1. + Used by the delete feature to cross-reference local files with S3 uploads. + + Args: + bucket: S3 bucket name + filename: Original filename + file_size: File size in bytes + + Returns: + Dict with s3_path, filename, file_size if found, else None + """ + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute( + """ + SELECT s3_path, filename, file_size FROM s3_files + WHERE bucket = ? AND filename = ? AND file_size = ? AND file_exists = 1 + LIMIT 1 + """, + (bucket, filename, file_size), + ) + + row = cursor.fetchone() + if row is None: + return None + + return { + "s3_path": row["s3_path"], + "filename": row["filename"], + "file_size": row["file_size"], + } + def invalidate_bucket(self, bucket: str) -> int: """Invalidate all cache entries for a bucket. diff --git a/app/services/delete_manager.py b/app/services/delete_manager.py new file mode 100644 index 0000000..cbb8680 --- /dev/null +++ b/app/services/delete_manager.py @@ -0,0 +1,694 @@ +"""Delete manager service for local file cleanup after S3 upload.""" + +import hashlib +import os +import threading +import uuid +from collections.abc import Callable +from concurrent.futures import ThreadPoolExecutor +from dataclasses import dataclass, field +from datetime import UTC, datetime +from enum import Enum +from pathlib import Path +from typing import Any + +from app.services.cache_service import get_cache_service +from app.services.log_service import get_log_service +from app.services.s3_service import create_s3_client, get_object_metadata + + +class DeleteStatus(Enum): + """Status for individual files in a delete job.""" + + PENDING = "pending" + SCANNING = "scanning" + VERIFYING = "verifying" + VERIFIED = "verified" + DELETING = "deleting" + DELETED = "deleted" + MISMATCH = "mismatch" + FAILED = "failed" + CANCELLED = "cancelled" + + +@dataclass +class FileDeleteState: + """State for a single file in a delete job.""" + + filename: str + local_path: str + file_size: int + s3_path: str + s3_bucket: str + writable: bool = True + status: DeleteStatus = DeleteStatus.PENDING + local_md5: str = "" + s3_etag: str = "" + s3_size: int = 0 + verification: str = "" + error_message: str = "" + + def to_dict(self) -> dict[str, Any]: + """Serialize to dictionary for API responses.""" + return { + "filename": self.filename, + "local_path": self.local_path, + "file_size": self.file_size, + "s3_path": self.s3_path, + "s3_bucket": self.s3_bucket, + "writable": self.writable, + "status": self.status.value, + "local_md5": self.local_md5, + "s3_etag": self.s3_etag, + "s3_size": self.s3_size, + "verification": self.verification, + "error_message": self.error_message, + } + + +@dataclass +class DeleteJob: + """A delete job tracking multiple files.""" + + job_id: str + files: list[FileDeleteState] = field(default_factory=list) + status: str = "pending" + created_at: str = field(default_factory=lambda: datetime.now(UTC).isoformat()) + started_at: str | None = None + completed_at: str | None = None + cancelled: bool = False + lock: threading.Lock = field(default_factory=threading.Lock) + + def to_dict(self) -> dict[str, Any]: + """Serialize to dictionary for API responses.""" + with self.lock: + status_counts: dict[str, int] = {} + total_deleted_size = 0 + for f in self.files: + status_counts[f.status.value] = status_counts.get(f.status.value, 0) + 1 + if f.status == DeleteStatus.DELETED: + total_deleted_size += f.file_size + + return { + "job_id": self.job_id, + "status": self.status, + "total_files": len(self.files), + "files": [f.to_dict() for f in self.files], + "status_counts": status_counts, + "total_deleted_size": total_deleted_size, + "created_at": self.created_at, + "started_at": self.started_at, + "completed_at": self.completed_at, + "cancelled": self.cancelled, + } + + def to_progress_dict(self) -> dict[str, Any]: + """Lightweight progress for SSE streaming.""" + with self.lock: + status_counts: dict[str, int] = {} + total_deleted_size = 0 + for f in self.files: + status_counts[f.status.value] = status_counts.get(f.status.value, 0) + 1 + if f.status == DeleteStatus.DELETED: + total_deleted_size += f.file_size + + files_processed = sum( + 1 + for f in self.files + if f.status + not in (DeleteStatus.PENDING, DeleteStatus.VERIFYING, DeleteStatus.DELETING) + ) + + return { + "job_id": self.job_id, + "status": self.status, + "total_files": len(self.files), + "files_processed": files_processed, + "status_counts": status_counts, + "total_deleted_size": total_deleted_size, + "cancelled": self.cancelled, + } + + +def compute_md5(file_path: str, chunk_size: int = 8 * 1024 * 1024) -> str: + """Compute MD5 hash of a file in chunks. + + Args: + file_path: Path to the file + chunk_size: Read chunk size in bytes (default 8MB) + + Returns: + Hex-encoded MD5 hash string + """ + md5 = hashlib.md5() # noqa: S324 + with open(file_path, "rb") as f: + while True: + chunk = f.read(chunk_size) + if not chunk: + break + md5.update(chunk) + return md5.hexdigest() + + +def is_multipart_etag(etag: str) -> bool: + """Check if an S3 ETag indicates a multipart upload. + + Multipart ETags contain a hyphen followed by the number of parts. + + Args: + etag: S3 ETag string (already stripped of quotes) + + Returns: + True if this is a multipart ETag + """ + return "-" in etag + + +class DeleteManager: + """Manages local file deletion jobs with MD5 verification against S3.""" + + def __init__(self, batch_config: dict[str, Any] | None = None) -> None: + self.jobs: dict[str, DeleteJob] = {} + + # Load batch processing configuration + if batch_config is None: + from app.config import get_settings + + settings = get_settings() + batch_config = settings.get_batch_config() + + # Import BatchConfig and BatchProcessor + from app.services.batch_processor import BatchConfig, BatchProcessor + + self.batch_config = BatchConfig.from_dict(batch_config) + self.batch_processor = ( + BatchProcessor(self.batch_config) if self.batch_config.enabled else None + ) + + def scan_folder( + self, + folder_path: str, + bucket: str, + excluded_subfolders: list[str] | None = None, + excluded_files: list[str] | None = None, + ) -> DeleteJob: + """Scan a folder for .mcap files and cross-reference with upload cache. + + Args: + folder_path: Local directory to scan + bucket: S3 bucket to check against + excluded_subfolders: Subfolder names to skip + excluded_files: Root-level filenames to skip + + Returns: + A new DeleteJob with files matched against the cache + """ + job_id = str(uuid.uuid4()) + job = DeleteJob(job_id=job_id) + cache = get_cache_service() + folder = Path(folder_path) + excluded_subs_set = set(excluded_subfolders or []) + excluded_files_set = set(excluded_files or []) + + for mcap_path in sorted(folder.rglob("*.mcap")): + if not mcap_path.is_file(): + continue + + rel = mcap_path.relative_to(folder) + parts = rel.parts + + # Skip root-level files that are excluded + if len(parts) == 1 and parts[0] in excluded_files_set: + continue + + # Skip files under excluded subfolders + if len(parts) > 1 and parts[0] in excluded_subs_set: + continue + + stat = mcap_path.stat() + file_size = stat.st_size + filename = mcap_path.name + + # Look up in cache + cache_info = cache.get_uploaded_file_info(bucket, filename, file_size) + + if cache_info is not None: + file_state = FileDeleteState( + filename=filename, + local_path=str(mcap_path.absolute()), + file_size=file_size, + s3_path=str(cache_info["s3_path"]), + s3_bucket=bucket, + writable=os.access(str(mcap_path), os.W_OK), + ) + job.files.append(file_state) + + self.jobs[job_id] = job + return job + + def start_delete_job( + self, + job_id: str, + aws_profile: str, + aws_region: str, + progress_callback: Callable[[DeleteJob], None] | None = None, + batch_callback: Callable[[dict[str, Any]], None] | None = None, + ) -> bool: + """Start verification and deletion for a job. + + Phase 1: Compute local MD5 hashes (parallel) + Phase 2: Verify against S3 via HEAD — size match (primary) + MD5 (secondary) + Phase 3: Delete verified files (sequential) + + Args: + job_id: The job to start + aws_profile: AWS profile name + aws_region: AWS region + progress_callback: Called after each file status change + batch_callback: Called for batch-level events (batch_started, batch_completed) + + Returns: + True if job was started + """ + job = self.jobs.get(job_id) + if not job: + return False + + with job.lock: + job.status = "verifying" + job.started_at = datetime.now(UTC).isoformat() + + log = get_log_service() + log.info( + "delete", + "job_started", + f"Delete job started: {len(job.files)} files", + {"job_id": job_id, "total_files": len(job.files)}, + ) + + # Phase 1: Compute local MD5 hashes + def compute_file_md5(file_state: FileDeleteState) -> None: + if job.cancelled: + return + with job.lock: + file_state.status = DeleteStatus.VERIFYING + if progress_callback: + progress_callback(job) + + try: + file_state.local_md5 = compute_md5(file_state.local_path) + except Exception as e: + with job.lock: + file_state.status = DeleteStatus.FAILED + file_state.error_message = f"MD5 computation failed: {e}" + log.error( + "delete", + "md5_failed", + f"MD5 failed for {file_state.filename}: {e}", + {"file": file_state.filename, "error": str(e)}, + ) + + with ThreadPoolExecutor(max_workers=4) as executor: + executor.map(compute_file_md5, job.files) + + if job.cancelled: + self._finalize_cancelled(job, progress_callback) + return True + + # Phase 2: Fetch S3 ETags and compare + try: + s3_client = create_s3_client(aws_profile, aws_region) + except Exception as e: + with job.lock: + job.status = "failed" + job.completed_at = datetime.now(UTC).isoformat() + log.error( + "delete", + "s3_client_failed", + f"Failed to create S3 client: {e}", + {"error": str(e)}, + ) + if progress_callback: + progress_callback(job) + return True + + def verify_against_s3(file_state: FileDeleteState) -> None: + """Verify a file against S3 using HEAD + size (primary) and MD5 (secondary). + + Verification levels: + - "md5+size": S3 exists, size matches, MD5 matches ETag (single-part) + - "size": S3 exists, size matches, multipart ETag (MD5 not comparable) + """ + if job.cancelled: + return + if file_state.status == DeleteStatus.FAILED: + return + + try: + metadata = get_object_metadata(s3_client, file_state.s3_bucket, file_state.s3_path) + if not metadata["success"]: + with job.lock: + file_state.status = DeleteStatus.FAILED + file_state.error_message = ( + f"S3 object not found: {metadata.get('error', 'unknown')}" + ) + return + + s3_size = int(metadata["size"]) + etag = str(metadata["etag"]) + file_state.s3_etag = etag + file_state.s3_size = s3_size + + # Primary check: file size must match + if s3_size != file_state.file_size: + with job.lock: + file_state.status = DeleteStatus.MISMATCH + file_state.error_message = ( + f"Size mismatch: local={file_state.file_size}, s3={s3_size}" + ) + return + + # Secondary check: MD5 vs ETag (only possible for single-part uploads) + if is_multipart_etag(etag): + # Multipart ETag — can't compare MD5, but size is confirmed + with job.lock: + file_state.status = DeleteStatus.VERIFIED + file_state.verification = "size" + else: + # Single-part ETag — compare MD5 + if etag == file_state.local_md5: + with job.lock: + file_state.status = DeleteStatus.VERIFIED + file_state.verification = "md5+size" + else: + with job.lock: + file_state.status = DeleteStatus.MISMATCH + file_state.error_message = ( + f"MD5 mismatch: local={file_state.local_md5}, s3={etag}" + ) + except Exception as e: + with job.lock: + file_state.status = DeleteStatus.FAILED + file_state.error_message = f"S3 verification failed: {e}" + + if progress_callback: + progress_callback(job) + + # Phase 2: Verify files against S3 + # Use batch processing for large jobs to improve performance and UI responsiveness + use_batch_processing = ( + self.batch_processor is not None + and self.batch_processor.should_use_batch_processing(len(job.files)) + ) + + if use_batch_processing and self.batch_processor: + log.info( + "delete", + "using_batch_processing", + f"Using batch processing for {len(job.files)} files", + {"job_id": job_id, "total_files": len(job.files)}, + ) + + # Process verification in batches + def check_cancelled() -> bool: + return job.cancelled + + # Create a wrapper that uses _verify_batch + def process_batch_fn( + batch_files: list[FileDeleteState], batch_id: int, total_batches: int + ) -> dict[str, Any]: + return self._verify_batch( + batch_files, + batch_id, + total_batches, + job, + s3_client, + progress_callback, + batch_callback, + ) + + # Use batch processor + self.batch_processor.process_batches( + job.files, + process_batch_fn, + progress_callback=None, # We handle progress in _verify_batch + check_cancelled=check_cancelled, + ) + else: + # Traditional non-batch processing + with ThreadPoolExecutor(max_workers=4) as executor: + executor.map(verify_against_s3, job.files) + + if job.cancelled: + self._finalize_cancelled(job, progress_callback) + return True + + # Phase 3: Delete verified files (sequential) + with job.lock: + job.status = "deleting" + if progress_callback: + progress_callback(job) + + for file_state in job.files: + if job.cancelled: + self._finalize_cancelled(job, progress_callback) + return True + + if file_state.status != DeleteStatus.VERIFIED: + continue + + with job.lock: + file_state.status = DeleteStatus.DELETING + if progress_callback: + progress_callback(job) + + try: + os.unlink(file_state.local_path) + with job.lock: + file_state.status = DeleteStatus.DELETED + log.info( + "delete", + "file_deleted", + f"Deleted {file_state.filename}", + { + "file": file_state.filename, + "local_path": file_state.local_path, + "s3_path": file_state.s3_path, + "size": file_state.file_size, + }, + ) + except Exception as e: + with job.lock: + file_state.status = DeleteStatus.FAILED + file_state.error_message = f"Delete failed: {e}" + log.error( + "delete", + "delete_failed", + f"Failed to delete {file_state.filename}: {e}", + {"file": file_state.filename, "error": str(e)}, + ) + + if progress_callback: + progress_callback(job) + + # Finalize + with job.lock: + job.status = "completed" + job.completed_at = datetime.now(UTC).isoformat() + + log.info( + "delete", + "job_completed", + f"Delete job completed: {job.to_progress_dict()['status_counts']}", + {"job_id": job_id, **job.to_progress_dict()["status_counts"]}, + ) + + if progress_callback: + progress_callback(job) + + return True + + def _verify_batch( + self, + batch_files: list[FileDeleteState], + batch_id: int, + total_batches: int, + job: DeleteJob, + s3_client: Any, + progress_callback: Callable[[DeleteJob], None] | None, + batch_callback: Callable[[dict[str, Any]], None] | None, + ) -> dict[str, Any]: + """Verify a batch of files against S3. + + Args: + batch_files: Files to verify in this batch + batch_id: 0-indexed batch number + total_batches: Total number of batches + job: The delete job + s3_client: S3 client for HEAD requests + progress_callback: Called after each file verification + batch_callback: Called for batch-level events + + Returns: + Dict with batch statistics + """ + # Send batch_started event + if batch_callback: + batch_callback( + { + "type": "batch_started", + "batch_id": batch_id, + "total_batches": total_batches, + "files_in_batch": len(batch_files), + } + ) + + verified = 0 + failed = 0 + + def verify_against_s3(file_state: FileDeleteState) -> None: + """Verify a file against S3 using HEAD + size (primary) and MD5 (secondary).""" + nonlocal verified, failed + + if job.cancelled: + return + if file_state.status == DeleteStatus.FAILED: + failed += 1 + return + + try: + metadata = get_object_metadata(s3_client, file_state.s3_bucket, file_state.s3_path) + if not metadata["success"]: + with job.lock: + file_state.status = DeleteStatus.FAILED + file_state.error_message = ( + f"S3 object not found: {metadata.get('error', 'unknown')}" + ) + failed += 1 + return + + s3_size = int(metadata["size"]) + etag = str(metadata["etag"]) + file_state.s3_etag = etag + file_state.s3_size = s3_size + + # Primary check: file size must match + if s3_size != file_state.file_size: + with job.lock: + file_state.status = DeleteStatus.MISMATCH + file_state.error_message = ( + f"Size mismatch: local={file_state.file_size}, s3={s3_size}" + ) + failed += 1 + return + + # Secondary check: MD5 vs ETag (only possible for single-part uploads) + if is_multipart_etag(etag): + with job.lock: + file_state.status = DeleteStatus.VERIFIED + file_state.verification = "size" + verified += 1 + else: + if etag == file_state.local_md5: + with job.lock: + file_state.status = DeleteStatus.VERIFIED + file_state.verification = "md5+size" + verified += 1 + else: + with job.lock: + file_state.status = DeleteStatus.MISMATCH + file_state.error_message = ( + f"MD5 mismatch: local={file_state.local_md5}, s3={etag}" + ) + failed += 1 + except Exception as e: + with job.lock: + file_state.status = DeleteStatus.FAILED + file_state.error_message = f"S3 verification failed: {e}" + failed += 1 + + if progress_callback: + progress_callback(job) + + # Process batch with ThreadPoolExecutor + max_workers = self.batch_config.max_workers if self.batch_processor else 4 + with ThreadPoolExecutor(max_workers=max_workers) as executor: + executor.map(verify_against_s3, batch_files) + + # Send batch_completed event + if batch_callback: + batch_callback( + { + "type": "batch_completed", + "batch_id": batch_id, + "files_verified": verified, + "files_failed": failed, + } + ) + + return { + "success": not job.cancelled, + "processed": len(batch_files), + "uploaded": verified, # "uploaded" maps to "verified" for delete operations + "failed": failed, + "bytes_uploaded": 0, # Not applicable for delete + } + + def _finalize_cancelled( + self, + job: DeleteJob, + progress_callback: Callable[[DeleteJob], None] | None, + ) -> None: + """Mark remaining pending/verifying files as cancelled and finalize.""" + with job.lock: + for f in job.files: + if f.status in ( + DeleteStatus.PENDING, + DeleteStatus.VERIFYING, + DeleteStatus.VERIFIED, + ): + f.status = DeleteStatus.CANCELLED + job.status = "cancelled" + job.completed_at = datetime.now(UTC).isoformat() + + log = get_log_service() + log.info( + "delete", + "job_cancelled", + "Delete job cancelled", + {"job_id": job.job_id}, + ) + + if progress_callback: + progress_callback(job) + + def cancel_job(self, job_id: str) -> bool: + """Cancel a delete job. + + Args: + job_id: The job to cancel + + Returns: + True if job was found and cancelled + """ + job = self.jobs.get(job_id) + if not job: + return False + job.cancelled = True + return True + + def get_job(self, job_id: str) -> DeleteJob | None: + """Retrieve a delete job by ID.""" + return self.jobs.get(job_id) + + +# Global singleton +_delete_manager: DeleteManager | None = None + + +def get_delete_manager() -> DeleteManager: + """Get the global delete manager instance.""" + global _delete_manager + if _delete_manager is None: + _delete_manager = DeleteManager() + return _delete_manager diff --git a/app/services/job_storage.py b/app/services/job_storage.py new file mode 100644 index 0000000..4d3b7b3 --- /dev/null +++ b/app/services/job_storage.py @@ -0,0 +1,455 @@ +"""Database-backed storage for large upload/delete jobs.""" + +import json +import logging +import sqlite3 +import threading +from datetime import UTC, datetime, timedelta +from typing import Any + +from app.config import BASE_DIR + +logger = logging.getLogger(__name__) + +# Database file location +DB_FILE = BASE_DIR / "upload_jobs.db" + +# SQL schema +SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS upload_jobs ( + job_id TEXT PRIMARY KEY, + job_type TEXT NOT NULL, + status TEXT NOT NULL, + total_files INTEGER NOT NULL, + files_processed INTEGER DEFAULT 0, + files_uploaded INTEGER DEFAULT 0, + files_failed INTEGER DEFAULT 0, + total_bytes INTEGER DEFAULT 0, + created_at TEXT NOT NULL, + started_at TEXT, + completed_at TEXT, + metadata TEXT +); + +CREATE TABLE IF NOT EXISTS upload_files ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + job_id TEXT NOT NULL, + filename TEXT NOT NULL, + local_path TEXT NOT NULL, + file_size INTEGER NOT NULL, + status TEXT NOT NULL, + s3_path TEXT, + start_time TEXT, + bytes_uploaded INTEGER DEFAULT 0, + error_message TEXT, + is_duplicate INTEGER DEFAULT 0, + is_valid INTEGER DEFAULT 1, + upload_started_at TEXT, + upload_completed_at TEXT, + FOREIGN KEY (job_id) REFERENCES upload_jobs(job_id) +); + +CREATE INDEX IF NOT EXISTS idx_upload_files_job_id ON upload_files(job_id); +CREATE INDEX IF NOT EXISTS idx_upload_files_status ON upload_files(status); +CREATE INDEX IF NOT EXISTS idx_upload_jobs_created_at ON upload_jobs(created_at); +""" + + +class JobStorage: + """SQLite-backed storage for upload/delete jobs.""" + + _instance: "JobStorage | None" = None + _lock = threading.Lock() + + def __new__(cls) -> "JobStorage": + """Singleton pattern to ensure only one storage instance.""" + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialize_db() + return cls._instance + + def _initialize_db(self) -> None: + """Initialize the database and create tables if needed.""" + try: + conn = sqlite3.connect(str(DB_FILE), check_same_thread=False) + conn.executescript(SCHEMA_SQL) + conn.commit() + conn.close() + logger.info(f"Job storage database initialized at {DB_FILE}") + except Exception as e: + logger.error(f"Failed to initialize job storage database: {e}", exc_info=True) + raise + + def _get_connection(self) -> sqlite3.Connection: + """Get a database connection.""" + conn = sqlite3.connect(str(DB_FILE), check_same_thread=False) + conn.row_factory = sqlite3.Row + return conn + + def save_job( + self, + job_id: str, + job_type: str, + total_files: int, + file_states: list[dict[str, Any]], + metadata: dict[str, Any] | None = None, + ) -> None: + """Save a new job to the database. + + Args: + job_id: Unique job identifier + job_type: Type of job ('upload' or 'delete') + total_files: Total number of files in job + file_states: List of file state dictionaries + metadata: Optional job metadata + """ + try: + conn = self._get_connection() + cursor = conn.cursor() + + # Insert job record + cursor.execute( + """ + INSERT INTO upload_jobs ( + job_id, job_type, status, total_files, created_at, metadata + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + ( + job_id, + job_type, + "pending", + total_files, + datetime.now(UTC).isoformat(), + json.dumps(metadata) if metadata else None, + ), + ) + + # Insert file records + for file_state in file_states: + cursor.execute( + """ + INSERT INTO upload_files ( + job_id, filename, local_path, file_size, status, + s3_path, start_time, is_duplicate, is_valid + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + job_id, + file_state.get("filename", ""), + file_state.get("local_path", ""), + file_state.get("file_size", 0), + file_state.get("status", "pending"), + file_state.get("s3_path", ""), + file_state.get("start_time"), + 1 if file_state.get("is_duplicate", False) else 0, + 1 if file_state.get("is_valid", True) else 0, + ), + ) + + conn.commit() + conn.close() + logger.info(f"Saved job {job_id} with {len(file_states)} files to database") + + except Exception as e: + logger.error(f"Failed to save job {job_id}: {e}", exc_info=True) + raise + + def update_job_status( + self, + job_id: str, + status: str, + files_processed: int | None = None, + files_uploaded: int | None = None, + files_failed: int | None = None, + total_bytes: int | None = None, + started_at: datetime | None = None, + completed_at: datetime | None = None, + ) -> None: + """Update job status and statistics. + + Args: + job_id: Job identifier + status: New job status + files_processed: Number of files processed (optional) + files_uploaded: Number of files uploaded (optional) + files_failed: Number of files failed (optional) + total_bytes: Total bytes uploaded (optional) + started_at: Job start time (optional) + completed_at: Job completion time (optional) + """ + try: + conn = self._get_connection() + cursor = conn.cursor() + + # Build dynamic UPDATE query + updates = ["status = ?"] + params: list[Any] = [status] + + if files_processed is not None: + updates.append("files_processed = ?") + params.append(files_processed) + + if files_uploaded is not None: + updates.append("files_uploaded = ?") + params.append(files_uploaded) + + if files_failed is not None: + updates.append("files_failed = ?") + params.append(files_failed) + + if total_bytes is not None: + updates.append("total_bytes = ?") + params.append(total_bytes) + + if started_at is not None: + updates.append("started_at = ?") + params.append(started_at.isoformat()) + + if completed_at is not None: + updates.append("completed_at = ?") + params.append(completed_at.isoformat()) + + params.append(job_id) + + query = f"UPDATE upload_jobs SET {', '.join(updates)} WHERE job_id = ?" + cursor.execute(query, params) + + conn.commit() + conn.close() + + except Exception as e: + logger.error(f"Failed to update job {job_id}: {e}", exc_info=True) + raise + + def update_file_status( + self, + job_id: str, + filename: str, + status: str, + bytes_uploaded: int | None = None, + error_message: str | None = None, + upload_started_at: datetime | None = None, + upload_completed_at: datetime | None = None, + ) -> None: + """Update status of a specific file in a job. + + Args: + job_id: Job identifier + filename: Filename to update + status: New file status + bytes_uploaded: Bytes uploaded (optional) + error_message: Error message (optional) + upload_started_at: Upload start time (optional) + upload_completed_at: Upload completion time (optional) + """ + try: + conn = self._get_connection() + cursor = conn.cursor() + + # Build dynamic UPDATE query + updates = ["status = ?"] + params: list[Any] = [status] + + if bytes_uploaded is not None: + updates.append("bytes_uploaded = ?") + params.append(bytes_uploaded) + + if error_message is not None: + updates.append("error_message = ?") + params.append(error_message) + + if upload_started_at is not None: + updates.append("upload_started_at = ?") + params.append(upload_started_at.isoformat()) + + if upload_completed_at is not None: + updates.append("upload_completed_at = ?") + params.append(upload_completed_at.isoformat()) + + params.extend([job_id, filename]) + + query = ( + f"UPDATE upload_files SET {', '.join(updates)} WHERE job_id = ? AND filename = ?" + ) + cursor.execute(query, params) + + conn.commit() + conn.close() + + except Exception as e: + logger.error(f"Failed to update file {filename} in job {job_id}: {e}", exc_info=True) + + def get_job(self, job_id: str) -> dict[str, Any] | None: + """Get job metadata by ID. + + Args: + job_id: Job identifier + + Returns: + Job metadata dict or None if not found + """ + try: + conn = self._get_connection() + cursor = conn.cursor() + + cursor.execute( + """ + SELECT job_id, job_type, status, total_files, + files_processed, files_uploaded, files_failed, total_bytes, + created_at, started_at, completed_at, metadata + FROM upload_jobs + WHERE job_id = ? + """, + (job_id,), + ) + + row = cursor.fetchone() + conn.close() + + if row is None: + return None + + return { + "job_id": row["job_id"], + "job_type": row["job_type"], + "status": row["status"], + "total_files": row["total_files"], + "files_processed": row["files_processed"], + "files_uploaded": row["files_uploaded"], + "files_failed": row["files_failed"], + "total_bytes": row["total_bytes"], + "created_at": row["created_at"], + "started_at": row["started_at"], + "completed_at": row["completed_at"], + "metadata": json.loads(row["metadata"]) if row["metadata"] else {}, + } + + except Exception as e: + logger.error(f"Failed to get job {job_id}: {e}", exc_info=True) + return None + + def get_job_results(self, job_id: str, page: int = 1, per_page: int = 100) -> dict[str, Any]: + """Get paginated file results for a job. + + Args: + job_id: Job identifier + page: Page number (1-indexed) + per_page: Results per page (default 100) + + Returns: + Dict with 'files' list and pagination info + """ + try: + conn = self._get_connection() + cursor = conn.cursor() + + # Get total count + cursor.execute("SELECT COUNT(*) as count FROM upload_files WHERE job_id = ?", (job_id,)) + total_files = cursor.fetchone()["count"] + + # Get paginated files + offset = (page - 1) * per_page + cursor.execute( + """ + SELECT filename, local_path, file_size, status, s3_path, + start_time, bytes_uploaded, error_message, + is_duplicate, is_valid, upload_started_at, upload_completed_at + FROM upload_files + WHERE job_id = ? + ORDER BY id + LIMIT ? OFFSET ? + """, + (job_id, per_page, offset), + ) + + files = [] + for row in cursor.fetchall(): + files.append( + { + "filename": row["filename"], + "local_path": row["local_path"], + "file_size": row["file_size"], + "status": row["status"], + "s3_path": row["s3_path"], + "start_time": row["start_time"], + "bytes_uploaded": row["bytes_uploaded"], + "error_message": row["error_message"], + "is_duplicate": bool(row["is_duplicate"]), + "is_valid": bool(row["is_valid"]), + "upload_started_at": row["upload_started_at"], + "upload_completed_at": row["upload_completed_at"], + } + ) + + conn.close() + + total_pages = (total_files + per_page - 1) // per_page + + return { + "job_id": job_id, + "files": files, + "pagination": { + "page": page, + "per_page": per_page, + "total_files": total_files, + "total_pages": total_pages, + "has_next": page < total_pages, + "has_prev": page > 1, + }, + } + + except Exception as e: + logger.error(f"Failed to get results for job {job_id}: {e}", exc_info=True) + return {"job_id": job_id, "files": [], "pagination": {}} + + def cleanup_old_jobs(self, days: int = 7) -> int: + """Delete jobs older than specified days. + + Args: + days: Age threshold in days + + Returns: + Number of jobs deleted + """ + try: + cutoff_date = datetime.now(UTC) - timedelta(days=days) + conn = self._get_connection() + cursor = conn.cursor() + + # Delete old files first (foreign key constraint) + cursor.execute( + """ + DELETE FROM upload_files + WHERE job_id IN ( + SELECT job_id FROM upload_jobs + WHERE created_at < ? + ) + """, + (cutoff_date.isoformat(),), + ) + + # Delete old jobs + cursor.execute( + "DELETE FROM upload_jobs WHERE created_at < ?", + (cutoff_date.isoformat(),), + ) + + deleted_count = cursor.rowcount + conn.commit() + conn.close() + + logger.info(f"Cleaned up {deleted_count} jobs older than {days} days") + return deleted_count + + except Exception as e: + logger.error(f"Failed to cleanup old jobs: {e}", exc_info=True) + return 0 + + +def get_job_storage() -> JobStorage: + """Get the singleton JobStorage instance.""" + return JobStorage() diff --git a/app/services/log_service.py b/app/services/log_service.py index 2d06715..854be2c 100644 --- a/app/services/log_service.py +++ b/app/services/log_service.py @@ -42,11 +42,7 @@ def _get_hive_dir(self, subdir: str, dt: datetime) -> Path: """ log_dir = self._get_log_dir() hive_dir = ( - log_dir - / subdir - / f"year={dt.year:04d}" - / f"month={dt.month:02d}" - / f"day={dt.day:02d}" + log_dir / subdir / f"year={dt.year:04d}" / f"month={dt.month:02d}" / f"day={dt.day:02d}" ) hive_dir.mkdir(parents=True, exist_ok=True) return hive_dir @@ -211,22 +207,24 @@ def save_job_csv( if duration and duration > 0 else None ) - writer.writerow([ - job_id, - f.filename, - f.file_size, - format_file_size(f.file_size), - f.s3_path, - f.status.value, - f.start_time.isoformat() if f.start_time else "", - f.upload_started_at.isoformat() if f.upload_started_at else "", - f.upload_completed_at.isoformat() if f.upload_completed_at else "", - f.upload_duration_seconds, - speed, - f.is_duplicate, - f.is_valid, - f.error_message, - ]) + writer.writerow( + [ + job_id, + f.filename, + f.file_size, + format_file_size(f.file_size), + f.s3_path, + f.status.value, + f.start_time.isoformat() if f.start_time else "", + f.upload_started_at.isoformat() if f.upload_started_at else "", + f.upload_completed_at.isoformat() if f.upload_completed_at else "", + f.upload_duration_seconds, + speed, + f.is_duplicate, + f.is_valid, + f.error_message, + ] + ) with self._write_lock: with open(out_path, "w", encoding="utf-8", newline="") as fh: @@ -251,28 +249,32 @@ def list_log_files(self) -> list[dict[str, Any]]: for f in sorted(json_dir.rglob("*.jsonl"), reverse=True): date_str = self._extract_date_from_hive_path(f) rel_path = f.relative_to(log_dir) - result.append({ - "date": date_str, - "filename": f.name, - "path": str(f), - "relative_path": str(rel_path), - "size_bytes": f.stat().st_size, - "type": "jsonl", - }) + result.append( + { + "date": date_str, + "filename": f.name, + "path": str(f), + "relative_path": str(rel_path), + "size_bytes": f.stat().st_size, + "type": "jsonl", + } + ) # Collect CSV files under csv/ if csv_dir.exists(): for f in sorted(csv_dir.rglob("*.csv"), reverse=True): date_str = self._extract_date_from_hive_path(f) rel_path = f.relative_to(log_dir) - result.append({ - "date": date_str, - "filename": f.name, - "path": str(f), - "relative_path": str(rel_path), - "size_bytes": f.stat().st_size, - "type": "csv", - }) + result.append( + { + "date": date_str, + "filename": f.name, + "path": str(f), + "relative_path": str(rel_path), + "size_bytes": f.stat().st_size, + "type": "csv", + } + ) return result @@ -417,8 +419,20 @@ def get_log_stats(self) -> dict[str, Any]: dates.sort() - # Count CSV files - csv_count = len(list(csv_dir.rglob("*.csv"))) if csv_dir.exists() else 0 + # Collect CSV file details + csv_files: list[dict[str, Any]] = [] + if csv_dir.exists(): + for csv_file in sorted(csv_dir.rglob("*.csv"), reverse=True): + date_str = self._extract_date_from_hive_path(csv_file) or "" + rel_path = str(csv_file.relative_to(log_dir)) + csv_files.append( + { + "path": rel_path, + "filename": csv_file.name, + "date": date_str, + "size": csv_file.stat().st_size, + } + ) return { "total_entries": total_entries, @@ -431,7 +445,123 @@ def get_log_stats(self) -> dict[str, Any]: "latest": dates[-1] if dates else None, }, "file_count": len(event_files), - "csv_count": csv_count, + "csv_count": len(csv_files), + "csv_files": csv_files, + } + + def get_upload_stats(self) -> dict[str, Any]: + """Parse all CSV upload summary files and return aggregated stats. + + Returns: + Dict with global totals and per-session detail including file rows. + """ + from app.services.utils import format_file_size + + log_dir = self._get_log_dir() + csv_dir = log_dir / "csv" + + total_uploaded = 0 + total_failed = 0 + total_skipped = 0 + total_bytes = 0 + sessions: list[dict[str, Any]] = [] + + if not csv_dir.exists(): + return { + "total_files_uploaded": 0, + "total_files_failed": 0, + "total_files_skipped": 0, + "total_bytes_uploaded": 0, + "total_sessions": 0, + "sessions": [], + } + + for csv_file in sorted(csv_dir.rglob("*.csv"), reverse=True): + rel_path = str(csv_file.relative_to(log_dir)) + date_str = self._extract_date_from_hive_path(csv_file) or "" + + # Extract time from filename: upload-summary-HHMMSS-shortid.csv + fname = csv_file.stem # e.g. upload-summary-143022-abcd1234 + parts = fname.split("-") + time_str = "" + if len(parts) >= 3: + raw_time = parts[2] # "143022" + if len(raw_time) == 6 and raw_time.isdigit(): + time_str = f"{raw_time[:2]}:{raw_time[2:4]}:{raw_time[4:6]}" + + session_files: list[dict[str, Any]] = [] + session_completed = 0 + session_failed = 0 + session_skipped = 0 + session_bytes = 0 + session_duration = 0.0 + + try: + with open(csv_file, encoding="utf-8", newline="") as fh: + reader = csv.DictReader(fh) + for row in reader: + status = row.get("status", "") + size_bytes = int(row.get("file_size_bytes", "0") or "0") + duration = float(row.get("upload_duration_seconds", "0") or "0") + speed = row.get("upload_speed_mbps", "") + + if status == "completed": + session_completed += 1 + session_bytes += size_bytes + elif status == "failed": + session_failed += 1 + elif status == "skipped": + session_skipped += 1 + + session_duration += duration + + session_files.append( + { + "filename": row.get("filename", ""), + "file_size_formatted": row.get("file_size_formatted", ""), + "status": status, + "upload_speed_mbps": speed, + "s3_path": row.get("s3_path", ""), + "error_message": row.get("error_message", ""), + } + ) + except (OSError, csv.Error): + continue + + total_uploaded += session_completed + total_failed += session_failed + total_skipped += session_skipped + total_bytes += session_bytes + + avg_speed = 0.0 + if session_duration > 0 and session_bytes > 0: + avg_speed = round(session_bytes / session_duration / 1024 / 1024 * 8, 1) + + sessions.append( + { + "csv_path": rel_path, + "date": date_str, + "time": time_str, + "total_files": len(session_files), + "completed": session_completed, + "failed": session_failed, + "skipped": session_skipped, + "total_bytes": session_bytes, + "total_bytes_formatted": format_file_size(session_bytes), + "total_duration_seconds": round(session_duration, 1), + "avg_speed_mbps": avg_speed, + "files": session_files, + } + ) + + return { + "total_files_uploaded": total_uploaded, + "total_files_failed": total_failed, + "total_files_skipped": total_skipped, + "total_bytes_uploaded": total_bytes, + "total_bytes_uploaded_formatted": format_file_size(total_bytes), + "total_sessions": len(sessions), + "sessions": sessions, } def sync_logs_to_s3( diff --git a/app/services/mcap_service.py b/app/services/mcap_service.py index 705d7c6..40e622a 100644 --- a/app/services/mcap_service.py +++ b/app/services/mcap_service.py @@ -163,15 +163,50 @@ def _find_datetime_in_dataframes(dataframes: dict[str, pd.DataFrame]) -> datetim return earliest_time -def extract_start_time(file_path: Path | str) -> datetime: - """Extract the earliest timestamp from an MCAP file using modaq_toolkit. +def extract_start_time_fast(file_path: Path | str) -> datetime: + """Extract timestamp from filename only (skip MCAP parsing for speed). + + This is a fast path that extracts timestamps solely from the filename, + skipping the expensive MCAP parsing step. Use when you trust your + filenames are correctly formatted and want maximum performance. + + Performance: ~0.1ms vs ~200ms for full MCAP parsing (2000x speedup) + + Args: + file_path: Path to the MCAP file + + Returns: + datetime: The timestamp extracted from the filename + + Raises: + ValueError: If timestamp cannot be extracted from filename + FileNotFoundError: If the file does not exist + """ + path = Path(file_path) + if not path.exists(): + raise FileNotFoundError(f"MCAP file not found: {path}") + + timestamp = _extract_timestamp_from_filename(path.name) + if timestamp is None: + raise ValueError( + f"Cannot extract timestamp from filename: {path.name}. " + "Consider using extract_start_time() for full MCAP parsing." + ) + + return timestamp + + +def extract_start_time(file_path: Path | str, skip_validation: bool = False) -> datetime: + """Extract the earliest timestamp from an MCAP file. Tries multiple strategies: - 1. Parse MCAP file and look for datetime indices/columns - 2. Extract timestamp from filename if MCAP parsing fails or returns invalid dates + 1. If skip_validation=True: Extract from filename only (fast path) + 2. If skip_validation=False: Parse MCAP file and look for datetime indices/columns + 3. Fallback: Extract timestamp from filename if MCAP parsing fails Args: file_path: Path to the MCAP file + skip_validation: If True, skip MCAP parsing and extract from filename only Returns: datetime: The earliest timestamp found in the MCAP file @@ -180,6 +215,9 @@ def extract_start_time(file_path: Path | str) -> datetime: ValueError: If the file cannot be parsed or has no timestamps FileNotFoundError: If the file does not exist """ + # Fast path: skip MCAP validation + if skip_validation: + return extract_start_time_fast(file_path) from modaq_toolkit import MCAPParser path = Path(file_path) @@ -262,11 +300,14 @@ def generate_s3_path(start_time: datetime, filename: str) -> str: return path -def get_file_info(file_path: Path | str) -> dict[str, str | int | None]: +def get_file_info( + file_path: Path | str, skip_validation: bool = False +) -> dict[str, str | int | None]: """Get information about an MCAP file. Args: file_path: Path to the MCAP file + skip_validation: If True, skip MCAP parsing and extract from filename only Returns: Dictionary containing file information @@ -283,12 +324,10 @@ def get_file_info(file_path: Path | str) -> dict[str, str | int | None]: } try: - start_time = extract_start_time(path) + start_time = extract_start_time(path, skip_validation=skip_validation) info["start_time"] = start_time.isoformat() info["s3_path"] = generate_s3_path(start_time, path.name) except Exception as e: info["error"] = str(e) return info - - diff --git a/app/services/s3_service.py b/app/services/s3_service.py index f50740e..f1afa60 100644 --- a/app/services/s3_service.py +++ b/app/services/s3_service.py @@ -6,10 +6,33 @@ from typing import Any import boto3 +from boto3.s3.transfer import TransferConfig from botocore.exceptions import ClientError, NoCredentialsError from mypy_boto3_s3 import S3Client +class UploadCancelledError(Exception): + """Raised when an upload is cancelled mid-transfer.""" + + +# Multipart threshold: files below this size are uploaded as a single PUT request, +# which produces a simple MD5 ETag. Files above use multipart upload, which produces +# a composite ETag (md5_of_part_md5s-part_count) that can't be compared to a local MD5. +# +# The Local Delete feature relies on MD5 ETag comparison for integrity verification +# before deleting local files. Lowering this threshold means more files get multipart +# ETags and fall back to size-only verification (still safe, but less thorough). +# +# Guidelines: +# < 100 MB files → single-part fine, no multipart benefit +# 100 MB – 1 GB → single-part fine on stable connections +# 1 – 5 GB → multipart recommended (retry resilience) +# > 5 GB → multipart required (S3 hard limit) +# +# Our MCAP files are typically 50-100 MB, so 1 GB is very conservative. +TRANSFER_CONFIG = TransferConfig(multipart_threshold=1024 * 1024 * 1024) # 1 GB + + def get_available_profiles() -> list[str]: """Get list of available AWS profiles from ~/.aws/config and ~/.aws/credentials.""" profiles: set[str] = set() @@ -83,6 +106,7 @@ def upload_file_with_progress( bucket: str, key: str, callback: Callable[[int, int], None] | None = None, + cancel_check: Callable[[], bool] | None = None, ) -> dict[str, Any]: """Upload a file to S3 with progress tracking. @@ -92,9 +116,15 @@ def upload_file_with_progress( bucket: S3 bucket name key: S3 object key callback: Progress callback function (bytes_uploaded, total_bytes) + cancel_check: Callable that returns True if the upload should be cancelled. + Checked on every progress callback (each chunk). When True, raises + UploadCancelledError to abort the boto3 transfer immediately. Returns: Dictionary with upload result information + + Raises: + UploadCancelledError: If cancel_check returns True during upload """ file_path = Path(path) file_size = file_path.stat().st_size @@ -103,18 +133,24 @@ class ProgressCallback: """Callback class for tracking upload progress.""" def __init__( - self, total_size: int, user_callback: Callable[[int, int], None] | None + self, + total_size: int, + user_callback: Callable[[int, int], None] | None, + should_cancel: Callable[[], bool] | None, ) -> None: self.total_size = total_size self.uploaded = 0 self.user_callback = user_callback + self.should_cancel = should_cancel def __call__(self, bytes_amount: int) -> None: + if self.should_cancel and self.should_cancel(): + raise UploadCancelledError(f"Upload cancelled for {key}") self.uploaded += bytes_amount if self.user_callback: self.user_callback(self.uploaded, self.total_size) - progress = ProgressCallback(file_size, callback) + progress = ProgressCallback(file_size, callback, cancel_check) try: client.upload_file( @@ -122,6 +158,7 @@ def __call__(self, bytes_amount: int) -> None: Bucket=bucket, Key=key, Callback=progress, + Config=TRANSFER_CONFIG, ) return { @@ -131,6 +168,8 @@ def __call__(self, bytes_amount: int) -> None: "size": file_size, "error": None, } + except UploadCancelledError: + raise except ClientError as e: return { "success": False, diff --git a/app/services/upload_manager.py b/app/services/upload_manager.py index 15aa369..2237018 100644 --- a/app/services/upload_manager.py +++ b/app/services/upload_manager.py @@ -6,7 +6,13 @@ import threading import uuid from collections.abc import Callable -from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed +from concurrent.futures import ( + FIRST_COMPLETED, + ProcessPoolExecutor, + ThreadPoolExecutor, + as_completed, + wait, +) from dataclasses import dataclass, field from datetime import UTC, datetime from enum import Enum @@ -16,6 +22,7 @@ from app.services import mcap_service, s3_service from app.services.cache_service import get_cache_service from app.services.log_service import get_log_service +from app.services.s3_service import UploadCancelledError from app.services.utils import format_file_size logger = logging.getLogger(__name__) @@ -24,14 +31,18 @@ EPOCH_CUTOFF = datetime(1980, 1, 1, tzinfo=UTC) -def _extract_start_time_worker(local_path: str) -> datetime | str: +def _extract_start_time_worker(local_path: str, skip_validation: bool = False) -> datetime | str: """Worker function for ProcessPoolExecutor — must be top-level for pickling. + Args: + local_path: Path to the MCAP file + skip_validation: If True, skip MCAP parsing and extract from filename only + Returns: datetime on success, or error message string on failure. """ try: - return mcap_service.extract_start_time(local_path) + return mcap_service.extract_start_time(local_path, skip_validation=skip_validation) except Exception as e: return str(e) @@ -196,6 +207,33 @@ def average_upload_speed_mbps(self) -> float | None: return round(self.successfully_uploaded_bytes / duration / 1024 / 1024 * 8, 2) return None + def to_progress_dict(self) -> dict[str, Any]: + """Lightweight dict for SSE progress events. + + Includes only job-level stats and currently active files (uploading/analyzing), + dropping the full 20K-file array that to_dict() includes. + """ + active_files = [ + f.to_dict() + for f in self.files + if f.status in (UploadStatus.UPLOADING, UploadStatus.ANALYZING) + ] + return { + "job_id": self.job_id, + "status": self.status.value, + "progress_percent": self.progress_percent, + "files_completed": self.files_completed, + "total_files": len(self.files), + "uploaded_bytes_formatted": format_file_size(self.uploaded_bytes), + "total_bytes_formatted": format_file_size(self.total_bytes), + "eta_seconds": self.eta_seconds, + "files_failed": self.files_failed, + "files_skipped": sum(1 for f in self.files if f.status == UploadStatus.SKIPPED), + "files_uploaded": sum(1 for f in self.files if f.status == UploadStatus.COMPLETED), + "cancelled": self.cancelled, + "files": active_files, + } + def resolve_analysis_status(self) -> None: """Set job status based on file analysis results.""" if any(f.status == UploadStatus.READY for f in self.files): @@ -240,14 +278,63 @@ def to_dict(self) -> dict[str, Any]: } +@dataclass +class ScannedFolder: + """Results for a single scanned subfolder.""" + + folder_path: str + relative_path: str + files: list[dict[str, Any]] + total_files: int = 0 + already_uploaded: int = 0 + all_uploaded: bool = False + error: str | None = None + + +@dataclass +class ScanJob: + """Tracks an async folder scan job.""" + + job_id: str + root_folder: str + status: str = "scanning" # scanning | completed | failed | cancelled + cancelled: bool = False + folders_scanned: int = 0 + folders_total: int = 0 + total_files_found: int = 0 + total_already_uploaded: int = 0 + total_size: int = 0 + scanned_folders: list[dict[str, Any]] = field(default_factory=list) + excluded_subfolders: list[str] = field(default_factory=list) + excluded_files: list[str] = field(default_factory=list) + created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) + lock: threading.Lock = field(default_factory=threading.Lock, repr=False) + + class UploadManager: """Manages upload jobs and their execution.""" - def __init__(self, max_workers: int = 4) -> None: + def __init__(self, max_workers: int = 4, batch_config: dict[str, Any] | None = None) -> None: self.jobs: dict[str, UploadJob] = {} + self.scan_jobs: dict[str, ScanJob] = {} self.max_workers = max_workers self._lock = threading.Lock() + # Load batch processing configuration + if batch_config is None: + from app.config import get_settings + + settings = get_settings() + batch_config = settings.get_batch_config() + + # Import BatchConfig and BatchProcessor + from app.services.batch_processor import BatchConfig, BatchProcessor + + self.batch_config = BatchConfig.from_dict(batch_config) + self.batch_processor = ( + BatchProcessor(self.batch_config) if self.batch_config.enabled else None + ) + def create_job( self, file_paths: list[str], @@ -305,6 +392,7 @@ def analyze_job( aws_profile: str, aws_region: str, s3_bucket: str, + skip_validation: bool | None = None, ) -> UploadJob | None: """Analyze files in a job - extract timestamps and check for duplicates. @@ -313,10 +401,17 @@ def analyze_job( aws_profile: AWS profile to use aws_region: AWS region s3_bucket: S3 bucket to check for duplicates + skip_validation: If True, skip MCAP parsing. If None, use live settings. Returns: The updated UploadJob or None if not found """ + if skip_validation is None: + from app.config import get_settings + + skip_validation = bool( + get_settings().batch_processing.get("skip_mcap_validation", False) + ) job = self.get_job(job_id) if not job: return None @@ -338,7 +433,9 @@ def analyze_job( file_state.status = UploadStatus.ANALYZING try: # Extract timestamp from MCAP - start_time = mcap_service.extract_start_time(file_state.local_path) + start_time = mcap_service.extract_start_time( + file_state.local_path, skip_validation=skip_validation + ) file_state.start_time = start_time # Generate S3 path @@ -381,9 +478,7 @@ def _check_duplicate( if cache_result is not None: file_state.is_duplicate = cache_result else: - file_state.is_duplicate = s3_service.check_file_exists( - s3_client, s3_bucket, s3_path - ) + file_state.is_duplicate = s3_service.check_file_exists(s3_client, s3_bucket, s3_path) if use_cache: cache = get_cache_service() cache.update_cache( @@ -403,6 +498,7 @@ def _analyze_single_file( job_id: str = "", progress_callback: Callable[["UploadJob", FileUploadState], None] | None = None, job: "UploadJob | None" = None, + skip_validation: bool = False, ) -> FileUploadState: """Analyze a single file - extract timestamp and check for duplicates. @@ -414,6 +510,7 @@ def _analyze_single_file( job_id: The parent job ID (for logging) progress_callback: Optional callback fired when file starts analyzing job: The parent UploadJob (needed for callback) + skip_validation: If True, skip MCAP parsing and extract from filename only Returns: The updated FileUploadState @@ -424,7 +521,9 @@ def _analyze_single_file( progress_callback(job, file_state) try: # Extract timestamp from MCAP - start_time = mcap_service.extract_start_time(file_state.local_path) + start_time = mcap_service.extract_start_time( + file_state.local_path, skip_validation=skip_validation + ) file_state.start_time = start_time # Check if timestamp is valid (after 1980) @@ -497,6 +596,7 @@ def analyze_job_async( s3_bucket: str, progress_callback: Callable[["UploadJob", FileUploadState], None] | None = None, use_cache: bool = True, + skip_validation: bool | None = None, ) -> UploadJob | None: """Analyze files in a job asynchronously with parallel processing. @@ -507,10 +607,17 @@ def analyze_job_async( s3_bucket: S3 bucket to check for duplicates progress_callback: Optional callback called after each file completes use_cache: Whether to use cache for duplicate checking + skip_validation: If True, skip MCAP parsing. If None, use live settings. Returns: The updated UploadJob or None if not found """ + if skip_validation is None: + from app.config import get_settings + + skip_validation = bool( + get_settings().batch_processing.get("skip_mcap_validation", False) + ) log = get_log_service() job = self.get_job(job_id) if not job: @@ -525,12 +632,6 @@ def analyze_job_async( {"job_id": job_id, "total_files": len(job.files)}, ) - # Keep files as PENDING; workers will mark ANALYZING when they start. - # Send initial callbacks so frontend knows the job started and can show queue. - if progress_callback: - for file_state in job.files: - progress_callback(job, file_state) - # Create S3 client for duplicate checking try: s3_client = s3_service.create_s3_client(aws_profile, aws_region) @@ -547,37 +648,55 @@ def analyze_job_async( # parallelism across cores, bypassing the GIL. cpu_workers = max(1, (os.cpu_count() or 4) - 1) for file_state in job.files: - file_state.status = UploadStatus.ANALYZING + file_state.status = UploadStatus.PENDING + + files_iter_async = iter(job.files) + active_async: dict[Any, FileUploadState] = {} + + def _submit_next_async(proc_executor: ProcessPoolExecutor) -> None: + fs = next(files_iter_async, None) + if fs is None or job.cancelled: + return + fs.status = UploadStatus.ANALYZING if progress_callback: - progress_callback(job, file_state) + progress_callback(job, fs) # "queued → analyzing" event + fut = proc_executor.submit(_extract_start_time_worker, fs.local_path, skip_validation) + active_async[fut] = fs with ProcessPoolExecutor(max_workers=cpu_workers) as proc_executor: - parse_futures = { - proc_executor.submit( - _extract_start_time_worker, file_state.local_path - ): file_state - for file_state in job.files - } - for future in as_completed(parse_futures): - file_state = parse_futures[future] - result = future.result() - if isinstance(result, str): - # Error message returned from worker - file_state.status = UploadStatus.FAILED - file_state.error_message = result - log.error( - "analysis", - "file_analysis_failed", - f"Failed to analyze {file_state.filename}: {result}", - {"job_id": job_id, "filename": file_state.filename, "error": result}, - ) - else: - file_state.start_time = result - naive_start = mcap_service.to_naive_utc(result) - file_state.is_valid = naive_start >= EPOCH_CUTOFF.replace(tzinfo=None) - file_state.s3_path = mcap_service.generate_s3_path(result, file_state.filename) - if progress_callback: - progress_callback(job, file_state) + for _ in range(cpu_workers): + _submit_next_async(proc_executor) + + while active_async: + if job.cancelled: + for f in list(active_async.keys()): + f.cancel() + break + + done, _ = wait(list(active_async.keys()), return_when=FIRST_COMPLETED) + for future in done: + file_state = active_async.pop(future) + result = future.result() + if isinstance(result, str): + # Error message returned from worker + file_state.status = UploadStatus.FAILED + file_state.error_message = result + log.error( + "analysis", + "file_analysis_failed", + f"Failed to analyze {file_state.filename}: {result}", + {"job_id": job_id, "filename": file_state.filename, "error": result}, + ) + else: + file_state.start_time = result + naive_start = mcap_service.to_naive_utc(result) + file_state.is_valid = naive_start >= EPOCH_CUTOFF.replace(tzinfo=None) + file_state.s3_path = mcap_service.generate_s3_path( + result, file_state.filename + ) + if progress_callback: + progress_callback(job, file_state) + _submit_next_async(proc_executor) # Phase 2: S3 duplicate checks (I/O-bound) — threads are fine here. parsed_files = [f for f in job.files if f.status != UploadStatus.FAILED] @@ -596,7 +715,7 @@ def analyze_job_async( for fut in as_completed(dup_futures): file_state = dup_futures[fut] try: - future.result() + fut.result() file_state.status = UploadStatus.READY log.info( "analysis", @@ -727,6 +846,11 @@ def make_upload_task( fs: FileUploadState, ) -> Callable[[], Any]: def upload_task() -> Any: + if job.cancelled: + with job.lock: + fs.status = UploadStatus.CANCELLED + return None + # Mark UPLOADING inside the worker so files stay READY until picked up with job.lock: fs.status = UploadStatus.UPLOADING @@ -757,6 +881,7 @@ def byte_callback(uploaded: int, total: int) -> None: s3_bucket, fs.s3_path, byte_callback, + cancel_check=lambda: job.cancelled, ) return upload_task @@ -766,12 +891,12 @@ def byte_callback(uploaded: int, total: int) -> None: # Process results as they complete for future in as_completed(futures): - if job.cancelled: - break - file_state = futures[future] try: result = future.result() + if result is None: + # Task was cancelled before starting + continue file_state.upload_completed_at = datetime.now(UTC) if result["success"]: file_state.status = UploadStatus.COMPLETED @@ -813,6 +938,10 @@ def byte_callback(uploaded: int, total: int) -> None: "error": file_state.error_message, }, ) + except UploadCancelledError: + with job.lock: + file_state.status = UploadStatus.CANCELLED + file_state.upload_completed_at = datetime.now(UTC) except Exception as e: file_state.upload_completed_at = datetime.now(UTC) file_state.status = UploadStatus.FAILED @@ -841,6 +970,11 @@ def byte_callback(uploaded: int, total: int) -> None: # Clean up temp directory when upload completes self.cleanup_temp_dir(job_id) + # Send terminal event IMMEDIATELY so the frontend unblocks. + # Heavy I/O (logging, CSV, S3 sync) follows below. + if progress_callback: + progress_callback(job) + uploaded_count = sum(1 for f in job.files if f.status == UploadStatus.COMPLETED) skipped_count = sum(1 for f in job.files if f.status == UploadStatus.SKIPPED) failed_count = sum(1 for f in job.files if f.status == UploadStatus.FAILED) @@ -878,9 +1012,416 @@ def byte_callback(uploaded: int, total: int) -> None: # Save per-job JSONL summary try: completed_at = job.completed_at or datetime.now(UTC) - log.save_job_jsonl(job_id, { - "timestamp": completed_at.isoformat(), - "event": "upload_job_completed", + log.save_job_jsonl( + job_id, + { + "timestamp": completed_at.isoformat(), + "event": "upload_job_completed", + "job_id": job_id, + "status": job.status.value, + "uploaded": uploaded_count, + "skipped": skipped_count, + "failed": failed_count, + "total_bytes_uploaded": job.successfully_uploaded_bytes, + "duration_seconds": job.total_upload_duration_seconds, + "avg_speed_mbps": job.average_upload_speed_mbps, + "files": file_summary, + }, + completed_at, + ) + except Exception: + logger.warning("Failed to save job JSONL summary", exc_info=True) + + # Save upload summary CSV + try: + log.save_job_csv(job_id, job, completed_at) + except Exception: + logger.warning("Failed to save job CSV summary", exc_info=True) + + # Auto-sync logs to S3 after job completion + try: + log.sync_logs_to_s3(s3_client, s3_bucket) + except Exception: + logger.debug("Log sync to S3 failed", exc_info=True) + + def analyze_and_upload_pipeline( + self, + job_id: str, + aws_profile: str, + aws_region: str, + s3_bucket: str, + skip_duplicates: bool = True, + analysis_callback: Callable[["UploadJob", FileUploadState], None] | None = None, + upload_callback: Callable[["UploadJob"], None] | None = None, + use_cache: bool = True, + skip_validation: bool | None = None, + ) -> None: + """Analyze each file and upload it immediately — pipeline approach. + + Instead of analyzing all files first and then uploading, this processes + files through a pipeline: MCAP parsing runs in a ProcessPoolExecutor, + and as each parse completes the file is immediately checked for duplicates + and submitted to a ThreadPoolExecutor for upload. + + Args: + job_id: The job ID to process + aws_profile: AWS profile to use + aws_region: AWS region + s3_bucket: S3 bucket to upload to + skip_duplicates: Whether to skip files that already exist + analysis_callback: Called after each file is analyzed + upload_callback: Called for upload progress updates + use_cache: Whether to use cache for duplicate checking + skip_validation: If True, skip MCAP parsing. If None, use batch_config setting + """ + # Determine skip_validation setting — read live from settings so that + # changes made in the Settings UI take effect without a server restart. + # (UploadManager is a singleton whose batch_config is frozen at init time.) + if skip_validation is None: + from app.config import get_settings + + skip_validation = bool( + get_settings().batch_processing.get("skip_mcap_validation", False) + ) + log = get_log_service() + job = self.get_job(job_id) + if not job: + return + + job.status = UploadStatus.UPLOADING + job.started_at = datetime.now(UTC) + + log.info( + "upload", + "pipeline_started", + f"Starting analyze-and-upload pipeline for {len(job.files)} files", + {"job_id": job_id, "total_files": len(job.files)}, + ) + + # Create S3 client + try: + s3_client = s3_service.create_s3_client(aws_profile, aws_region) + except Exception as e: + job.status = UploadStatus.FAILED + for file_state in job.files: + file_state.status = UploadStatus.FAILED + file_state.error_message = f"Failed to create S3 client: {e}" + if analysis_callback: + analysis_callback(job, file_state) + if upload_callback: + upload_callback(job) + return + + cpu_workers = max(1, (os.cpu_count() or 4) - 1) + upload_executor = ThreadPoolExecutor(max_workers=self.max_workers) + + # Mark all files as PENDING (waiting their turn in the analysis pool) + for fs in job.files: + fs.status = UploadStatus.PENDING + + files_iter = iter(job.files) + active: dict[Any, FileUploadState] = {} + + def _submit_next(proc_executor: ProcessPoolExecutor) -> None: + fs = next(files_iter, None) + if fs is None or job.cancelled: + return + fs.status = UploadStatus.ANALYZING + if analysis_callback: + analysis_callback(job, fs) # "queued → analyzing" event + fut = proc_executor.submit(_extract_start_time_worker, fs.local_path, skip_validation) + active[fut] = fs + + try: + with ProcessPoolExecutor(max_workers=cpu_workers) as proc_executor: + # Fill initial slots + for _ in range(cpu_workers): + _submit_next(proc_executor) + + while active: + if job.cancelled: + for f in list(active.keys()): + f.cancel() + break + + done, _ = wait(list(active.keys()), return_when=FIRST_COMPLETED) + for future in done: + fs = active.pop(future) + result = future.result() + + if isinstance(result, str): + # Parse failed + fs.status = UploadStatus.FAILED + fs.error_message = result + log.error( + "analysis", + "file_analysis_failed", + f"Failed to analyze {fs.filename}: {result}", + {"job_id": job_id, "filename": fs.filename, "error": result}, + ) + if analysis_callback: + analysis_callback(job, fs) + _submit_next(proc_executor) + continue + + # Parse succeeded — set timestamp and generate S3 path + fs.start_time = result + naive_start = mcap_service.to_naive_utc(result) + fs.is_valid = naive_start >= EPOCH_CUTOFF.replace(tzinfo=None) + fs.s3_path = mcap_service.generate_s3_path(result, fs.filename) + + # Check duplicate (I/O but fast — cache lookup or S3 HEAD) + self._check_duplicate(fs, s3_client, s3_bucket, use_cache) + fs.status = UploadStatus.READY + + log.info( + "analysis", + "file_analysis_completed", + f"Analyzed {fs.filename}", + { + "job_id": job_id, + "filename": fs.filename, + "file_size": fs.file_size, + "s3_path": fs.s3_path, + "is_duplicate": fs.is_duplicate, + "is_valid": fs.is_valid, + }, + ) + + # Notify frontend of analysis result + if analysis_callback: + analysis_callback(job, fs) + + # Fill freed slot immediately + _submit_next(proc_executor) + + # Decide: skip or upload? + if not fs.is_valid: + fs.status = UploadStatus.SKIPPED + fs.error_message = "Invalid timestamp (pre-1980)" + log.warning( + "upload", + "file_upload_skipped", + f"Skipped invalid timestamp: {fs.filename}", + { + "job_id": job_id, + "filename": fs.filename, + "reason": "invalid_timestamp", + }, + ) + if upload_callback: + upload_callback(job) + continue + + if skip_duplicates and fs.is_duplicate: + fs.status = UploadStatus.SKIPPED + fs.bytes_uploaded = fs.file_size + log.info( + "upload", + "file_upload_skipped", + f"Skipped duplicate: {fs.filename}", + { + "job_id": job_id, + "filename": fs.filename, + "reason": "duplicate", + }, + ) + if upload_callback: + upload_callback(job) + continue + + # Submit for upload immediately + def make_upload_task( + file_state: FileUploadState, + ) -> Callable[[], Any]: + def upload_task() -> Any: + if job.cancelled: + with job.lock: + file_state.status = UploadStatus.CANCELLED + if analysis_callback: + analysis_callback(job, file_state) + if upload_callback: + upload_callback(job) + return None + + try: + with job.lock: + file_state.status = UploadStatus.UPLOADING + file_state.upload_started_at = datetime.now(UTC) + log.info( + "upload", + "file_upload_started", + f"Uploading {file_state.filename}", + { + "job_id": job_id, + "filename": file_state.filename, + "file_size": file_state.file_size, + "s3_path": file_state.s3_path, + }, + ) + if upload_callback: + upload_callback(job) + + def byte_callback(uploaded: int, total: int) -> None: + with job.lock: + file_state.bytes_uploaded = uploaded + if upload_callback: + upload_callback(job) + + upload_result = s3_service.upload_file_with_progress( + s3_client, + file_state.local_path, + s3_bucket, + file_state.s3_path, + byte_callback, + cancel_check=lambda: job.cancelled, + ) + + # Handle completion inline + file_state.upload_completed_at = datetime.now(UTC) + if upload_result["success"]: + file_state.status = UploadStatus.COMPLETED + file_state.bytes_uploaded = file_state.file_size + log.info( + "upload", + "file_upload_completed", + f"Uploaded {file_state.filename}", + { + "job_id": job_id, + "filename": file_state.filename, + "file_size": file_state.file_size, + "upload_duration_seconds": ( + file_state.upload_duration_seconds + ), + "s3_path": file_state.s3_path, + }, + ) + try: + cache = get_cache_service() + cache.update_cache( + s3_bucket, + file_state.s3_path, + exists=True, + filename=file_state.filename, + file_size=file_state.file_size, + ) + except Exception: + logger.debug( + "Cache update failed after upload", + exc_info=True, + ) + else: + file_state.status = UploadStatus.FAILED + file_state.error_message = upload_result.get( + "error", "Unknown error" + ) + log.error( + "upload", + "file_upload_failed", + f"Failed to upload {file_state.filename}: " + f"{file_state.error_message}", + { + "job_id": job_id, + "filename": file_state.filename, + "error": file_state.error_message, + }, + ) + except UploadCancelledError: + with job.lock: + file_state.status = UploadStatus.CANCELLED + file_state.upload_completed_at = datetime.now(UTC) + except Exception as e: + file_state.upload_completed_at = datetime.now(UTC) + file_state.status = UploadStatus.FAILED + file_state.error_message = str(e) + log.error( + "upload", + "file_upload_failed", + f"Failed to upload {file_state.filename}: {e}", + { + "job_id": job_id, + "filename": file_state.filename, + "error": str(e), + }, + ) + + # Notify per-file status so the frontend + # updates this row immediately (the progress + # dict only includes active files, so without + # this the row would keep spinning). + if analysis_callback: + analysis_callback(job, file_state) + if upload_callback: + upload_callback(job) + return None + + return upload_task + + upload_executor.submit(make_upload_task(fs)) + + except Exception as e: + log.error( + "upload", + "pipeline_error", + f"Pipeline error: {e}", + {"job_id": job_id, "error": str(e)}, + ) + finally: + # Wait for ALL uploads (in-flight + queued) to complete + upload_executor.shutdown(wait=True) + + # Mark any files still in non-terminal states as cancelled + if job.cancelled: + with job.lock: + for fs in job.files: + if fs.status in ( + UploadStatus.PENDING, + UploadStatus.READY, + UploadStatus.ANALYZING, + UploadStatus.UPLOADING, + ): + fs.status = UploadStatus.CANCELLED + + # Final job status + job.completed_at = datetime.now(UTC) + if job.cancelled: + job.status = UploadStatus.CANCELLED + elif all(f.status in (UploadStatus.COMPLETED, UploadStatus.SKIPPED) for f in job.files): + job.status = UploadStatus.COMPLETED + elif any(f.status == UploadStatus.COMPLETED for f in job.files): + job.status = UploadStatus.COMPLETED # Partial success + else: + job.status = UploadStatus.FAILED + + # Clean up temp directory + self.cleanup_temp_dir(job_id) + + # Send terminal event IMMEDIATELY so the frontend unblocks. + # Heavy I/O (logging, CSV, S3 sync) follows below. + if upload_callback: + upload_callback(job) + + uploaded_count = sum(1 for f in job.files if f.status == UploadStatus.COMPLETED) + skipped_count = sum(1 for f in job.files if f.status == UploadStatus.SKIPPED) + failed_count = sum(1 for f in job.files if f.status == UploadStatus.FAILED) + + file_summary = [ + { + "filename": f.filename, + "s3_path": f.s3_path, + "status": f.status.value, + "file_size": f.file_size, + "duration_seconds": f.upload_duration_seconds, + } + for f in job.files + ] + + log.info( + "upload", + "upload_job_completed", + f"Upload job completed: {uploaded_count} uploaded, " + f"{skipped_count} skipped, {failed_count} failed", + { "job_id": job_id, "status": job.status.value, "uploaded": uploaded_count, @@ -890,7 +1431,29 @@ def byte_callback(uploaded: int, total: int) -> None: "duration_seconds": job.total_upload_duration_seconds, "avg_speed_mbps": job.average_upload_speed_mbps, "files": file_summary, - }, completed_at) + }, + ) + + # Save per-job JSONL summary + completed_at = job.completed_at or datetime.now(UTC) + try: + log.save_job_jsonl( + job_id, + { + "timestamp": completed_at.isoformat(), + "event": "upload_job_completed", + "job_id": job_id, + "status": job.status.value, + "uploaded": uploaded_count, + "skipped": skipped_count, + "failed": failed_count, + "total_bytes_uploaded": job.successfully_uploaded_bytes, + "duration_seconds": job.total_upload_duration_seconds, + "avg_speed_mbps": job.average_upload_speed_mbps, + "files": file_summary, + }, + completed_at, + ) except Exception: logger.warning("Failed to save job JSONL summary", exc_info=True) @@ -900,15 +1463,12 @@ def byte_callback(uploaded: int, total: int) -> None: except Exception: logger.warning("Failed to save job CSV summary", exc_info=True) - # Auto-sync logs to S3 after job completion + # Auto-sync logs to S3 try: log.sync_logs_to_s3(s3_client, s3_bucket) except Exception: logger.debug("Log sync to S3 failed", exc_info=True) - if progress_callback: - progress_callback(job) - def cancel_job(self, job_id: str) -> bool: """Cancel an upload job. @@ -923,9 +1483,14 @@ def cancel_job(self, job_id: str) -> bool: return False job.cancelled = True - for file_state in job.files: - if file_state.status in (UploadStatus.PENDING, UploadStatus.READY): - file_state.status = UploadStatus.CANCELLED + with job.lock: + for file_state in job.files: + if file_state.status in ( + UploadStatus.PENDING, + UploadStatus.READY, + UploadStatus.ANALYZING, + ): + file_state.status = UploadStatus.CANCELLED # Clean up temp directory when job is cancelled self.cleanup_temp_dir(job_id) @@ -969,6 +1534,7 @@ def pre_filter_files( s3_bucket: str, aws_profile: str = "default", aws_region: str = "us-west-2", + cache_only: bool = False, ) -> tuple[list[str], dict[str, Any]]: """Pre-filter files using cache and filename timestamp extraction. @@ -1017,9 +1583,7 @@ def pre_filter_files( } # First: check cache by filename+size (works regardless of timestamp source) - filename_result = cache.check_exists_by_filename( - s3_bucket, path.name, stat.st_size - ) + filename_result = cache.check_exists_by_filename(s3_bucket, path.name, stat.st_size) if filename_result is True: stats["cache_hits"] += 1 stats["cache_skipped"] += 1 @@ -1062,37 +1626,306 @@ def pre_filter_files( # Batch S3 HEAD checks for cache misses if cache_miss_s3_paths: - try: - s3_client = s3_service.create_s3_client(aws_profile, aws_region) - - def check_s3(s3_path: str) -> bool: - return s3_service.check_file_exists(s3_client, s3_bucket, s3_path) - - with ThreadPoolExecutor(max_workers=self.max_workers) as executor: - results = list(executor.map(check_s3, cache_miss_s3_paths)) - - for idx, s3_path, exists in zip( - cache_miss_indices, cache_miss_s3_paths, results, strict=True - ): - # Update cache with result - fs = file_statuses[idx] - cache.update_cache( - s3_bucket, s3_path, exists, fs["filename"], fs["size"] - ) - if exists: - stats["s3_hits"] += 1 - fs["already_uploaded"] = True - else: - files_to_analyze.append(fs["path"]) - except Exception: - # S3 check failed — fall back to full analysis for cache misses + if cache_only: + # In cache_only mode, skip S3 HEAD checks — treat misses as not-uploaded for idx in cache_miss_indices: files_to_analyze.append(file_statuses[idx]["path"]) + else: + try: + s3_client = s3_service.create_s3_client(aws_profile, aws_region) + + def check_s3(s3_path: str) -> bool: + return s3_service.check_file_exists(s3_client, s3_bucket, s3_path) + + with ThreadPoolExecutor(max_workers=self.max_workers) as executor: + results = list(executor.map(check_s3, cache_miss_s3_paths)) + + for idx, s3_path, exists in zip( + cache_miss_indices, cache_miss_s3_paths, results, strict=True + ): + # Update cache with result + fs = file_statuses[idx] + cache.update_cache(s3_bucket, s3_path, exists, fs["filename"], fs["size"]) + if exists: + stats["s3_hits"] += 1 + fs["already_uploaded"] = True + else: + files_to_analyze.append(fs["path"]) + except Exception: + # S3 check failed — fall back to full analysis for cache misses + for idx in cache_miss_indices: + files_to_analyze.append(file_statuses[idx]["path"]) stats["to_analyze"] = len(files_to_analyze) stats["file_statuses"] = file_statuses return files_to_analyze, stats + def create_scan_job( + self, + folder_path: str, + excluded_subfolders: list[str] | None = None, + excluded_files: list[str] | None = None, + ) -> ScanJob: + """Create a new scan job for a folder. + + Args: + folder_path: Root folder to scan + excluded_subfolders: Subfolder names to exclude from scan + excluded_files: Root-level filenames to exclude from scan + + Returns: + The created ScanJob + """ + job_id = str(uuid.uuid4()) + scan_job = ScanJob( + job_id=job_id, + root_folder=folder_path, + excluded_subfolders=excluded_subfolders or [], + excluded_files=excluded_files or [], + ) + with self._lock: + self.scan_jobs[job_id] = scan_job + return scan_job + + def get_scan_job(self, job_id: str) -> ScanJob | None: + """Get a scan job by ID.""" + return self.scan_jobs.get(job_id) + + def cancel_scan_job(self, job_id: str) -> bool: + """Cancel a scan job. + + Args: + job_id: The scan job ID to cancel + + Returns: + True if job was found and cancelled + """ + scan_job = self.get_scan_job(job_id) + if not scan_job: + return False + scan_job.cancelled = True + scan_job.status = "cancelled" + return True + + def scan_folder_async( + self, + job_id: str, + s3_bucket: str, + aws_profile: str, + aws_region: str, + progress_callback: Callable[[str, dict[str, Any]], None] | None = None, + cache_only: bool = False, + ) -> None: + """Scan a folder asynchronously, processing subfolder by subfolder. + + Args: + job_id: The scan job ID + s3_bucket: S3 bucket for duplicate checking + aws_profile: AWS profile for S3 access + aws_region: AWS region for S3 access + progress_callback: Called with (job_id, event_data) for each event + """ + scan_job = self.get_scan_job(job_id) + if not scan_job: + return + + root = Path(scan_job.root_folder) + log = get_log_service() + + try: + # Build exclusion sets for fast lookup + excluded_subs_set = set(scan_job.excluded_subfolders) + excluded_files_set = set(scan_job.excluded_files) + + # Phase 1: Enumerate subfolders containing .mcap files (fast, metadata only) + folder_map: dict[str, list[Path]] = {} + for mcap_path in root.rglob("*.mcap"): + if scan_job.cancelled: + break + if mcap_path.is_file(): + rel = mcap_path.relative_to(root) + parts = rel.parts + # Skip root-level files in excluded_files list + if len(parts) == 1 and parts[0] in excluded_files_set: + continue + # Skip files under excluded subfolders + if len(parts) > 1 and parts[0] in excluded_subs_set: + continue + parent = str(mcap_path.parent) + if parent not in folder_map: + folder_map[parent] = [] + folder_map[parent].append(mcap_path) + + if scan_job.cancelled: + if progress_callback: + progress_callback( + job_id, + { + "type": "scan_complete", + "status": "cancelled", + }, + ) + return + + scan_job.folders_total = len(folder_map) + + if progress_callback: + progress_callback( + job_id, + { + "type": "scan_started", + "folders_total": scan_job.folders_total, + "root_folder": scan_job.root_folder, + }, + ) + + # Phase 2: Process each subfolder + for folder_path_str, mcap_paths in sorted(folder_map.items()): + if scan_job.cancelled: + break + + try: + relative_path = str(Path(folder_path_str).relative_to(root)) + if relative_path == ".": + relative_path = "." + + # Collect file info + file_paths: list[str] = [] + files_info: list[dict[str, Any]] = [] + folder_size = 0 + for mcap_path in sorted(mcap_paths, key=lambda p: p.name): + stat = mcap_path.stat() + file_paths.append(str(mcap_path)) + folder_size += stat.st_size + files_info.append( + { + "path": str(mcap_path), + "filename": mcap_path.name, + "size": stat.st_size, + "mtime": stat.st_mtime, + "relative_path": str(mcap_path.relative_to(root)), + } + ) + + # Pre-filter this batch for duplicates + _, pre_stats = self.pre_filter_files( + file_paths, + s3_bucket, + aws_profile, + aws_region, + cache_only=cache_only, + ) + + # Merge pre-filter results into file info + prefilter_map: dict[str, bool] = {} + for fs in pre_stats.get("file_statuses", []): + prefilter_map[fs["path"]] = fs.get("already_uploaded", False) + + already_uploaded_count = 0 + for fi in files_info: + fi["already_uploaded"] = prefilter_map.get(fi["path"], False) + if fi["already_uploaded"]: + already_uploaded_count += 1 + + all_uploaded = already_uploaded_count == len(files_info) and len(files_info) > 0 + + scanned = ScannedFolder( + folder_path=folder_path_str, + relative_path=relative_path, + files=files_info, + total_files=len(files_info), + already_uploaded=already_uploaded_count, + all_uploaded=all_uploaded, + ) + + except Exception as e: + relative_path = str(Path(folder_path_str).relative_to(root)) + scanned = ScannedFolder( + folder_path=folder_path_str, + relative_path=relative_path, + files=[], + error=str(e), + ) + log.error( + "scan", + "scan_folder_error", + f"Error scanning {folder_path_str}: {e}", + {"job_id": job_id, "folder": folder_path_str, "error": str(e)}, + ) + + # Build folder dict for both storage and SSE + folder_dict = { + "relative_path": scanned.relative_path, + "files": scanned.files, + "total_files": scanned.total_files, + "already_uploaded": scanned.already_uploaded, + "all_uploaded": scanned.all_uploaded, + "error": scanned.error, + } + + # Update running totals and store results + with scan_job.lock: + scan_job.folders_scanned += 1 + scan_job.total_files_found += scanned.total_files + scan_job.total_already_uploaded += scanned.already_uploaded + scan_job.total_size += sum(f.get("size", 0) for f in scanned.files) + scan_job.scanned_folders.append(folder_dict) + + if progress_callback: + progress_callback( + job_id, + { + "type": "scan_folder_complete", + "folder": folder_dict, + "folders_scanned": scan_job.folders_scanned, + "folders_total": scan_job.folders_total, + "running_totals": { + "total_files_found": scan_job.total_files_found, + "total_already_uploaded": scan_job.total_already_uploaded, + "total_size": scan_job.total_size, + }, + }, + ) + + # Terminal event + with scan_job.lock: + if scan_job.cancelled: + scan_job.status = "cancelled" + else: + scan_job.status = "completed" + + if progress_callback: + progress_callback( + job_id, + { + "type": "scan_complete", + "status": scan_job.status, + "folders_scanned": scan_job.folders_scanned, + "folders_total": scan_job.folders_total, + "total_files_found": scan_job.total_files_found, + "total_already_uploaded": scan_job.total_already_uploaded, + "total_size": scan_job.total_size, + }, + ) + + except Exception as e: + scan_job.status = "failed" + log.error( + "scan", + "scan_job_failed", + f"Scan job {job_id} failed: {e}", + {"job_id": job_id, "error": str(e)}, + ) + if progress_callback: + progress_callback( + job_id, + { + "type": "scan_complete", + "status": "failed", + "error": str(e), + }, + ) + def get_active_jobs(self) -> list[UploadJob]: """Get all currently active (non-terminal) jobs.""" active_statuses = { diff --git a/app/static/js/app.js b/app/static/js/app.js deleted file mode 100644 index 08d325b..0000000 --- a/app/static/js/app.js +++ /dev/null @@ -1,51 +0,0 @@ -/** - * modaq-upload - Entry Point - * - * Detects the current page via data-page attribute and dynamically - * imports the appropriate module. Registers delegated click handlers - * for data-action attributes (replacing inline onclick). - */ -import { - closeAboutModal, - initAboutModal, - loadHeaderVersion, - openAboutModal, -} from './modules/about.js'; -import { hideEl } from './modules/dom.js'; -import { goToStep } from './modules/stepper.js'; - -// Global initialization (runs on every page) -initAboutModal(); -loadHeaderVersion(); - -// Delegated click handler for data-action attributes -document.addEventListener('click', (e) => { - const target = /** @type {HTMLElement} */ (e.target).closest('[data-action]'); - if (!target) return; - - const action = /** @type {HTMLElement} */ (target).dataset.action; - - if (action === 'open-about') { - openAboutModal(); - } else if (action === 'close-about') { - closeAboutModal(); - } else if (action === 'go-to-step') { - const step = Number(/** @type {HTMLElement} */ (target).dataset.step); - if (step) goToStep(step); - } else if (action === 'close-confirm-modal') { - hideEl('confirm-upload-modal'); - } -}); - -// Page-specific module loading -const page = document.body.dataset.page; - -if (page === 'upload') { - import('./modules/upload-init.js').then(({ initUpload }) => initUpload()); -} else if (page === 'files') { - import('./modules/file-browser.js').then(({ initFileBrowser }) => initFileBrowser()); -} else if (page === 'settings') { - import('./modules/settings.js').then(({ initSettings }) => initSettings()); -} else if (page === 'logs') { - import('./modules/logs.js').then(({ initLogs }) => initLogs()); -} diff --git a/app/static/js/modules/about.js b/app/static/js/modules/about.js deleted file mode 100644 index a206e82..0000000 --- a/app/static/js/modules/about.js +++ /dev/null @@ -1,55 +0,0 @@ -/** - * About modal functionality. - */ -import { apiGet } from './api.js'; -import { hideEl, setText, showEl } from './dom.js'; -import state from './state.js'; - -/** - * Load version info from the API and populate header badge. - */ -export async function loadHeaderVersion() { - try { - const data = await apiGet('/api/settings/version'); - state.appVersionData = data; - setText('header-version', data.version || '0.0.0'); - } catch { - setText('header-version', '?'); - } -} - -export function openAboutModal() { - showEl('about-modal'); - document.body.style.overflow = 'hidden'; - - if (state.appVersionData) { - setText('about-version', state.appVersionData.version || '0.0.0'); - setText('about-commit', state.appVersionData.commit || '-'); - setText('about-branch', state.appVersionData.branch || '-'); - } -} - -export function closeAboutModal() { - hideEl('about-modal'); - document.body.style.overflow = ''; -} - -/** - * Initialize about modal event listeners. - */ -export function initAboutModal() { - document.addEventListener('keydown', (e) => { - if (e.key === 'Escape') { - closeAboutModal(); - } - }); - - const modal = document.getElementById('about-modal'); - if (modal) { - modal.addEventListener('click', (e) => { - if (e.target === modal) { - closeAboutModal(); - } - }); - } -} diff --git a/app/static/js/modules/analysis.js b/app/static/js/modules/analysis.js deleted file mode 100644 index acc4e18..0000000 --- a/app/static/js/modules/analysis.js +++ /dev/null @@ -1,392 +0,0 @@ -/** - * File analysis/validation and combined upload progress (Step 3). - */ -import { apiGet } from './api.js'; -import { hideEl, setText, showEl } from './dom.js'; -import { formatBytes } from './formatters.js'; -import { showNotification } from './notify.js'; -import state from './state.js'; -import { setUploadStep } from './stepper.js'; -import { showCompletionSummary, updateProgressUI } from './upload-exec.js'; - -// Progress weighting: analysis is typically slower than upload -const ANALYSIS_WEIGHT = 70; -const UPLOAD_WEIGHT = 30; - -/** - * Cache of last-known status per filename to avoid redundant DOM rebuilds. - * @type {Map} - */ -const fileRowStatusCache = new Map(); - -/** Pending file updates to flush in the next animation frame. @type {Map} */ -const pendingFileUpdates = new Map(); - -/** Whether a requestAnimationFrame is already scheduled. */ -let rafScheduled = false; - -/** Pending overall progress data to flush. @type {any} */ -let pendingProgressData = null; - -/** Current phase of the upload flow: 'analysis' or 'upload'. */ -let currentPhase = 'analysis'; - -/** - * Reset internal state (call when starting a new job or resetting). - */ -export function resetAnalysisState() { - fileRowStatusCache.clear(); - pendingFileUpdates.clear(); - pendingProgressData = null; - rafScheduled = false; - currentPhase = 'analysis'; -} - -/** - * Check for an active upload job and restore UI state. - */ -export async function checkForActiveJob() { - try { - const data = await apiGet('/api/upload/active'); - if (!data.job_id) return; - - state.currentJobId = data.job_id; - const job = data.job; - - if (job.status === 'analyzing' || job.status === 'uploading') { - setUploadStep(3); - hideEl('folder-browser-panel'); - showEl('upload-section'); - - if (job.status === 'uploading') { - setText('upload-phase-label', 'Uploading files...'); - } - - connectCombinedProgressStream(state.currentJobId); - } else if (job.status === 'ready') { - setUploadStep(3); - hideEl('folder-browser-panel'); - showEl('upload-section'); - connectCombinedProgressStream(state.currentJobId); - } else if ( - job.status === 'completed' || - job.status === 'failed' || - job.status === 'cancelled' - ) { - setUploadStep(4); - hideEl('folder-browser-panel'); - showEl('completion-section'); - showCompletionSummary(job); - } - } catch (error) { - console.log('No active job found:', /** @type {Error} */ (error).message); - } -} - -/** - * Connect to SSE stream for combined validation + upload progress. - * @param {string | null} jobId - */ -export function connectCombinedProgressStream(jobId) { - if (state.eventSource) { - state.eventSource.close(); - } - - resetAnalysisState(); - - state.eventSource = new EventSource(`/api/upload/progress/${jobId}`); - - let analysisTotal = 0; - let analysisCompleted = 0; - - state.eventSource.onmessage = (event) => { - const data = JSON.parse(event.data); - - if (data.error) { - showNotification(data.error, 'error'); - state.eventSource?.close(); - return; - } - - // Analysis progress: queue per-file update for next frame - if (data.type === 'analysis_progress') { - queueFileUpdate(data.file); - - // Set total from the first event that carries it - if (data.total_files && analysisTotal === 0) { - analysisTotal = data.total_files; - setText('files-total', data.total_files); - } - - // Only count terminal statuses for progress bar (not pending/analyzing) - if (data.file.status !== 'pending' && data.file.status !== 'analyzing') { - analysisCompleted++; - } - - // Update progress bar for analysis phase (use float, let CSS transition smooth it) - if (analysisTotal > 0) { - const percent = (analysisCompleted / analysisTotal) * ANALYSIS_WEIGHT; - setProgressBar(percent); - } - } - - // Analysis complete: transition to upload phase - if (data.type === 'analysis_complete') { - setProgressBar(ANALYSIS_WEIGHT); - setPhaseLabel('Preparing upload...'); - - if (!data.auto_upload) { - state.eventSource?.close(); - state.eventSource = null; - } - } - - // Auto-upload starting - if (data.type === 'auto_upload_starting') { - currentPhase = 'upload'; - setPhaseLabel('Uploading files...'); - } - - // Upload progress updates (job-level data without a type field) - if (!data.type && data.job_id && data.status) { - if (data.status === 'uploading') { - // Remap upload progress into the UPLOAD_WEIGHT portion of the bar - const adjusted = { - ...data, - progress_percent: ANALYSIS_WEIGHT + (data.progress_percent / 100) * UPLOAD_WEIGHT, - }; - pendingProgressData = adjusted; - - // Queue per-file row updates - if (data.files) { - for (const file of data.files) { - queueFileUpdate(file); - } - } - scheduleRaf(); - } else if (['completed', 'failed', 'cancelled'].includes(data.status)) { - state.eventSource?.close(); - state.eventSource = null; - showCompletionSummary(data); - } - } - }; - - state.eventSource.onerror = () => { - if (state.eventSource) { - state.eventSource.close(); - state.eventSource = null; - showNotification('Connection to server lost', 'error'); - } - }; -} - -/** - * Set the overall progress bar value (CSS transition handles smoothing). - * @param {number} percent - */ -function setProgressBar(percent) { - const progressBar = /** @type {HTMLElement | null} */ (document.getElementById('progress-bar')); - if (progressBar) progressBar.style.width = `${percent}%`; - setText('progress-percent', percent.toFixed(1)); -} - -/** - * Update the phase label with an opacity fade transition. - * @param {string} text - */ -function setPhaseLabel(text) { - const phaseLabel = document.getElementById('upload-phase-label'); - if (!phaseLabel || phaseLabel.textContent === text) return; - phaseLabel.style.opacity = '0'; - setTimeout(() => { - phaseLabel.textContent = text; - phaseLabel.style.opacity = '1'; - }, 150); -} - -/** - * Queue a file update for the next animation frame. - * @param {any} fileData - */ -function queueFileUpdate(fileData) { - pendingFileUpdates.set(fileData.filename, fileData); - scheduleRaf(); -} - -/** Schedule a requestAnimationFrame if not already pending. */ -function scheduleRaf() { - if (!rafScheduled) { - rafScheduled = true; - requestAnimationFrame(flushUpdates); - } -} - -/** Flush all pending updates in a single animation frame. */ -function flushUpdates() { - rafScheduled = false; - - // Flush pending file row updates - for (const [, fileData] of pendingFileUpdates) { - updateUploadFileRow(fileData); - } - pendingFileUpdates.clear(); - - // Recompute queue positions after row updates - recomputeQueuePositions(); - - // Flush pending overall progress - if (pendingProgressData) { - updateProgressUI(pendingProgressData); - pendingProgressData = null; - } -} - -/** - * Recompute "Queued (X of Y)" labels for pending/ready-queued files. - */ -function recomputeQueuePositions() { - const allRows = document.querySelectorAll('[data-upload-file]'); - // Determine which statuses count as "queued" based on current phase - const queuedStatuses = currentPhase === 'upload' ? ['ready'] : ['pending']; - - // First pass: count queued files - let totalQueued = 0; - for (const row of allRows) { - const filename = /** @type {HTMLElement} */ (row).getAttribute('data-upload-file'); - const status = fileRowStatusCache.get(filename || ''); - if (status && queuedStatuses.includes(status)) { - totalQueued++; - } - } - - if (totalQueued === 0) return; - - // Second pass: assign positions - let pos = 1; - for (const row of allRows) { - const filename = /** @type {HTMLElement} */ (row).getAttribute('data-upload-file'); - const status = fileRowStatusCache.get(filename || ''); - if (status && queuedStatuses.includes(status)) { - const label = row.querySelector('[data-queue-label]'); - if (label) { - label.textContent = - currentPhase === 'upload' - ? `Queued for upload (${pos} of ${totalQueued})` - : `Queued (${pos} of ${totalQueued})`; - } - pos++; - } - } -} - -/** - * Update a single file row with minimal DOM changes. - * Only rebuilds innerHTML on status transitions; for uploading progress, - * just updates the bar width and percentage text to avoid resetting spinners. - * @param {any} fileData - */ -function updateUploadFileRow(fileData) { - const row = document.querySelector(`[data-upload-file="${fileData.filename}"]`); - if (!row) return; - - const cachedStatus = fileRowStatusCache.get(fileData.filename); - const newStatus = fileData.is_duplicate ? `${fileData.status}:dup` : fileData.status; - - // If status hasn't changed, only do micro-updates for uploading progress - if (cachedStatus === newStatus) { - if (fileData.status === 'uploading') { - const bar = /** @type {HTMLElement | null} */ (row.querySelector('[data-progress-bar]')); - if (bar) bar.style.width = `${fileData.progress_percent || 0}%`; - const label = row.querySelector('[data-progress-label]'); - if (label) { - label.textContent = - fileData.progress_percent != null ? `${fileData.progress_percent.toFixed(0)}%` : ''; - } - } - return; - } - - // Status changed — full rebuild of the row - fileRowStatusCache.set(fileData.filename, newStatus); - row.innerHTML = buildFileRowHTML(fileData); -} - -/** - * Build the full HTML for a file row. - * @param {any} fileData - * @returns {string} - */ -function buildFileRowHTML(fileData) { - let statusIcon = ''; - let statusText = ''; - - if (fileData.status === 'completed') { - statusIcon = - ''; - statusText = 'Uploaded'; - } else if (fileData.status === 'failed') { - statusIcon = - ''; - statusText = `Failed`; - } else if (fileData.status === 'skipped') { - statusIcon = - ''; - statusText = 'Skipped'; - } else if (fileData.status === 'uploading') { - statusIcon = - ''; - const pct = fileData.progress_percent != null ? `${fileData.progress_percent.toFixed(0)}%` : ''; - statusText = ` -
-
-
-
- ${pct} -
`; - } else if (fileData.status === 'analyzing') { - statusIcon = - ''; - statusText = 'Validating'; - } else if (fileData.status === 'ready') { - if (fileData.is_duplicate) { - statusIcon = - ''; - statusText = 'Duplicate'; - } else if (currentPhase === 'upload') { - // In upload phase, non-duplicate ready files are queued for upload - statusIcon = - '
'; - statusText = 'Queued for upload'; - } else { - statusIcon = - ''; - statusText = 'Validated'; - } - } else if (fileData.status === 'pending') { - statusIcon = - '
'; - statusText = 'Queued'; - } else { - statusText = `${fileData.status || 'Unknown'}`; - } - - return ` -
- ${statusIcon || '
'} - ${fileData.filename} -
-
- ${fileData.file_size_formatted ? `${fileData.file_size_formatted}` : ''} - ${statusText} -
- `; -} - -/** - * @param {any} fileData - * @returns {string} - */ -export function formatFileSize(fileData) { - return fileData.file_size_formatted || formatBytes(fileData.file_size || 0); -} diff --git a/app/static/js/modules/api.js b/app/static/js/modules/api.js deleted file mode 100644 index ca68506..0000000 --- a/app/static/js/modules/api.js +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Shared fetch wrappers for JSON API calls. - */ - -/** - * GET a JSON endpoint. Throws on non-ok responses. - * @param {string} url - * @returns {Promise} - */ -export async function apiGet(url) { - const response = await fetch(url); - const data = await response.json(); - if (!response.ok) throw new Error(data.error || `Request failed (${response.status})`); - return data; -} - -/** - * POST to a JSON endpoint. Throws on non-ok responses. - * @param {string} url - * @param {any} [body] - If provided, sent as JSON with Content-Type header. - * @returns {Promise} - */ -export async function apiPost(url, body) { - /** @type {RequestInit} */ - const options = { method: 'POST' }; - if (body !== undefined) { - options.headers = { 'Content-Type': 'application/json' }; - options.body = JSON.stringify(body); - } - const response = await fetch(url, options); - const data = await response.json(); - if (!response.ok) throw new Error(data.error || `Request failed (${response.status})`); - return data; -} - -/** - * PUT to a JSON endpoint. Throws on non-ok responses. - * @param {string} url - * @param {any} body - * @returns {Promise} - */ -export async function apiPut(url, body) { - const response = await fetch(url, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }); - const data = await response.json(); - if (!response.ok) throw new Error(data.error || `Request failed (${response.status})`); - return data; -} diff --git a/app/static/js/modules/debounce.js b/app/static/js/modules/debounce.js deleted file mode 100644 index d7b0b72..0000000 --- a/app/static/js/modules/debounce.js +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Debounce utility. - */ - -/** - * @param {(...args: any[]) => void} fn - * @param {number} delay - * @returns {(...args: any[]) => void} - */ -export function debounce(fn, delay) { - let timeout = 0; - return (/** @type {any[]} */ ...args) => { - clearTimeout(timeout); - timeout = window.setTimeout(() => fn(...args), delay); - }; -} diff --git a/app/static/js/modules/dom.js b/app/static/js/modules/dom.js deleted file mode 100644 index 0af1309..0000000 --- a/app/static/js/modules/dom.js +++ /dev/null @@ -1,73 +0,0 @@ -/** - * DOM manipulation helpers. - */ - -/** - * Set the text content of an element by ID. - * @param {string} id - * @param {string | number} value - */ -export function setText(id, value) { - const el = document.getElementById(id); - if (el) el.textContent = String(value); -} - -/** - * Remove the `hidden` class from an element by ID. - * @param {string} id - */ -export function showEl(id) { - document.getElementById(id)?.classList.remove('hidden'); -} - -/** - * Add the `hidden` class to an element by ID. - * @param {string} id - */ -export function hideEl(id) { - document.getElementById(id)?.classList.add('hidden'); -} - -/** - * Disable a button, swap its label, run a callback, then restore. - * @param {HTMLButtonElement | null} btn - * @param {string} loadingText - * @param {() => Promise} callback - */ -/** - * Toggle the `hidden` class on an element by ID. - * @param {string} id - * @param {boolean} show - When true, remove `hidden`; when false, add it. - */ -export function toggleEl(id, show) { - document.getElementById(id)?.classList.toggle('hidden', !show); -} - -/** - * Append text to an element's textContent by ID. - * @param {string} id - * @param {string} value - */ -export function appendText(id, value) { - const el = document.getElementById(id); - if (el) el.textContent += String(value); -} - -/** - * Disable a button, swap its label, run a callback, then restore. - * @param {HTMLButtonElement | null} btn - * @param {string} loadingText - * @param {() => Promise} callback - */ -export async function withLoadingButton(btn, loadingText, callback) { - if (!btn) return; - const originalText = btn.textContent; - btn.disabled = true; - btn.textContent = loadingText; - try { - await callback(); - } finally { - btn.disabled = false; - btn.textContent = originalText; - } -} diff --git a/app/static/js/modules/file-browser.js b/app/static/js/modules/file-browser.js deleted file mode 100644 index cfc58ea..0000000 --- a/app/static/js/modules/file-browser.js +++ /dev/null @@ -1,205 +0,0 @@ -/** - * S3 file browser page functionality. - */ -import { apiGet } from './api.js'; -import { debounce } from './debounce.js'; -import { hideEl, setText, showEl } from './dom.js'; -import { formatBytes } from './formatters.js'; -import { fileIcon, folderIcon } from './icons.js'; -import { showNotification } from './notify.js'; -import state from './state.js'; - -export function initFileBrowser() { - const refreshBtn = document.getElementById('refresh-btn'); - const searchInput = /** @type {HTMLInputElement | null} */ ( - document.getElementById('search-input') - ); - const retryBtn = document.getElementById('retry-btn'); - const closeSearchBtn = document.getElementById('close-search-btn'); - - if (!refreshBtn) return; - - refreshBtn.addEventListener('click', () => loadFiles(state.currentPrefix)); - retryBtn?.addEventListener('click', () => loadFiles(state.currentPrefix)); - closeSearchBtn?.addEventListener('click', hideSearchResults); - - const debouncedSearch = debounce(() => { - const query = searchInput?.value.trim() || ''; - if (query.length >= 2) { - searchFiles(query); - } else { - hideSearchResults(); - } - }, 300); - searchInput?.addEventListener('input', debouncedSearch); - - loadSettings().then(() => loadFiles('')); -} - -async function loadSettings() { - try { - const settings = await apiGet('/api/settings'); - setText('bucket-name', settings.s3_bucket || 'No bucket configured'); - } catch (_error) { - setText('bucket-name', 'Error loading settings'); - } -} - -/** - * @param {string} prefix - */ -async function loadFiles(prefix) { - state.currentPrefix = prefix; - - showEl('loading-state'); - hideEl('error-state'); - hideEl('empty-state'); - hideEl('file-list'); - - try { - const data = await apiGet(`/api/files/list?prefix=${encodeURIComponent(prefix)}`); - - if (!data.success) { - throw new Error(data.error || 'Failed to load files'); - } - - hideEl('loading-state'); - - updateBreadcrumb(data.breadcrumbs || []); - - if (data.folders.length === 0 && data.files.length === 0) { - showEl('empty-state'); - return; - } - - displayFiles(data.folders, data.files); - } catch (error) { - hideEl('loading-state'); - showEl('error-state'); - setText('error-message', /** @type {Error} */ (error).message); - } -} - -/** - * @param {Array<{ name: string, prefix: string }>} breadcrumbs - */ -function updateBreadcrumb(breadcrumbs) { - const nav = document.getElementById('breadcrumb'); - if (!nav) return; - - nav.innerHTML = ` - Root - ${breadcrumbs - .map( - (b) => ` - / - ${b.name} - `, - ) - .join('')} - `; - - for (const link of nav.querySelectorAll('a')) { - link.addEventListener('click', (e) => { - e.preventDefault(); - loadFiles(/** @type {HTMLElement} */ (link).dataset.prefix || ''); - }); - } -} - -/** - * @param {Array<{ name: string, prefix: string }>} folders - * @param {Array<{ name: string, key: string, size: number, last_modified?: string }>} files - */ -function displayFiles(folders, files) { - const fileList = document.getElementById('file-list'); - if (!fileList) return; - - fileList.innerHTML = [ - ...folders.map( - (folder) => ` -
- ${folderIcon()} - ${folder.name}/ -
- `, - ), - ...files.map( - (file) => ` -
-
- ${fileIcon()} - ${file.name} -
-
- ${formatBytes(file.size)} - ${file.last_modified ? new Date(file.last_modified).toLocaleString() : '-'} -
-
- `, - ), - ].join(''); - - showEl('file-list'); - - for (const el of fileList.querySelectorAll('[data-prefix]')) { - el.addEventListener('click', () => - loadFiles(/** @type {HTMLElement} */ (el).dataset.prefix || ''), - ); - } -} - -/** - * @param {string} query - */ -async function searchFiles(query) { - try { - const data = await apiGet( - `/api/files/search?query=${encodeURIComponent(query)}&prefix=${encodeURIComponent(state.currentPrefix)}`, - ); - showSearchResults(data.files, query); - } catch (error) { - showNotification(/** @type {Error} */ (error).message, 'error'); - } -} - -/** - * @param {Array<{ name: string, key: string, size: number }>} files - * @param {string} _query - */ -function showSearchResults(files, _query) { - const container = document.getElementById('search-results'); - const list = document.getElementById('search-results-list'); - if (!container || !list) return; - - list.innerHTML = - files.length === 0 - ? '
No files found
' - : files - .map( - (file) => ` -
-
- ${fileIcon()} -
-
${file.name}
-
${file.key}
-
-
- ${formatBytes(file.size)} -
- `, - ) - .join(''); - - showEl('search-results'); -} - -function hideSearchResults() { - hideEl('search-results'); - - const searchInput = /** @type {HTMLInputElement | null} */ ( - document.getElementById('search-input') - ); - if (searchInput) searchInput.value = ''; -} diff --git a/app/static/js/modules/folder-browser.js b/app/static/js/modules/folder-browser.js deleted file mode 100644 index 55c0102..0000000 --- a/app/static/js/modules/folder-browser.js +++ /dev/null @@ -1,624 +0,0 @@ -import { connectCombinedProgressStream } from './analysis.js'; -import { apiGet, apiPost } from './api.js'; -import { hideEl, setText, showEl } from './dom.js'; -import { formatBytes, formatMtime } from './formatters.js'; -import { fileIcon, folderIcon } from './icons.js'; -import { showNotification } from './notify.js'; -import { toggleSort, updateSortIndicators } from './sorting-helpers.js'; -/** - * Inline folder browser and scan results for folder-based upload. - */ -import state from './state.js'; -import { setUploadStep, showUploadSteps } from './stepper.js'; - -/** - * Initialize the inline folder browser and auto-load initial folder. - */ -export async function initFolderBrowser() { - const panel = document.getElementById('folder-browser-panel'); - if (!panel) return; - - document.getElementById('select-folder-btn')?.addEventListener('click', selectCurrentFolder); - - // Delegated click handler for folder navigation (shared across 3 containers) - /** @param {Event} e */ - function handleFolderNavClick(e) { - const target = /** @type {HTMLElement} */ (e.target).closest('[data-action="navigate-folder"]'); - if (target) { - loadFolderBrowser(/** @type {HTMLElement} */ (target).dataset.path || ''); - } - } - - document.getElementById('folder-list')?.addEventListener('click', handleFolderNavClick); - document.getElementById('folder-quick-links')?.addEventListener('click', handleFolderNavClick); - document.getElementById('folder-breadcrumb')?.addEventListener('click', handleFolderNavClick); - - // Sort header click handler (Review table) - const scanSection = document.getElementById('scan-results-section'); - if (scanSection) { - scanSection.addEventListener('click', (e) => { - const th = /** @type {HTMLElement} */ (e.target).closest('[data-sort]'); - if (th) { - sortReviewTable(/** @type {HTMLElement} */ (th).dataset.sort || 'filename'); - } - }); - } - - // Sort header click handler (File browser table) - const fileTableHead = document.getElementById('file-table-head'); - if (fileTableHead) { - fileTableHead.addEventListener('click', (e) => { - const th = /** @type {HTMLElement} */ (e.target).closest('[data-file-sort]'); - if (th) { - sortBrowserFileTable(/** @type {HTMLElement} */ (th).dataset.fileSort || 'name'); - } - }); - } - - // Auto-load: use last folder or default from settings - const lastFolder = localStorage.getItem('lastUploadFolder'); - if (lastFolder) { - loadFolderBrowser(lastFolder); - } else { - try { - const settings = await apiGet('/api/settings'); - loadFolderBrowser(settings.default_upload_folder || ''); - } catch (_error) { - loadFolderBrowser(''); - } - } -} - -/** - * Load folder contents from the backend. - * Uses raw fetch for complex retry-on-failure logic. - * @param {string} path - * @param {boolean} [isRetry] - */ -export async function loadFolderBrowser(path, isRetry = false) { - hideEl('folder-list'); - hideEl('folder-error'); - showEl('folder-loading'); - - try { - const url = path ? `/api/files/browse?path=${encodeURIComponent(path)}` : '/api/files/browse'; - const response = await fetch(url); - const data = await response.json(); - - if (!response.ok) { - if (path && !isRetry) { - console.warn(`Failed to load saved folder "${path}", falling back to home`); - loadFolderBrowser('', true); - return; - } - throw new Error(data.error || 'Failed to load folder'); - } - - // Update quick links (using data-action instead of onclick) - const quickLinksContainer = document.getElementById('folder-quick-links'); - if (quickLinksContainer) { - const lastUsedFolder = localStorage.getItem('lastUploadFolder'); - let quickLinksHtml = ''; - - if (lastUsedFolder && lastUsedFolder !== data.current_path) { - const lastFolderName = lastUsedFolder.split('/').pop() || lastUsedFolder; - quickLinksHtml += ` - - `; - } - - quickLinksHtml += data.quick_links - .map( - (/** @type {{ name: string, path: string }} */ link) => ` - - `, - ) - .join(''); - - quickLinksContainer.innerHTML = quickLinksHtml; - } - - // Update breadcrumbs - const breadcrumbContainer = document.getElementById('folder-breadcrumb'); - if (breadcrumbContainer) { - breadcrumbContainer.innerHTML = data.breadcrumbs - .map( - (/** @type {{ name: string, path: string }} */ crumb, /** @type {number} */ i) => ` - ${i > 0 ? '/' : ''} - - `, - ) - .join(''); - } - - // Update MCAP count - setText('folder-mcap-count', data.mcap_count); - - // Enable select button and store current path - const selectFolderBtn = /** @type {HTMLButtonElement | null} */ ( - document.getElementById('select-folder-btn') - ); - if (selectFolderBtn) { - selectFolderBtn.disabled = false; - selectFolderBtn.dataset.path = data.current_path; - } - - // Update bottom summary with MCAP count - if (data.mcap_count > 0) { - setText( - 'folder-select-summary', - `${data.mcap_count} MCAP file${data.mcap_count === 1 ? '' : 's'} in this folder.`, - ); - } else { - setText( - 'folder-select-summary', - 'Navigate to the folder containing your MCAP files, then click Upload Folder.', - ); - } - - // Render folder list - hideEl('folder-loading'); - showEl('folder-list'); - - const folderList = document.getElementById('folder-list'); - if (folderList) { - if (data.folders.length === 0 && data.files.length === 0) { - folderList.innerHTML = - '
This folder is empty
'; - } else { - folderList.innerHTML = [ - data.parent_path - ? ` -
- - - - .. -
- ` - : '', - ...data.folders.map( - (/** @type {{ name: string, path: string, mcap_count: number }} */ folder) => ` -
-
- ${folderIcon()} - ${folder.name} -
- ${ - folder.mcap_count > 0 - ? `${folder.mcap_count} mcap` - : '' - } -
- `, - ), - ].join(''); - } - } - - // Render MCAP file table - if (data.files.length > 0) { - state.browserFiles = data.files; - state.browserFileSortConfig = state.browserFileSortConfig || { - column: 'name', - ascending: true, - }; - renderBrowserFileTable(); - showEl('file-table-section'); - } else { - state.browserFiles = []; - hideEl('file-table-section'); - } - } catch (error) { - hideEl('folder-loading'); - showEl('folder-error'); - setText('folder-error-message', /** @type {Error} */ (error).message); - } -} - -/** - * Select the current folder and scan for MCAP files. - */ -async function selectCurrentFolder() { - const btn = /** @type {HTMLButtonElement | null} */ ( - document.getElementById('select-folder-btn') - ); - if (!btn) return; - - const folderPath = btn.dataset.path; - if (!folderPath) return; - - btn.disabled = true; - const btnSpan = btn.querySelector('span'); - if (btnSpan) btnSpan.textContent = 'Scanning...'; - - try { - const data = await apiPost('/api/upload/scan-folder', { folder_path: folderPath }); - - if (data.total_count === 0) { - showNotification('No MCAP files found in this folder', 'error'); - return; - } - - state.selectedFolderPath = folderPath; - localStorage.setItem('lastUploadFolder', folderPath); - - const prefilterData = await apiPost('/api/upload/bulk-analyze', { - file_paths: data.files.map((/** @type {{ path: string }} */ f) => f.path), - pre_filter_only: true, - }); - - showScanResults(data, prefilterData.pre_filter_stats || {}); - } catch (error) { - showNotification(/** @type {Error} */ (error).message, 'error'); - } finally { - btn.disabled = false; - const resetSpan = btn.querySelector('span'); - if (resetSpan) resetSpan.textContent = 'Upload This Folder'; - } -} - -/** - * Show scan results UI with sortable review table. - * @param {any} scanData - * @param {any} prefilterStats - */ -function showScanResults(scanData, prefilterStats) { - showUploadSteps(2); - - // Store folder path for relative path computation - state.scanFolderPath = scanData.folder_path; - state.scanTotalSize = scanData.total_size || 0; - - setText('selected-folder-path', scanData.folder_path); - setText('scan-total', scanData.total_count); - setText('scan-total-volume', formatBytes(state.scanTotalSize)); - setText('scan-already-uploaded', prefilterStats.cache_skipped || 0); - - // Merge scan data files with prefilter statuses - const fileStatuses = prefilterStats.file_statuses || []; - /** @type {Map} */ - const prefilterMap = new Map(); - for (const fs of fileStatuses) { - prefilterMap.set(fs.path, fs); - } - - /** @type {Array} */ - const mergedStatuses = []; - for (const scanFile of scanData.files) { - const pf = prefilterMap.get(scanFile.path); - mergedStatuses.push({ - path: scanFile.path, - filename: scanFile.filename, - size: scanFile.size, - mtime: scanFile.mtime || 0, - relative_path: scanFile.relative_path || scanFile.filename, - already_uploaded: pf ? pf.already_uploaded : false, - }); - } - - state.scanFilePaths = mergedStatuses - .filter((/** @type {any} */ f) => !f.already_uploaded) - .map((/** @type {any} */ f) => f.path); - - state.scanFileStatuses = mergedStatuses; - state.reviewSortConfig = { column: 'filename', ascending: true }; - - renderReviewTable(mergedStatuses); - - const hideUploadedCheckbox = /** @type {HTMLInputElement | null} */ ( - document.getElementById('scan-hide-uploaded') - ); - if (hideUploadedCheckbox) { - hideUploadedCheckbox.checked = false; - hideUploadedCheckbox.onchange = () => applyScanFileFilter(); - } - - const continueBtn = /** @type {HTMLButtonElement | null} */ ( - document.getElementById('continue-upload-btn') - ); - if (continueBtn) { - continueBtn.disabled = state.scanFileStatuses.length === 0; - } - - hideEl('folder-browser-panel'); - showEl('scan-results-section'); -} - -/** - * Render the sortable review table body. - * @param {any[]} fileStatuses - */ -function renderReviewTable(fileStatuses) { - const tbody = document.getElementById('scan-file-list'); - if (!tbody) return; - - const { column, ascending } = state.reviewSortConfig; - - const sorted = [...fileStatuses].sort((a, b) => { - let cmp = 0; - if (column === 'filename') { - cmp = a.filename.localeCompare(b.filename); - } else if (column === 'path') { - const aDir = getDirectoryPart(a.relative_path); - const bDir = getDirectoryPart(b.relative_path); - cmp = aDir.localeCompare(bDir); - } else if (column === 'mtime') { - cmp = (a.mtime || 0) - (b.mtime || 0); - } else if (column === 'size') { - cmp = a.size - b.size; - } else if (column === 'status') { - cmp = Number(a.already_uploaded) - Number(b.already_uploaded); - } - return ascending ? cmp : -cmp; - }); - - tbody.innerHTML = sorted - .map((file) => { - const dirPath = getDirectoryPart(file.relative_path); - - return ` - - - - ${file.filename} - - - - ${dirPath || '.'} - - ${formatMtime(file.mtime)} - ${formatBytes(file.size)} - - - ${file.already_uploaded ? 'Uploaded' : 'To Upload'} - - - - `; - }) - .join(''); - - // Update sort indicators - updateSortIndicators('[data-sort]', '.sort-indicator', 'sort', column, ascending); - - // Re-apply filter - applyScanFileFilter(); -} - -/** - * Get the directory part of a relative path (everything before the filename). - * @param {string} relativePath - * @returns {string} - */ -function getDirectoryPart(relativePath) { - const lastSlash = relativePath.lastIndexOf('/'); - return lastSlash >= 0 ? relativePath.substring(0, lastSlash) : ''; -} - -/** - * Sort the review table by column. - * @param {string} column - */ -function sortReviewTable(column) { - toggleSort(state.reviewSortConfig, column); - renderReviewTable(state.scanFileStatuses); -} - -function applyScanFileFilter() { - const hideUploadedEl = /** @type {HTMLInputElement | null} */ ( - document.getElementById('scan-hide-uploaded') - ); - const hideUploaded = hideUploadedEl?.checked || false; - const tbody = document.getElementById('scan-file-list'); - if (!tbody) return; - - const rows = tbody.querySelectorAll('[data-already-uploaded]'); - for (const row of rows) { - if (hideUploaded && /** @type {HTMLElement} */ (row).dataset.alreadyUploaded === 'true') { - row.classList.add('hidden'); - } else { - row.classList.remove('hidden'); - } - } -} - -/** - * Show the confirm upload modal. - */ -export function showConfirmModal() { - if (!state.scanFileStatuses || state.scanFileStatuses.length === 0) { - showNotification('No files to upload', 'error'); - return; - } - - // Calculate size of non-uploaded files (default view) - updateConfirmModalCounts(false); - - // Wire up force-reupload checkbox to update counts dynamically - const checkbox = /** @type {HTMLInputElement | null} */ ( - document.getElementById('force-reupload-checkbox') - ); - if (checkbox) { - checkbox.checked = false; - checkbox.onchange = () => updateConfirmModalCounts(checkbox.checked); - } - - showEl('confirm-skip-note'); - showEl('confirm-upload-modal'); -} - -/** - * Update confirm modal file count/size based on force-reupload state. - * @param {boolean} forceReupload - */ -function updateConfirmModalCounts(forceReupload) { - let uploadSize = 0; - let uploadCount = 0; - for (const file of state.scanFileStatuses) { - if (forceReupload || !file.already_uploaded) { - uploadSize += file.size; - uploadCount++; - } - } - - setText('confirm-file-count', uploadCount); - setText('confirm-total-size', formatBytes(uploadSize)); - - if (forceReupload) { - hideEl('confirm-skip-note'); - } else { - showEl('confirm-skip-note'); - } -} - -/** - * Start the combined validate + upload flow. - */ -export async function startCombinedUpload() { - // Close confirm modal - hideEl('confirm-upload-modal'); - - // Check force-reupload state - const forceCheckbox = /** @type {HTMLInputElement | null} */ ( - document.getElementById('force-reupload-checkbox') - ); - const forceReupload = forceCheckbox?.checked || false; - - // Determine which file paths to send - const filePaths = forceReupload - ? state.scanFileStatuses.map((/** @type {any} */ f) => f.path) - : state.scanFilePaths; - - if (!filePaths || filePaths.length === 0) { - showNotification('No files to upload', 'error'); - return; - } - - setUploadStep(3); - - hideEl('scan-results-section'); - showEl('upload-section'); - - // Set phase label - setText('upload-phase-label', 'Validating files...'); - - // Initialize file list with pending status - initUploadFileList(filePaths); - - try { - /** @type {Record} */ - const requestBody = { - file_paths: filePaths, - auto_upload: true, - }; - if (forceReupload) { - requestBody.skip_duplicates = false; - } - - const data = await apiPost('/api/upload/bulk-analyze', requestBody); - - state.currentJobId = data.job_id; - setText('files-total', data.total_files); - - connectCombinedProgressStream(data.job_id); - } catch (error) { - showNotification(/** @type {Error} */ (error).message, 'error'); - } -} - -/** - * Render the sortable file table in the folder browser. - */ -function renderBrowserFileTable() { - const tbody = document.getElementById('file-table-body'); - if (!tbody || !state.browserFiles) return; - - const { column, ascending } = state.browserFileSortConfig; - - const sorted = [...state.browserFiles].sort((a, b) => { - let cmp = 0; - if (column === 'name') { - cmp = a.name.localeCompare(b.name); - } else if (column === 'mtime') { - cmp = (a.mtime || 0) - (b.mtime || 0); - } else if (column === 'size') { - cmp = a.size - b.size; - } - return ascending ? cmp : -cmp; - }); - - tbody.innerHTML = sorted - .map( - (/** @type {{ name: string, size: number, mtime: number }} */ file) => ` - - -
- ${fileIcon('h-4 w-4 text-gray-400 mr-2 flex-shrink-0')} - ${file.name} -
- - ${formatMtime(file.mtime)} - ${formatBytes(file.size)} - - `, - ) - .join(''); - - // Update sort indicators - updateSortIndicators('[data-file-sort]', '.file-sort-indicator', 'fileSort', column, ascending); -} - -/** - * Sort the browser file table by column. - * @param {string} column - */ -function sortBrowserFileTable(column) { - if (!state.browserFileSortConfig) { - state.browserFileSortConfig = { column, ascending: true }; - } else { - toggleSort(state.browserFileSortConfig, column); - } - renderBrowserFileTable(); -} - -/** - * Initialize the upload file list with queued status. - * @param {string[]} filePaths - */ -function initUploadFileList(filePaths) { - const listEl = document.getElementById('upload-file-list'); - if (!listEl) return; - - const total = filePaths.length; - listEl.innerHTML = filePaths - .map((path, index) => { - const filename = path.split('/').pop() || path; - return ` -
-
-
- - - -
- ${filename} -
- Queued (${index + 1} of ${total}) -
- `; - }) - .join(''); -} diff --git a/app/static/js/modules/formatters.js b/app/static/js/modules/formatters.js deleted file mode 100644 index d3ceaa8..0000000 --- a/app/static/js/modules/formatters.js +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Pure formatting functions for bytes, time, and dates. - */ - -/** - * @param {number} bytes - * @returns {string} - */ -export function formatBytes(bytes) { - if (bytes === 0) return '0 B'; - const k = 1024; - const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; - const i = Math.floor(Math.log(bytes) / Math.log(k)); - return `${Number.parseFloat((bytes / k ** i).toFixed(1))} ${sizes[i]}`; -} - -/** - * @param {number | null | undefined} seconds - * @returns {string} - */ -export function formatEta(seconds) { - if (!seconds || seconds < 0) return 'Calculating...'; - if (seconds < 60) return `${seconds}s`; - if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${seconds % 60}s`; - const hours = Math.floor(seconds / 3600); - const mins = Math.floor((seconds % 3600) / 60); - return `${hours}h ${mins}m`; -} - -/** - * @param {number} seconds - * @returns {string} - */ -export function formatDuration(seconds) { - if (seconds < 1) { - return `${Math.round(seconds * 1000)}ms`; - } - if (seconds < 60) { - return `${seconds.toFixed(1)}s`; - } - const mins = Math.floor(seconds / 60); - const secs = Math.round(seconds % 60); - return `${mins}m ${secs}s`; -} - -/** - * Format a Unix epoch (seconds) into a locale date string. - * @param {number | null | undefined} epochSeconds - * @returns {string} - */ -export function formatMtime(epochSeconds) { - if (!epochSeconds) return '-'; - return new Date(epochSeconds * 1000).toLocaleString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); -} diff --git a/app/static/js/modules/icons.js b/app/static/js/modules/icons.js deleted file mode 100644 index d9bc569..0000000 --- a/app/static/js/modules/icons.js +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Reusable SVG icon templates. - */ - -const FILE_ICON_PATH = - 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z'; - -const FOLDER_ICON_PATH = 'M2 6a2 2 0 012-2h5l2 2h5a2 2 0 012 2v6a2 2 0 01-2 2H4a2 2 0 01-2-2V6z'; - -/** - * File document SVG icon. - * @param {string} [cls] - CSS classes (default: 'h-5 w-5 text-gray-400 mr-3') - * @returns {string} - */ -export function fileIcon(cls = 'h-5 w-5 text-gray-400 mr-3') { - return ``; -} - -/** - * Folder SVG icon. - * @param {string} [cls] - CSS classes (default: 'h-5 w-5 text-nlr-yellow mr-3') - * @returns {string} - */ -export function folderIcon(cls = 'h-5 w-5 text-nlr-yellow mr-3') { - return ``; -} diff --git a/app/static/js/modules/logs.js b/app/static/js/modules/logs.js deleted file mode 100644 index 1a65cfc..0000000 --- a/app/static/js/modules/logs.js +++ /dev/null @@ -1,425 +0,0 @@ -/** - * Logs viewer page module. - * - * Loads log entries from /api/logs/entries with filtering, pagination, - * expandable detail rows, and S3 sync trigger. - */ -import { apiGet, apiPost } from './api.js'; -import { debounce } from './debounce.js'; -import { hideEl, setText, toggleEl, withLoadingButton } from './dom.js'; -import { showNotification } from './notify.js'; -import state from './state.js'; - -/** - * Format an ISO timestamp for display. - * @param {string} iso - * @returns {string} - */ -function formatTimestamp(iso) { - try { - const d = new Date(iso); - return d.toLocaleString(undefined, { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - }); - } catch { - return iso; - } -} - -/** - * Return Tailwind classes for a log level badge. - * @param {string} level - * @returns {string} - */ -function levelBadgeClass(level) { - const upper = level.toUpperCase(); - if (upper === 'ERROR') return 'bg-red-100 text-red-800'; - if (upper === 'WARNING') return 'bg-yellow-100 text-yellow-800'; - return 'bg-blue-100 text-blue-800'; -} - -/** - * Get the filter DOM elements (shared by clearFilters and applyFilters). - */ -function getFilterEls() { - return { - date: /** @type {HTMLInputElement | null} */ (document.getElementById('filter-date')), - level: /** @type {HTMLSelectElement | null} */ (document.getElementById('filter-level')), - category: /** @type {HTMLSelectElement | null} */ (document.getElementById('filter-category')), - search: /** @type {HTMLInputElement | null} */ (document.getElementById('filter-search')), - }; -} - -/** - * Render the log table body from an array of entries. - * @param {Array>} entries - */ -function renderLogTable(entries) { - const tbody = document.getElementById('log-table-body'); - if (!tbody) return; - - if (entries.length === 0) { - tbody.innerHTML = - 'No log entries found'; - return; - } - - let html = ''; - for (const entry of entries) { - const ts = formatTimestamp(/** @type {string} */ (entry.timestamp) || ''); - const level = /** @type {string} */ (entry.level) || 'INFO'; - const category = /** @type {string} */ (entry.category) || ''; - const event = /** @type {string} */ (entry.event) || ''; - const message = /** @type {string} */ (entry.message) || ''; - const metadata = /** @type {Record | undefined} */ (entry.metadata); - const hasMetadata = metadata && Object.keys(metadata).length > 0; - - const rowClass = hasMetadata ? 'cursor-pointer hover:bg-gray-50' : ''; - const toggleAttr = hasMetadata ? 'data-log-toggle' : ''; - - html += ` - ${ts} - ${level} - ${category} - ${event} - ${message} - `; - - if (hasMetadata) { - const metaRows = Object.entries(metadata) - .map( - ([k, v]) => - `${k}${typeof v === 'object' ? JSON.stringify(v) : String(v)}`, - ) - .join(''); - - html += ` - - ${metaRows}
- - `; - } - } - - tbody.innerHTML = html; - - // Wire toggle for expandable rows - for (const row of tbody.querySelectorAll('[data-log-toggle]')) { - row.addEventListener('click', () => { - const detail = row.nextElementSibling; - if (detail?.classList.contains('log-detail-row')) { - detail.classList.toggle('hidden'); - } - }); - } -} - -/** Fetch and render log entries using current filters + pagination. */ -export async function loadLogEntries() { - const params = new URLSearchParams(); - const { logFilters, logPagination } = state; - - if (logFilters.date) params.set('date', logFilters.date); - if (logFilters.level) params.set('level', logFilters.level); - if (logFilters.category) params.set('category', logFilters.category); - if (logFilters.search) params.set('search', logFilters.search); - params.set('offset', String(logPagination.offset)); - params.set('limit', String(logPagination.limit)); - - try { - const data = await apiGet(`/api/logs/entries?${params}`); - - renderLogTable(data.entries || []); - - // Update count label - const total = data.total || 0; - const from = total > 0 ? data.offset + 1 : 0; - const to = Math.min(data.offset + data.limit, total); - setText('log-count-label', `Showing ${from}-${to} of ${total}`); - - // Pagination buttons - const prevBtn = /** @type {HTMLButtonElement | null} */ ( - document.getElementById('prev-page-btn') - ); - const nextBtn = /** @type {HTMLButtonElement | null} */ ( - document.getElementById('next-page-btn') - ); - if (prevBtn) prevBtn.disabled = data.offset <= 0; - if (nextBtn) nextBtn.disabled = data.offset + data.limit >= total; - } catch { - setText('log-count-label', 'Failed to load log entries'); - } -} - -/** Fetch and render log stats into the stat cards. */ -export async function loadLogStats() { - try { - const data = await apiGet('/api/logs/stats'); - - setText('stat-total', data.total_entries ?? 0); - setText('stat-today', data.today_entries ?? 0); - setText('stat-errors', data.level_counts?.ERROR ?? 0); - setText('stat-files', data.file_count ?? 0); - } catch { - // Silently fail — stats are non-critical - } -} - -/** POST /api/logs/sync and show notification. */ -export async function syncLogs() { - const btn = /** @type {HTMLButtonElement | null} */ (document.getElementById('sync-logs-btn')); - - await withLoadingButton(btn, 'Syncing...', async () => { - try { - const data = await apiPost('/api/logs/sync'); - - if (data.success) { - showNotification(`Synced ${data.synced} log files to S3`, 'success'); - } else { - showNotification(`Sync failed: ${data.error || 'Unknown error'}`, 'error'); - } - } catch (err) { - showNotification(`Sync error: ${err}`, 'error'); - } - }); -} - -/** Track which CSV is currently previewed (to toggle on re-click). */ -let currentPreviewPath = ''; - -/** - * Extract time (HH:MM:SS) from a CSV filename like upload-summary-143022-abcd1234.csv. - * @param {string} filename - * @returns {string} - */ -function extractTimeFromFilename(filename) { - const match = filename.match(/upload-summary-(\d{2})(\d{2})(\d{2})/); - if (match) return `${match[1]}:${match[2]}:${match[3]}`; - return '-'; -} - -/** Fetch CSV file list and render the Upload Summaries table. */ -export async function loadCsvFiles() { - try { - const data = await apiGet('/api/logs/files'); - /** @type {Array<{date: string|null, filename: string, relative_path: string, size_bytes: number, type: string}>} */ - const csvFiles = (data.files || []).filter( - (/** @type {{type: string}} */ f) => f.type === 'csv', - ); - - // Update badge - setText('csv-count-badge', csvFiles.length); - toggleEl('csv-count-badge', csvFiles.length > 0); - - const tbody = document.getElementById('csv-table-body'); - if (!tbody) return; - - if (csvFiles.length === 0) { - tbody.innerHTML = - 'No upload summaries found'; - return; - } - - let html = ''; - for (const file of csvFiles) { - const date = file.date || '-'; - const time = extractTimeFromFilename(file.filename); - const escapedPath = file.relative_path.replace(/"/g, '"'); - - html += ` - ${date} - ${time} - ${file.filename} - - - - - `; - } - tbody.innerHTML = html; - - // Wire download buttons - for (const btn of tbody.querySelectorAll('.csv-download-btn')) { - btn.addEventListener('click', () => { - const path = /** @type {HTMLElement} */ (btn).dataset.path || ''; - downloadCsv(path); - }); - } - - // Wire view buttons - for (const btn of tbody.querySelectorAll('.csv-view-btn')) { - btn.addEventListener('click', () => { - const path = /** @type {HTMLElement} */ (btn).dataset.path || ''; - previewCsv(path); - }); - } - } catch { - // Non-critical — silently fail - } -} - -/** - * Trigger a browser download for a CSV file. - * @param {string} path - Relative path within the log directory - */ -function downloadCsv(path) { - window.location.href = `/api/logs/csv-download?path=${encodeURIComponent(path)}`; -} - -/** - * Fetch and render a CSV preview inline. Toggle on re-click. - * @param {string} path - Relative path within the log directory - */ -async function previewCsv(path) { - const panel = document.getElementById('csv-preview-panel'); - const content = document.getElementById('csv-preview-content'); - if (!panel || !content) return; - - // Toggle off if clicking the same file - if (currentPreviewPath === path && !panel.classList.contains('hidden')) { - hideEl('csv-preview-panel'); - currentPreviewPath = ''; - return; - } - - currentPreviewPath = path; - content.innerHTML = '

Loading...

'; - panel.classList.remove('hidden'); - - const filename = path.split('/').pop() || path; - setText('csv-preview-title', `Preview: ${filename}`); - - try { - const data = await apiGet(`/api/logs/csv-preview?path=${encodeURIComponent(path)}`); - - if (data.error) { - content.innerHTML = `

${data.error}

`; - return; - } - - const columns = /** @type {string[]} */ (data.columns || []); - const rows = /** @type {Array>} */ (data.rows || []); - - if (columns.length === 0) { - content.innerHTML = '

Empty CSV

'; - return; - } - - let tableHtml = ''; - tableHtml += ''; - for (const col of columns) { - tableHtml += ``; - } - tableHtml += ''; - for (const row of rows) { - tableHtml += ''; - for (const col of columns) { - const val = row[col] ?? ''; - tableHtml += ``; - } - tableHtml += ''; - } - tableHtml += '
${col}
${val}
'; - content.innerHTML = tableHtml; - } catch { - content.innerHTML = '

Failed to load CSV preview

'; - } -} - -/** Reset all filters and reload. */ -export function clearFilters() { - state.logFilters = { date: null, level: null, category: null, search: '' }; - state.logPagination = { offset: 0, limit: 100 }; - - const { date, level, category, search } = getFilterEls(); - if (date) date.value = ''; - if (level) level.value = ''; - if (category) category.value = ''; - if (search) search.value = ''; - - loadLogEntries(); - loadLogStats(); -} - -/** Read current filter values from the DOM into state and reload. */ -function applyFilters() { - const { date, level, category, search } = getFilterEls(); - - state.logFilters.date = date?.value || null; - state.logFilters.level = level?.value || null; - state.logFilters.category = category?.value || null; - state.logFilters.search = search?.value || ''; - state.logPagination.offset = 0; - - loadLogEntries(); -} - -/** Initialize the logs page: load data and wire event handlers. */ -export function initLogs() { - // Reset state - state.logFilters = { date: null, level: null, category: null, search: '' }; - state.logPagination = { offset: 0, limit: 100 }; - - // Initial load - loadLogEntries(); - loadLogStats(); - loadCsvFiles(); - - // CSV section toggle - const csvToggle = document.getElementById('csv-section-toggle'); - const csvBody = document.getElementById('csv-section-body'); - const csvIcon = document.getElementById('csv-toggle-icon'); - if (csvToggle && csvBody) { - csvToggle.addEventListener('click', () => { - csvBody.classList.toggle('hidden'); - csvIcon?.classList.toggle('rotate-90'); - }); - } - - // CSV preview close button - document.getElementById('csv-preview-close')?.addEventListener('click', () => { - hideEl('csv-preview-panel'); - currentPreviewPath = ''; - }); - - // Filter controls - const { date: dateEl, level: levelEl, category: catEl, search: searchEl } = getFilterEls(); - const clearBtn = document.getElementById('clear-filters-btn'); - const syncBtn = document.getElementById('sync-logs-btn'); - const prevBtn = document.getElementById('prev-page-btn'); - const nextBtn = document.getElementById('next-page-btn'); - - if (dateEl) dateEl.addEventListener('change', applyFilters); - if (levelEl) levelEl.addEventListener('change', applyFilters); - if (catEl) catEl.addEventListener('change', applyFilters); - - // Debounced search - if (searchEl) { - searchEl.addEventListener('input', debounce(applyFilters, 300)); - } - - if (clearBtn) clearBtn.addEventListener('click', clearFilters); - if (syncBtn) syncBtn.addEventListener('click', syncLogs); - - if (prevBtn) { - prevBtn.addEventListener('click', () => { - state.logPagination.offset = Math.max( - 0, - state.logPagination.offset - state.logPagination.limit, - ); - loadLogEntries(); - }); - } - - if (nextBtn) { - nextBtn.addEventListener('click', () => { - state.logPagination.offset += state.logPagination.limit; - loadLogEntries(); - }); - } -} diff --git a/app/static/js/modules/notify.js b/app/static/js/modules/notify.js deleted file mode 100644 index e121a5b..0000000 --- a/app/static/js/modules/notify.js +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Toast notification system. - */ - -/** - * @param {string} message - * @param {'info' | 'error' | 'success'} [type] - */ -export function showNotification(message, type = 'info') { - const notification = document.createElement('div'); - notification.className = `fixed top-4 right-4 px-6 py-3 rounded-md shadow-lg z-50 transition-opacity duration-300 ${ - type === 'error' - ? 'bg-red-500 text-white' - : type === 'success' - ? 'bg-green-500 text-white' - : 'bg-nlr-blue text-white' - }`; - notification.textContent = message; - document.body.appendChild(notification); - - setTimeout(() => { - notification.classList.add('opacity-0'); - setTimeout(() => notification.remove(), 300); - }, 5000); -} diff --git a/app/static/js/modules/settings.js b/app/static/js/modules/settings.js deleted file mode 100644 index 06c3ea5..0000000 --- a/app/static/js/modules/settings.js +++ /dev/null @@ -1,402 +0,0 @@ -import { apiGet, apiPost, apiPut } from './api.js'; -import { appendText, setText, showEl, withLoadingButton } from './dom.js'; -import { showNotification } from './notify.js'; -/** - * Settings page functionality. - */ -import state from './state.js'; - -const AWS_REGION_OPTIONS = new Set([ - 'us-west-2', - 'us-west-1', - 'us-east-1', - 'us-east-2', - 'us-gov-west-1', - 'us-gov-east-1', -]); - -function getAwsRegion() { - const select = /** @type {HTMLSelectElement | null} */ ( - document.getElementById('aws-region-select') - ); - if (select?.value === 'other') { - const custom = /** @type {HTMLInputElement | null} */ ( - document.getElementById('aws-region-custom') - ); - return custom?.value.trim() || ''; - } - return select?.value || 'us-west-2'; -} - -/** - * @param {string} region - */ -function setAwsRegion(region) { - const select = /** @type {HTMLSelectElement | null} */ ( - document.getElementById('aws-region-select') - ); - const customInput = document.getElementById('aws-region-custom'); - const helpText = document.getElementById('aws-region-help'); - - if (!select || !customInput || !helpText) return; - - if (AWS_REGION_OPTIONS.has(region)) { - select.value = region; - customInput.classList.add('hidden'); - helpText.classList.add('hidden'); - /** @type {HTMLInputElement} */ (customInput).value = ''; - } else { - select.value = 'other'; - /** @type {HTMLInputElement} */ (customInput).value = region; - customInput.classList.remove('hidden'); - helpText.classList.remove('hidden'); - } -} - -export function initSettings() { - const form = document.getElementById('settings-form'); - if (!form) return; - - document.getElementById('aws-region-select')?.addEventListener('change', (e) => { - const customInput = document.getElementById('aws-region-custom'); - const helpText = document.getElementById('aws-region-help'); - if (/** @type {HTMLSelectElement} */ (e.target).value === 'other') { - customInput?.classList.remove('hidden'); - helpText?.classList.remove('hidden'); - /** @type {HTMLInputElement} */ (customInput)?.focus(); - } else { - customInput?.classList.add('hidden'); - helpText?.classList.add('hidden'); - if (customInput) /** @type {HTMLInputElement} */ (customInput).value = ''; - } - }); - - loadCurrentSettings(); - loadAwsProfiles(); - loadVersionInfo(); - loadCacheStats(); - - form.addEventListener('submit', async (e) => { - e.preventDefault(); - await saveSettings(); - }); - - document.getElementById('test-connection-btn')?.addEventListener('click', testConnection); - document.getElementById('check-updates-btn')?.addEventListener('click', checkForUpdates); - document.getElementById('run-update-btn')?.addEventListener('click', runUpdate); - document.getElementById('reset-settings-btn')?.addEventListener('click', resetSettings); - document.getElementById('clear-cache-btn')?.addEventListener('click', clearBrowserCache); - document.getElementById('sync-cache-btn')?.addEventListener('click', syncCacheWithAws); - document.getElementById('invalidate-cache-btn')?.addEventListener('click', invalidateUploadCache); -} - -async function loadCurrentSettings() { - try { - const settings = await apiGet('/api/settings'); - - setAwsRegion(settings.aws_region || 'us-west-2'); - - const s3Bucket = /** @type {HTMLInputElement | null} */ (document.getElementById('s3-bucket')); - if (s3Bucket) s3Bucket.value = settings.s3_bucket || ''; - - const defaultFolder = /** @type {HTMLInputElement | null} */ ( - document.getElementById('default-folder') - ); - if (defaultFolder) defaultFolder.value = settings.default_upload_folder || ''; - - state.currentAwsProfile = settings.aws_profile; - } catch (_error) { - showNotification('Failed to load settings', 'error'); - } -} - -async function loadAwsProfiles() { - try { - const data = await apiGet('/api/settings/profiles'); - - const select = /** @type {HTMLSelectElement | null} */ (document.getElementById('aws-profile')); - if (!select) return; - - select.innerHTML = data.profiles - .map((/** @type {string} */ profile) => ``) - .join(''); - - if (state.currentAwsProfile) { - select.value = state.currentAwsProfile; - } - } catch (_error) { - showNotification('Failed to load AWS profiles', 'error'); - } -} - -async function loadVersionInfo() { - try { - const data = await apiGet('/api/settings/version'); - - setText('git-branch', data.branch || '-'); - setText('git-commit', data.commit || '-'); - setText('git-date', data.last_updated || '-'); - setText('pkg-version', data.version || '-'); - - let versionText = data.version || '0.0.0'; - if (data.commit) { - versionText += ` (${data.commit})`; - } - setText('version-info', versionText); - } catch (error) { - console.error('Failed to load version info:', error); - } -} - -async function saveSettings() { - const settings = { - aws_profile: /** @type {HTMLSelectElement} */ (document.getElementById('aws-profile'))?.value, - aws_region: getAwsRegion(), - s3_bucket: /** @type {HTMLInputElement} */ (document.getElementById('s3-bucket'))?.value, - default_upload_folder: /** @type {HTMLInputElement} */ ( - document.getElementById('default-folder') - )?.value, - }; - - try { - await apiPut('/api/settings', settings); - showNotification('Settings saved successfully', 'success'); - } catch (error) { - showNotification(/** @type {Error} */ (error).message, 'error'); - } -} - -async function testConnection() { - const btn = /** @type {HTMLButtonElement | null} */ ( - document.getElementById('test-connection-btn') - ); - const status = document.getElementById('connection-status'); - if (!btn || !status) return; - - status.textContent = 'Testing connection...'; - status.className = 'mt-1 text-sm text-gray-500'; - - const settings = { - aws_profile: /** @type {HTMLSelectElement} */ (document.getElementById('aws-profile'))?.value, - aws_region: getAwsRegion(), - s3_bucket: /** @type {HTMLInputElement} */ (document.getElementById('s3-bucket'))?.value, - }; - - await withLoadingButton(btn, 'Testing...', async () => { - try { - const data = await apiPost('/api/settings/validate', settings); - status.textContent = data.success ? data.message : data.error; - status.className = `mt-1 text-sm ${data.success ? 'text-green-600' : 'text-red-600'}`; - } catch (error) { - status.textContent = /** @type {Error} */ (error).message; - status.className = 'mt-1 text-sm text-red-600'; - } - }); -} - -async function checkForUpdates() { - const btn = /** @type {HTMLButtonElement | null} */ ( - document.getElementById('check-updates-btn') - ); - const status = document.getElementById('update-status'); - const updateBtn = /** @type {HTMLButtonElement | null} */ ( - document.getElementById('run-update-btn') - ); - if (!btn || !status) return; - - await withLoadingButton(btn, 'Checking...', async () => { - try { - const data = await apiGet('/api/settings/check-updates'); - - if (data.error) { - status.textContent = `Error: ${data.error}`; - status.className = 'text-sm text-red-600'; - } else if (data.updates_available) { - status.textContent = 'Updates available! Click "Update Application" to install.'; - status.className = 'text-sm text-nlr-yellow font-medium'; - if (updateBtn) updateBtn.disabled = false; - } else if (data.up_to_date) { - status.textContent = 'Application is up to date.'; - status.className = 'text-sm text-green-600'; - } else { - status.textContent = 'Could not determine update status.'; - status.className = 'text-sm text-gray-600'; - } - } catch (error) { - status.textContent = /** @type {Error} */ (error).message; - status.className = 'text-sm text-red-600'; - } - }); -} - -async function runUpdate() { - const btn = /** @type {HTMLButtonElement | null} */ (document.getElementById('run-update-btn')); - const status = document.getElementById('update-status'); - if (!btn || !status) return; - - status.textContent = 'Running update...'; - showEl('update-log'); - setText('update-output', 'Starting update...\n'); - - await withLoadingButton(btn, 'Updating...', async () => { - try { - const data = await apiPost('/api/settings/update'); - - let output = ''; - - if (data.results.git_pull) { - output += '=== Git Pull ===\n'; - output += data.results.git_pull.success ? '[SUCCESS]\n' : '[FAILED]\n'; - output += `${data.results.git_pull.output}\n\n`; - } - - if (data.results.pip_install) { - output += '=== Pip Install ===\n'; - output += data.results.pip_install.success ? '[SUCCESS]\n' : '[FAILED]\n'; - output += `${data.results.pip_install.output}\n\n`; - } - - if (data.results.modaq_toolkit) { - output += '=== MODAQ Toolkit Update ===\n'; - output += data.results.modaq_toolkit.success ? '[SUCCESS]\n' : '[FAILED]\n'; - output += `${data.results.modaq_toolkit.output}\n`; - } - - setText('update-output', output); - - if (data.success) { - status.textContent = 'Update completed! Restart the application to apply changes.'; - status.className = 'text-sm text-green-600 font-medium'; - showNotification('Update completed! Please restart the application.', 'success'); - } else { - status.textContent = 'Update completed with some errors. Check the log below.'; - status.className = 'text-sm text-yellow-600'; - } - - loadVersionInfo(); - } catch (error) { - status.textContent = /** @type {Error} */ (error).message; - status.className = 'text-sm text-red-600'; - appendText('update-output', `\nError: ${/** @type {Error} */ (error).message}`); - } - }); -} - -async function resetSettings() { - if (!confirm('Are you sure you want to reset all settings to defaults?')) { - return; - } - - try { - await apiPut('/api/settings', { - aws_profile: 'default', - aws_region: 'us-west-2', - s3_bucket: '', - default_upload_folder: '', - }); - - loadCurrentSettings(); - showNotification('Settings reset to defaults', 'success'); - } catch (error) { - showNotification(/** @type {Error} */ (error).message, 'error'); - } -} - -function clearBrowserCache() { - if ( - !confirm( - 'Are you sure you want to clear the browser cache? This will forget your last used folder and other local preferences.', - ) - ) { - return; - } - - try { - localStorage.removeItem('lastUploadFolder'); - showNotification('Browser cache cleared', 'success'); - } catch (error) { - showNotification(`Failed to clear cache: ${/** @type {Error} */ (error).message}`, 'error'); - } -} - -async function loadCacheStats() { - try { - const data = await apiGet('/api/settings/cache/stats'); - - if (data.success && data.stats) { - const stats = data.stats; - - setText('cache-total', stats.total_entries || 0); - setText('cache-exists', stats.exists_count || 0); - setText('cache-deleted', stats.not_exists_count || 0); - - if (stats.last_full_sync) { - setText('cache-last-sync', new Date(stats.last_full_sync).toLocaleString()); - } else { - setText('cache-last-sync', 'Never'); - } - } - } catch (error) { - console.error('Failed to load cache stats:', error); - } -} - -async function syncCacheWithAws() { - const btn = /** @type {HTMLButtonElement | null} */ (document.getElementById('sync-cache-btn')); - const status = document.getElementById('sync-status'); - if (!btn || !status) return; - - status.classList.remove('hidden'); - status.textContent = 'Fetching file list from S3...'; - status.className = 'mt-2 text-sm text-gray-600'; - - await withLoadingButton(btn, 'Syncing...', async () => { - try { - const data = await apiPost('/api/settings/cache/sync'); - - if (data.success) { - status.textContent = data.message; - status.className = 'mt-2 text-sm text-green-600'; - showNotification(data.message, 'success'); - loadCacheStats(); - } else { - status.textContent = `Error: ${data.error}`; - status.className = 'mt-2 text-sm text-red-600'; - showNotification(data.error, 'error'); - } - } catch (error) { - status.textContent = `Error: ${/** @type {Error} */ (error).message}`; - status.className = 'mt-2 text-sm text-red-600'; - showNotification(/** @type {Error} */ (error).message, 'error'); - } - }); -} - -async function invalidateUploadCache() { - if ( - !confirm( - 'Are you sure you want to clear the upload cache? This will delete all cached file records for the current bucket. The next upload will need to re-check all files against S3.', - ) - ) { - return; - } - - const btn = /** @type {HTMLButtonElement | null} */ ( - document.getElementById('invalidate-cache-btn') - ); - - await withLoadingButton(btn, 'Clearing...', async () => { - try { - const data = await apiPost('/api/settings/cache/invalidate'); - - if (data.success) { - showNotification(data.message, 'success'); - loadCacheStats(); - } else { - showNotification(data.error || 'Failed to clear cache', 'error'); - } - } catch (error) { - showNotification(/** @type {Error} */ (error).message, 'error'); - } - }); -} diff --git a/app/static/js/modules/sorting-helpers.js b/app/static/js/modules/sorting-helpers.js deleted file mode 100644 index 4ce1d70..0000000 --- a/app/static/js/modules/sorting-helpers.js +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Table sorting helpers: toggle column direction and update header indicators. - */ - -/** - * Toggle sort direction on a column config object. - * @param {{ column: string, ascending: boolean }} config - * @param {string} column - */ -export function toggleSort(config, column) { - if (config.column === column) { - config.ascending = !config.ascending; - } else { - config.column = column; - config.ascending = true; - } -} - -/** - * Update sort indicator arrows in table headers. - * @param {string} headerSelector - e.g. `'[data-sort]'` - * @param {string} indicatorSelector - e.g. `'.sort-indicator'` - * @param {string} dataKey - dataset key, e.g. `'sort'` or `'fileSort'` - * @param {string} currentColumn - * @param {boolean} ascending - */ -export function updateSortIndicators( - headerSelector, - indicatorSelector, - dataKey, - currentColumn, - ascending, -) { - for (const th of document.querySelectorAll(headerSelector)) { - const indicator = th.querySelector(indicatorSelector); - if (!indicator) continue; - const col = /** @type {HTMLElement} */ (th).dataset[dataKey]; - indicator.textContent = col === currentColumn ? (ascending ? ' \u25B2' : ' \u25BC') : ''; - } -} diff --git a/app/static/js/modules/state.js b/app/static/js/modules/state.js deleted file mode 100644 index d2e9107..0000000 --- a/app/static/js/modules/state.js +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Centralized mutable state for the modaq_upload. - * Replaces all global `let` variables and `window.*` properties. - */ -const state = { - /** @type {string | null} */ - currentJobId: null, - - /** @type {EventSource | null} */ - eventSource: null, - - /** @type {string | null} */ - selectedFolderPath: null, - - /** Current upload step (1-4) */ - currentStep: 1, - - /** S3 file browser current prefix */ - currentPrefix: '', - - /** @type {{ version?: string, commit?: string, branch?: string } | null} */ - appVersionData: null, - - /** @type {string | undefined} */ - currentAwsProfile: undefined, - - /** @type {string[]} */ - scanFilePaths: [], - - /** @type {Array<{ path: string, filename: string, size: number, mtime?: number, already_uploaded: boolean }>} */ - scanFileStatuses: [], - - /** Total size of all scanned files in bytes */ - scanTotalSize: 0, - - /** @type {string | null} */ - scanFolderPath: null, - - /** Sort configuration for the review table */ - reviewSortConfig: { column: 'filename', ascending: true }, - - /** @type {Array<{ name: string, size: number, mtime: number }>} */ - browserFiles: [], - - /** Sort configuration for the folder browser file table */ - browserFileSortConfig: { column: 'name', ascending: true }, - - /** Log viewer filter state */ - logFilters: { - /** @type {string | null} */ date: null, - /** @type {string | null} */ level: null, - /** @type {string | null} */ category: null, - /** @type {string} */ search: '', - }, - - /** Log viewer pagination state */ - logPagination: { offset: 0, limit: 100 }, -}; - -export default state; diff --git a/app/static/js/modules/stepper.js b/app/static/js/modules/stepper.js deleted file mode 100644 index bce8cdd..0000000 --- a/app/static/js/modules/stepper.js +++ /dev/null @@ -1,83 +0,0 @@ -/** - * Upload step indicator management. - */ -import { setText } from './dom.js'; -import { showNotification } from './notify.js'; -import state from './state.js'; - -export const UPLOAD_STEPS = { - 1: { name: 'Select', description: 'Select files or a folder to upload' }, - 2: { - name: 'Review', - description: 'Review files found - click Continue to upload, or Back to select different files', - }, - 3: { name: 'Upload', description: 'Validating and uploading files...' }, - 4: { name: 'Complete', description: 'Upload complete!' }, -}; - -/** - * Set the current step in the upload flow. - * @param {number} step - */ -export function setUploadStep(step) { - state.currentStep = step; - const stepsContainer = document.getElementById('upload-steps'); - if (!stepsContainer) return; - - for (let i = 1; i <= 4; i++) { - const stepEl = stepsContainer.querySelector(`[data-step="${i}"]`); - if (!stepEl) continue; - - stepEl.classList.remove('completed', 'active'); - - if (i < step) { - stepEl.classList.add('completed'); - } else if (i === step) { - stepEl.classList.add('active'); - } - } - - const connectors = stepsContainer.querySelectorAll('.step-connector'); - connectors.forEach((connector, index) => { - /** @type {HTMLElement} */ (connector).style.backgroundColor = - index < step - 1 ? '#5D9732' : '#D1D5DB'; - }); - - if (UPLOAD_STEPS[step]) { - setText('step-description', UPLOAD_STEPS[step].description); - } -} - -/** - * Show the upload steps indicator and set the current step. - * @param {number} step - */ -export function showUploadSteps(step) { - setUploadStep(step); -} - -/** - * Reset the upload steps indicator to step 1. - */ -export function hideUploadSteps() { - setUploadStep(1); -} - -/** - * Navigate to a specific step (for going back). - * Uses dynamic import to avoid circular dependency with upload-control. - * @param {number} targetStep - */ -export async function goToStep(targetStep) { - if (targetStep >= state.currentStep) return; - - if (targetStep === 1 && state.currentStep <= 2) { - const { resetUpload } = await import('./upload-control.js'); - resetUpload(); - return; - } - - if (state.currentStep >= 3) { - showNotification('Cannot go back during or after upload', 'error'); - } -} diff --git a/app/static/js/modules/upload-control.js b/app/static/js/modules/upload-control.js deleted file mode 100644 index 62c107e..0000000 --- a/app/static/js/modules/upload-control.js +++ /dev/null @@ -1,44 +0,0 @@ -import { resetAnalysisState } from './analysis.js'; -/** - * Cancel and reset operations for uploads. - */ -import { apiPost } from './api.js'; -import { hideEl, showEl } from './dom.js'; -import { showNotification } from './notify.js'; -import state from './state.js'; -import { hideUploadSteps } from './stepper.js'; - -export async function cancelUpload() { - if (!state.currentJobId) return; - - try { - await apiPost(`/api/upload/cancel/${state.currentJobId}`); - if (state.eventSource) state.eventSource.close(); - showNotification('Upload cancelled', 'info'); - } catch (_error) { - showNotification('Failed to cancel upload', 'error'); - } -} - -export function resetUpload() { - state.currentJobId = null; - if (state.eventSource) { - state.eventSource.close(); - state.eventSource = null; - } - - resetAnalysisState(); - hideUploadSteps(); - - showEl('folder-browser-panel'); - hideEl('upload-section'); - hideEl('completion-section'); - hideEl('scan-results-section'); - hideEl('confirm-upload-modal'); - - state.selectedFolderPath = null; - state.scanFilePaths = []; - state.scanFileStatuses = []; - state.scanTotalSize = 0; - state.scanFolderPath = null; -} diff --git a/app/static/js/modules/upload-exec.js b/app/static/js/modules/upload-exec.js deleted file mode 100644 index a164da9..0000000 --- a/app/static/js/modules/upload-exec.js +++ /dev/null @@ -1,160 +0,0 @@ -/** - * Upload execution: progress tracking and completion summary. - */ -import { hideEl, setText, showEl } from './dom.js'; -import { formatDuration, formatEta } from './formatters.js'; -import { fileIcon } from './icons.js'; -import { showNotification } from './notify.js'; -import { setUploadStep } from './stepper.js'; - -/** @type {any} */ -let lastCompletedJob = null; - -/** - * @param {any} job - */ -export function updateProgressUI(job) { - setText('progress-percent', job.progress_percent.toFixed(1)); - setText('files-completed', job.files_completed); - setText('files-total', job.total_files); - setText('bytes-uploaded', job.uploaded_bytes_formatted); - setText('bytes-total', job.total_bytes_formatted); - setText('eta', formatEta(job.eta_seconds)); - - const progressBar = /** @type {HTMLElement | null} */ (document.getElementById('progress-bar')); - if (progressBar) progressBar.style.width = `${job.progress_percent}%`; -} - -/** - * @param {any} job - */ -export function showCompletionSummary(job) { - lastCompletedJob = job; - setUploadStep(4); - - hideEl('upload-section'); - showEl('completion-section'); - - const completed = job.files.filter((/** @type {any} */ f) => f.status === 'completed').length; - const skipped = job.files.filter((/** @type {any} */ f) => f.status === 'skipped').length; - const failed = job.files.filter((/** @type {any} */ f) => f.status === 'failed').length; - - setText('completed-count', completed); - setText('skipped-count', skipped); - setText('failed-count', failed); - setText('total-uploaded-size', job.successfully_uploaded_bytes_formatted || '-'); - setText('total-upload-time', job.total_upload_duration_formatted || '-'); - setText( - 'avg-upload-speed', - job.average_upload_speed_mbps ? `${job.average_upload_speed_mbps} Mbps` : '-', - ); - - const uploadedFiles = job.files.filter( - (/** @type {any} */ f) => f.status === 'completed' && f.upload_duration_seconds, - ); - if (uploadedFiles.length > 0) { - const avgSeconds = - uploadedFiles.reduce( - (/** @type {number} */ sum, /** @type {any} */ f) => sum + f.upload_duration_seconds, - 0, - ) / uploadedFiles.length; - setText('avg-file-time', formatDuration(avgSeconds)); - } else { - setText('avg-file-time', '-'); - } - - const tbody = document.getElementById('completion-file-list'); - if (tbody) { - tbody.innerHTML = job.files - .map((/** @type {any} */ file) => { - let statusBadge; - let statusClass; - if (file.status === 'completed') { - statusBadge = 'Uploaded'; - statusClass = 'bg-green-100 text-green-800'; - } else if (file.status === 'skipped') { - statusBadge = 'Skipped'; - statusClass = 'bg-yellow-100 text-yellow-800'; - } else if (file.status === 'failed') { - statusBadge = 'Failed'; - statusClass = 'bg-red-100 text-red-800'; - } else { - statusBadge = file.status; - statusClass = 'bg-gray-100 text-gray-800'; - } - - const duration = file.upload_duration_seconds - ? formatDuration(file.upload_duration_seconds) - : '-'; - const speed = file.upload_speed_mbps ? `${file.upload_speed_mbps} Mbps` : '-'; - - return ` - - -
- ${fileIcon('h-4 w-4 text-gray-400 mr-2')} - ${file.filename} -
- - ${file.s3_path || '-'} - ${file.file_size_formatted} - ${duration} - ${speed} - - ${statusBadge} - - - `; - }) - .join(''); - } - - if (failed === 0) { - showNotification('Upload completed successfully!', 'success'); - } else { - showNotification(`Upload completed with ${failed} failed files`, 'error'); - } -} - -/** - * Download the upload summary as a CSV file. - */ -export function downloadSummaryCSV() { - if (!lastCompletedJob || !lastCompletedJob.files) { - showNotification('No upload data available', 'error'); - return; - } - - const headers = ['Filename', 'Size', 'S3 Path', 'Status', 'Duration', 'Speed']; - const rows = lastCompletedJob.files.map((/** @type {any} */ file) => { - const duration = file.upload_duration_seconds - ? formatDuration(file.upload_duration_seconds) - : ''; - const speed = file.upload_speed_mbps ? `${file.upload_speed_mbps} Mbps` : ''; - return [ - file.filename, - file.file_size_formatted || '', - file.s3_path || '', - file.status, - duration, - speed, - ]; - }); - - const csvContent = [ - headers.join(','), - ...rows.map((/** @type {string[]} */ row) => - row.map((/** @type {string} */ cell) => `"${String(cell).replace(/"/g, '""')}"`).join(','), - ), - ].join('\n'); - - const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); - const url = URL.createObjectURL(blob); - const link = document.createElement('a'); - link.href = url; - const now = new Date(); - const pad = (/** @type {number} */ n) => String(n).padStart(2, '0'); - link.download = `upload-summary-${now.toISOString().slice(0, 10)}-${pad(now.getHours())}${pad(now.getMinutes())}.csv`; - link.click(); - URL.revokeObjectURL(url); -} diff --git a/app/static/js/modules/upload-init.js b/app/static/js/modules/upload-init.js deleted file mode 100644 index f952c20..0000000 --- a/app/static/js/modules/upload-init.js +++ /dev/null @@ -1,34 +0,0 @@ -import { checkForActiveJob } from './analysis.js'; -import { hideEl, showEl } from './dom.js'; -import { initFolderBrowser, showConfirmModal, startCombinedUpload } from './folder-browser.js'; -/** - * Upload page initialization and event wiring. - */ -import state from './state.js'; -import { setUploadStep } from './stepper.js'; -import { cancelUpload, resetUpload } from './upload-control.js'; -import { downloadSummaryCSV } from './upload-exec.js'; - -export function initUpload() { - const panel = document.getElementById('folder-browser-panel'); - if (!panel) return; - - initFolderBrowser(); - - setUploadStep(1); - checkForActiveJob(); - - document.getElementById('cancel-upload-btn')?.addEventListener('click', cancelUpload); - document.getElementById('upload-more-btn')?.addEventListener('click', resetUpload); - document.getElementById('download-csv-btn')?.addEventListener('click', downloadSummaryCSV); - - document.getElementById('continue-upload-btn')?.addEventListener('click', showConfirmModal); - document.getElementById('confirm-upload-btn')?.addEventListener('click', startCombinedUpload); - - document.getElementById('cancel-scan-btn')?.addEventListener('click', () => { - hideEl('scan-results-section'); - showEl('folder-browser-panel'); - state.selectedFolderPath = null; - setUploadStep(1); - }); -} diff --git a/app/templates/base.html b/app/templates/base.html deleted file mode 100644 index bbdf0aa..0000000 --- a/app/templates/base.html +++ /dev/null @@ -1,399 +0,0 @@ - - - - - - {% block title %}{{ display_name }}{% endblock %} - - - - - - - - - - - - - - {% block head %}{% endblock %} - - - -
- -
-

{{ display_name }}

- - National Laboratory of the Rockies - -
- - - -
- - - {% with messages = get_flashed_messages(with_categories=true) %} - {% if messages %} -
- {% for category, message in messages %} -
-

{{ message }}

-
- {% endfor %} -
- {% endif %} - {% endwith %} - - -
- {% block content %}{% endblock %} -
- - - - - - - - - - - {% block scripts %}{% endblock %} - - diff --git a/app/templates/files.html b/app/templates/files.html deleted file mode 100644 index 4330026..0000000 --- a/app/templates/files.html +++ /dev/null @@ -1,106 +0,0 @@ -{% extends "base.html" %} -{% from "macros.html" import spinner %} - -{% block title %}Browse Uploaded Files - {{ super() }}{% endblock %} - -{% block body_attrs %}data-page="files"{% endblock %} - -{% block content %} -
- -
-
-

S3 File Browser

-

Browse uploaded MCAP files in S3

-
-
- -
- - - - -
- -
-
- - -
-
-
- - - - Loading... -
-
- -
-
-
- - - - - -
- -
- {{ spinner("Loading files...") }} -
- - - - - - - - - -
- - - -
-{% endblock %} - diff --git a/app/templates/index.html b/app/templates/index.html deleted file mode 100644 index b9976bf..0000000 --- a/app/templates/index.html +++ /dev/null @@ -1,375 +0,0 @@ -{% extends "base.html" %} -{% from "macros.html" import spinner, stat_card %} - -{% block title %}Upload Files - {{ super() }}{% endblock %} - -{% block body_attrs %}data-page="upload"{% endblock %} - -{% block content %} -
- -
-
-

Upload MCAP Files

-

Select a folder to upload MCAP files to MODAQ Cloud (NLR AWS S3)

-
-
- - -
-
-
- -
-
- 1 -
- Select -
- - -
- - -
-
- 2 -
- Review -
- - -
- - -
-
- 3 -
- Upload -
- - -
- - -
-
- - - -
- Complete -
-
- - -
- Select files or a folder to upload -
-
-
- - -
- - - - -
-
- -
-
- - -
- - 0 MCAP files in this folder (not subfolders) - -
- - -
- -
- - - - - - - - - - - -
- - Navigate to the folder containing your MCAP files, then click Upload Folder. - - -
-
- - - - - - - - - -
- - - -{% endblock %} diff --git a/app/templates/logs.html b/app/templates/logs.html deleted file mode 100644 index c940502..0000000 --- a/app/templates/logs.html +++ /dev/null @@ -1,169 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Logs - {{ super() }}{% endblock %} - -{% block body_attrs %}data-page="logs"{% endblock %} - -{% block content %} -
- -
-

Application Logs

-

View upload activity, analysis results, and system events

-
- - -
-
-
-
-
Total Entries
-
-
-
-
-
Today
-
-
-
-
-
Errors
-
-
-
-
-
Log Files
-
-
- - -
-
- -
- -
- - -
- -
- - -
- -
- - -
- -
- - - - -
-
- - -
- - -
- - -
- -
- Loading... -
- - -
-
- - -
- - - - - - - - - - - - - - - -
TimestampLevelCategoryEventMessage
Loading...
-
-
-
-{% endblock %} diff --git a/app/templates/macros.html b/app/templates/macros.html deleted file mode 100644 index 117c521..0000000 --- a/app/templates/macros.html +++ /dev/null @@ -1,16 +0,0 @@ -{# Reusable template macros #} - -{% macro spinner(message="Loading...") %} - - - - -

{{ message }}

-{% endmacro %} - -{% macro stat_card(id, label, color, value="0", size="2xl") %} -
-
{{ value }}
-
{{ label }}
-
-{% endmacro %} diff --git a/app/templates/settings.html b/app/templates/settings.html deleted file mode 100644 index 8e61dfd..0000000 --- a/app/templates/settings.html +++ /dev/null @@ -1,241 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Settings - {{ super() }}{% endblock %} - -{% block body_attrs %}data-page="settings"{% endblock %} - -{% block content %} -
- -
-

Settings

-

Configure AWS credentials and S3 bucket

-
- - -
-
- -
- - -

Select AWS profile from ~/.aws/credentials

-
- - -
- - - - -
- - -
- - -

Name of the S3 bucket to upload files to

-
- - -
- - -

Default folder to open when selecting files (optional)

-
- - -
-
-
- Connection Status -
- Not tested -
-
- -
-
- - -
- -
-
-
- - -
-
-

Application Updates

-

Download the latest updates from GitHub/MODAQ2

-
- - -
-
-
- Version: - - -
-
- Branch: - - -
-
- Commit: - - -
-
- Last Updated: - - -
-
-
- - -
-
-
-
- Click "Check for Updates" to see if updates are available -
-
-
- - -
-
-
- - - -
- - -
-
-

Upload Cache

-

Local cache for tracking uploaded files and detecting duplicates

-
- - -
-
-
- Cached Files: - - -
-
- Exists: - - -
-
- Deleted: - - -
-
- Last Sync: - Never -
-
-
- - -
-
-
- Sync with AWS -

Reconcile cache with actual S3 bucket state (marks deleted files)

-
-
- -
-
- -
-
- - -
-
-

Danger Zone

-
-
-
-
- Clear Browser Cache -

Clear remembered folders and local preferences

-
- -
-
-
- Clear Upload Cache -

Delete all cached file records for current bucket

-
- -
-
-
- Reset Settings -

Reset all settings to defaults

-
- -
-
-
-
-{% endblock %} - diff --git a/biome.json b/biome.json deleted file mode 100644 index 0e9c777..0000000 --- a/biome.json +++ /dev/null @@ -1,39 +0,0 @@ -{ - "$schema": "https://biomejs.dev/schemas/1.9.0/schema.json", - "organizeImports": { - "enabled": true - }, - "formatter": { - "enabled": true, - "indentStyle": "space", - "indentWidth": 2, - "lineWidth": 100 - }, - "javascript": { - "formatter": { - "quoteStyle": "single", - "semicolons": "always" - } - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "correctness": { - "noUnusedVariables": "warn", - "noUnusedImports": "warn" - }, - "suspicious": { - "noExplicitAny": "off" - }, - "style": { - "noParameterAssign": "off", - "useConst": "error" - } - } - }, - "files": { - "include": ["app/static/js/**/*.js"], - "ignore": ["node_modules/", "tests/"] - } -} diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..be7b770 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,48 @@ +# Logs +/logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Dependencies +node_modules + +# Build outputs +dist +dist-ssr +*.local + +# Environment variables +.env +.env.local +.env.*.local + +# Testing +coverage +.vitest + +# TypeScript +*.tsbuildinfo +*.tsbuildinfo.* + +# Caches +.cache +.vite +.biome-cache + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# OS files +Thumbs.db diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..427b247 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,26 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + rules: { + '@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_' }], + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..c10691f --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,17 @@ + + + + + + + + MODAQ Upload + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..249a958 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,6003 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.18", + "lucide-react": "^0.574.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.0", + "zustand": "^5.0.11" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.1.18", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "@vitest/coverage-v8": "^3.2.4", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "jsdom": "^27.0.1", + "tailwindcss": "^4.1.18", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1", + "vitest": "^3.2.4" + } + }, + "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/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.8.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", + "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.6" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "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/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz", + "integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", + "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz", + "integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.1", + "@csstools/css-calc": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.27", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz", + "integrity": "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@istanbuljs/schema": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz", + "integrity": "sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.18.tgz", + "integrity": "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz", + "integrity": "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-arm64": "4.1.18", + "@tailwindcss/oxide-darwin-x64": "4.1.18", + "@tailwindcss/oxide-freebsd-x64": "4.1.18", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", + "@tailwindcss/oxide-linux-x64-musl": "4.1.18", + "@tailwindcss/oxide-wasm32-wasi": "4.1.18", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz", + "integrity": "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz", + "integrity": "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz", + "integrity": "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz", + "integrity": "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz", + "integrity": "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz", + "integrity": "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz", + "integrity": "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz", + "integrity": "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz", + "integrity": "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz", + "integrity": "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.1.0", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz", + "integrity": "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz", + "integrity": "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.18.tgz", + "integrity": "sha512-jVA+/UpKL1vRLg6Hkao5jldawNmRo7mQYrZtNHMIVpLfLhDml5nMRUo/8MwoX2vNXvnaXNNMedrMfMugAVX1nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.1.18", + "@tailwindcss/oxide": "4.1.18", + "tailwindcss": "4.1.18" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7" + } + }, + "node_modules/@tanstack/react-table": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz", + "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==", + "license": "MIT", + "dependencies": { + "@tanstack/table-core": "8.21.3" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/@tanstack/react-virtual": { + "version": "3.13.18", + "resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.18.tgz", + "integrity": "sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==", + "license": "MIT", + "dependencies": { + "@tanstack/virtual-core": "3.13.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/table-core": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz", + "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/virtual-core": { + "version": "3.13.18", + "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz", + "integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "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", + "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" + }, + "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/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "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/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.10.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.10.13.tgz", + "integrity": "sha512-oH72nZRfDv9lADUBSo104Aq7gPHpQZc4BTx38r9xf9pg5LfP6EzSyH2n7qFmmxRQXh7YlUXODcYsg6PuTDSxGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.16.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.0.tgz", + "integrity": "sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/type-utils": "8.56.0", + "@typescript-eslint/utils": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.56.0.tgz", + "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.56.0.tgz", + "integrity": "sha512-M3rnyL1vIQOMeWxTWIW096/TtVP+8W3p/XnaFflhmcFp+U4zlxUxWj4XwNs6HbDeTtN4yun0GNTTDBw/SvufKg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.56.0", + "@typescript-eslint/types": "^8.56.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.56.0.tgz", + "integrity": "sha512-7UiO/XwMHquH+ZzfVCfUNkIXlp/yQjjnlYUyYz7pfvlK3/EyyN6BK+emDmGNyQLBtLGaYrTAI6KOw8tFucWL2w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.0.tgz", + "integrity": "sha512-bSJoIIt4o3lKXD3xmDh9chZcjCz5Lk8xS7Rxn+6l5/pKrDpkCwtQNQQwZ2qRPk7TkUYhrq3WPIHXOXlbXP0itg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.56.0.tgz", + "integrity": "sha512-qX2L3HWOU2nuDs6GzglBeuFXviDODreS58tLY/BALPC7iu3Fa+J7EOTwnX9PdNBxUI7Uh0ntP0YWGnxCkXzmfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/utils": "8.56.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.56.0.tgz", + "integrity": "sha512-DBsLPs3GsWhX5HylbP9HNG15U0bnwut55Lx12bHB9MpXxQ+R5GC8MwQe+N1UFXxAeQDvEsEDY6ZYwX03K7Z6HQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.0.tgz", + "integrity": "sha512-ex1nTUMWrseMltXUHmR2GAQ4d+WjkZCT4f+4bVsps8QEdh0vlBsaCokKTPlnqBFqqGaxilDNJG7b8dolW2m43Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.56.0", + "@typescript-eslint/tsconfig-utils": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/visitor-keys": "8.56.0", + "debug": "^4.4.3", + "minimatch": "^9.0.5", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.4.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.56.0.tgz", + "integrity": "sha512-RZ3Qsmi2nFGsS+n+kjLAYDPVlrzf7UhTffrDIKr+h2yzAlYP/y5ZulU0yeDEPItos2Ph46JAL5P/On3pe7kDIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.56.0", + "@typescript-eslint/types": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.0.tgz", + "integrity": "sha512-q+SL+b+05Ud6LbEE35qe4A99P+htKTKVbyiNEe45eCbJFyh/HVK9QXwlrbz+Q4L8SOW4roxSVwXYj4DMBT7Ieg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.56.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.0.tgz", + "integrity": "sha512-A0XeIi7CXU7nPlfHS9loMYEKxUaONu/hTEzHTGba9Huu94Cq1hPivf+DE5erJozZOky0LfvXAyrV/tcswpLI0Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-3.2.4.tgz", + "integrity": "sha512-EyF9SXU6kS5Ku/U82E259WSnvg6c8KTjppUncuNdm5QHpe17mwREHnjDzozC8x9MZ0xfBUFSaLkRv4TMA75ALQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@ampproject/remapping": "^2.3.0", + "@bcoe/v8-coverage": "^1.0.2", + "ast-v8-to-istanbul": "^0.3.3", + "debug": "^4.4.1", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-lib-source-maps": "^5.0.6", + "istanbul-reports": "^3.1.7", + "magic-string": "^0.30.17", + "magicast": "^0.3.5", + "std-env": "^3.9.0", + "test-exclude": "^7.0.1", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "3.2.4", + "vitest": "3.2.4" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-3.2.4.tgz", + "integrity": "sha512-Io0yyORnB6sikFlt8QW5K7slY4OjqNX9jmJQ02QDda8lyM6B5oNgVWoSoKPac8/kgnCUzuHQKrSLtu/uOqqrig==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/expect/node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/expect/node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-3.2.4.tgz", + "integrity": "sha512-46ryTE9RZO/rfDd7pEqFl7etuyzekzEhUbTW3BvmeO/BcCMEgq59BKhek3dXDWgAj4oMK6OZi+vRr1wPW6qjEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "3.2.4", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.17" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/runner": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-3.2.4.tgz", + "integrity": "sha512-oukfKT9Mk41LreEW09vt45f8wx7DordoWUZMYdY/cyAk7w5TWkTRCNZYF7sX7n2wB7jyGAl74OxgwhPgKaqDMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "3.2.4", + "pathe": "^2.0.3", + "strip-literal": "^3.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner/node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-3.2.4.tgz", + "integrity": "sha512-dEYtS7qQP2CjU27QBC5oUOxLE/v5eLkGqPE0ZKEIDGMs4vKWe7IjgLOeauHsR0D5YuuycGRO5oSRXnwnmA78fQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "magic-string": "^0.30.17", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot/node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-3.2.4.tgz", + "integrity": "sha512-vAfasCOe6AIK70iP5UD11Ac4siNUNJ9i/9PZ3NKx07sG6sUxeag1LWdNrMWeKKYBLlzuK+Gn65Yd5nyL6ds+nw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^4.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "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/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "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", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "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/ast-v8-to-istanbul": { + "version": "0.3.11", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.11.tgz", + "integrity": "sha512-Qya9fkoofMjCBNVdWINMjB5KZvkYfaO9/anwkWnjxibpWUxo5iHl2sOdP7/uAqaRuUYuoo8rDwnbaaKVFxoUvw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "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/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001770", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001770.tgz", + "integrity": "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw==", + "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/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "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/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "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/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "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": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.6.tgz", + "integrity": "sha512-ESL2CrkS/2wTPfuend7Zhkzo2u0daGJ/A2VucJOgQ/C48S/zB8MMeMHSGKYpXhIjbPxfuezITkaBH1wqv00DDQ==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "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==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-6.0.1.tgz", + "integrity": "sha512-euIQENZg6x8mj3fO6o9+fOW8MimUI4PpD/fZBhJfeioZVy9TUpM4UY7KjQNVZFlqwJ0UdzRDzkycB997HEq1BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^15.1.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "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/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "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/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "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" + }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true, + "license": "MIT" + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.19.0", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.19.0.tgz", + "integrity": "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "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.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "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/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "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/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.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/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/foreground-child": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", + "integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==", + "dev": true, + "license": "ISC", + "dependencies": { + "cross-spawn": "^7.0.6", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "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/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "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/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "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-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "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/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-5.0.6.tgz", + "integrity": "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.23", + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jackspeak": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, + "node_modules/jiti": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "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==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsdom": { + "version": "27.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-27.0.1.tgz", + "integrity": "sha512-SNSQteBL1IlV2zqhwwolaG9CwhIhTvVHWg3kTss/cLE7H/X4644mtPQqYvCfsSrGQWt9hSZcgOXX8bOZaMN+kA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/dom-selector": "^6.7.2", + "cssstyle": "^5.3.1", + "data-urls": "^6.0.0", + "decimal.js": "^10.6.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", + "parse5": "^8.0.0", + "rrweb-cssom": "^0.8.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.0", + "whatwg-encoding": "^3.1.1", + "whatwg-mimetype": "^4.0.0", + "whatwg-url": "^15.1.0", + "ws": "^8.18.3", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=20" + }, + "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/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "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/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "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/lucide-react": { + "version": "0.574.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.574.0.tgz", + "integrity": "sha512-dJ8xb5juiZVIbdSn3HTyHsjjIwUwZ4FNwV0RtYDScOyySOeie1oXZTymST6YPJ4Qwt3Po8g4quhYl4OxtACiuQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "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", + "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/magicast": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.3.5.tgz", + "integrity": "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.25.4", + "@babel/types": "^7.25.4", + "source-map-js": "^1.2.0" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, + "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/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "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/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "dev": true, + "license": "BlueOak-1.0.0" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/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/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "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/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "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/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "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", + "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/pretty-format/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", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "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" + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.13.0.tgz", + "integrity": "sha512-PZgus8ETambRT17BUm/LL8lX3Of+oiLaPuVTRH3l1eLvSPpKO3AvhAEb5N7ihAFZQrYDqkvvWfFh9p0z9VsjLw==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.13.0.tgz", + "integrity": "sha512-5CO/l5Yahi2SKC6rGZ+HDEjpjkGaG/ncEP7eWFTvFxbHP8yeeI0PxTDjimtpXYlR3b3i9/WIL4VJttPrESIf2g==", + "license": "MIT", + "dependencies": { + "react-router": "7.13.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "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/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "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.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "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.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "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/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "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/signal-exit": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", + "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string-width-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz", + "integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "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/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/strip-literal": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-3.1.0.tgz", + "integrity": "sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^9.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/antfu" + } + }, + "node_modules/strip-literal/node_modules/js-tokens": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-9.0.1.tgz", + "integrity": "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.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/tailwindcss": { + "version": "4.1.18", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz", + "integrity": "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/test-exclude": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-7.0.1.tgz", + "integrity": "sha512-pFYqmTw68LXVjeWJMST4+borgQP2AyMNbg1BpZh9LbyhUeNkeaPF9gzfPGUAnSMV3qPYdWUwDIjjCLiSDOl7vg==", + "dev": true, + "license": "ISC", + "dependencies": { + "@istanbuljs/schema": "^0.1.2", + "glob": "^10.4.1", + "minimatch": "^9.0.4" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/test-exclude/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/test-exclude/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "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/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "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": "2.0.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-2.0.0.tgz", + "integrity": "sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-4.0.4.tgz", + "integrity": "sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.23" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.4.0.tgz", + "integrity": "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "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/typescript-eslint": { + "version": "8.56.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.56.0.tgz", + "integrity": "sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.56.0", + "@typescript-eslint/parser": "8.56.0", + "@typescript-eslint/typescript-estree": "8.56.0", + "@typescript-eslint/utils": "8.56.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.0.0" + } + }, + "node_modules/undici-types": { + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", + "dev": true, + "license": "MIT" + }, + "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/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-3.2.4.tgz", + "integrity": "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.4.1", + "es-module-lexer": "^1.7.0", + "pathe": "^2.0.3", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz", + "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/expect": "3.2.4", + "@vitest/mocker": "3.2.4", + "@vitest/pretty-format": "^3.2.4", + "@vitest/runner": "3.2.4", + "@vitest/snapshot": "3.2.4", + "@vitest/spy": "3.2.4", + "@vitest/utils": "3.2.4", + "chai": "^5.2.0", + "debug": "^4.4.1", + "expect-type": "^1.2.1", + "magic-string": "^0.30.17", + "pathe": "^2.0.3", + "picomatch": "^4.0.2", + "std-env": "^3.9.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.2", + "tinyglobby": "^0.2.14", + "tinypool": "^1.1.1", + "tinyrainbow": "^2.0.0", + "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", + "vite-node": "3.2.4", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/debug": "^4.1.12", + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "@vitest/browser": "3.2.4", + "@vitest/ui": "3.2.4", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/debug": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/vitest/node_modules/@vitest/pretty-format": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz", + "integrity": "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest/node_modules/@vitest/utils": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-3.2.4.tgz", + "integrity": "sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "3.2.4", + "loupe": "^3.1.4", + "tinyrainbow": "^2.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "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": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "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": "15.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-15.1.0.tgz", + "integrity": "sha512-2ytDk0kiEj/yu90JOAp44PVPUkO9+jVhyf+SybKlRHSDlvOOZhdPIrr7xTH64l4WixO2cP+wQIcgujkGBPPz6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "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/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/wrap-ansi-cjs/node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrap-ansi/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "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" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.11", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.11.tgz", + "integrity": "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..2686499 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,48 @@ +{ + "name": "frontend", + "private": true, + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc -b --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage", + "check": "tsc -b --noEmit && vitest run" + }, + "dependencies": { + "@tanstack/react-table": "^8.21.3", + "@tanstack/react-virtual": "^3.13.18", + "lucide-react": "^0.574.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", + "react-router-dom": "^7.13.0", + "zustand": "^5.0.11" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@tailwindcss/vite": "^4.1.18", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.10.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "@vitest/coverage-v8": "^3.2.4", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "jsdom": "^27.0.1", + "tailwindcss": "^4.1.18", + "typescript": "~5.9.3", + "typescript-eslint": "^8.48.0", + "vite": "^7.3.1", + "vitest": "^3.2.4" + } +} diff --git a/app/static/images/alliance-logo_black.svg b/frontend/public/images/alliance-logo_black.svg similarity index 100% rename from app/static/images/alliance-logo_black.svg rename to frontend/public/images/alliance-logo_black.svg diff --git a/app/static/images/doe-logo.svg b/frontend/public/images/doe-logo.svg similarity index 100% rename from app/static/images/doe-logo.svg rename to frontend/public/images/doe-logo.svg diff --git a/app/static/images/modaq-logo.png b/frontend/public/images/modaq-logo.png similarity index 100% rename from app/static/images/modaq-logo.png rename to frontend/public/images/modaq-logo.png diff --git a/app/static/images/nlr-logo@2x-01.png b/frontend/public/images/nlr-logo@2x-01.png similarity index 100% rename from app/static/images/nlr-logo@2x-01.png rename to frontend/public/images/nlr-logo@2x-01.png diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..b7ccc87 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,31 @@ +import { useEffect } from "react"; +import { Route, Routes } from "react-router-dom"; +import Layout from "./components/layout/Layout.tsx"; +import DeletePage from "./pages/DeletePage.tsx"; +import FilesPage from "./pages/FilesPage.tsx"; +import LogsPage from "./pages/LogsPage.tsx"; +import SettingsPage from "./pages/SettingsPage.tsx"; +import UploadPage from "./pages/UploadPage.tsx"; +import { useAppStore } from "./stores/appStore.ts"; + +export default function App() { + const loadSettings = useAppStore((s) => s.loadSettings); + const loadVersion = useAppStore((s) => s.loadVersion); + + useEffect(() => { + loadSettings(); + loadVersion(); + }, [loadSettings, loadVersion]); + + return ( + + }> + } /> + } /> + } /> + } /> + } /> + + + ); +} diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts new file mode 100644 index 0000000..f0ba620 --- /dev/null +++ b/frontend/src/api/client.ts @@ -0,0 +1,42 @@ +/** Typed fetch wrappers for the Flask API. */ + +export class ApiError extends Error { + status: number; + constructor(status: number, message: string) { + super(message); + this.name = "ApiError"; + this.status = status; + } +} + +async function handleResponse(res: Response): Promise { + if (!res.ok) { + const body = await res.json().catch(() => ({ error: res.statusText })); + throw new ApiError(res.status, body.error ?? res.statusText); + } + return res.json() as Promise; +} + +export async function apiGet(url: string, params?: Record): Promise { + const qs = params ? `?${new URLSearchParams(params)}` : ""; + const res = await fetch(`${url}${qs}`); + return handleResponse(res); +} + +export async function apiPost(url: string, body?: unknown): Promise { + const res = await fetch(url, { + method: "POST", + headers: body != null ? { "Content-Type": "application/json" } : undefined, + body: body != null ? JSON.stringify(body) : undefined, + }); + return handleResponse(res); +} + +export async function apiPut(url: string, body: unknown): Promise { + const res = await fetch(url, { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + return handleResponse(res); +} diff --git a/frontend/src/components/common/AlertBanner.tsx b/frontend/src/components/common/AlertBanner.tsx new file mode 100644 index 0000000..5b802c4 --- /dev/null +++ b/frontend/src/components/common/AlertBanner.tsx @@ -0,0 +1,78 @@ +/** + * Reusable alert banner for displaying information, warnings, errors, and success messages. + */ + +import { InfoIcon, WarningIcon, ErrorIcon, SuccessIcon, ShieldIcon } from "../../utils/icons.tsx"; +import type { ReactNode } from "react"; + +type AlertType = "info" | "warning" | "error" | "success" | "shield"; + +interface AlertBannerProps { + type: AlertType; + title?: string; + message: ReactNode; + icon?: ReactNode; + className?: string; +} + +const alertStyles: Record = { + info: "bg-blue-50 border-blue-200", + warning: "bg-yellow-50 border-yellow-200", + error: "bg-red-50 border-red-200", + success: "bg-green-50 border-green-200", + shield: "bg-green-50 border-green-200", +}; + +const titleStyles: Record = { + info: "text-blue-800", + warning: "text-yellow-800", + error: "text-red-800", + success: "text-green-800", + shield: "text-green-800", +}; + +const messageStyles: Record = { + info: "text-blue-700", + warning: "text-yellow-700", + error: "text-red-700", + success: "text-green-700", + shield: "text-green-700", +}; + +const iconMap: Record = { + info: InfoIcon, + warning: WarningIcon, + error: ErrorIcon, + success: SuccessIcon, + shield: ShieldIcon, +}; + +const iconColorStyles: Record = { + info: "text-blue-500", + warning: "text-yellow-500", + error: "text-red-500", + success: "text-green-700", + shield: "text-green-700", +}; + +export default function AlertBanner({ + type, + title, + message, + icon, + className = "", +}: AlertBannerProps) { + const Icon = iconMap[type]; + + return ( +
+
+ {icon || } +
+ {title &&

{title}

} +
{message}
+
+
+
+ ); +} diff --git a/frontend/src/components/common/Breadcrumb.tsx b/frontend/src/components/common/Breadcrumb.tsx new file mode 100644 index 0000000..9e408aa --- /dev/null +++ b/frontend/src/components/common/Breadcrumb.tsx @@ -0,0 +1,50 @@ +import { useEffect, useRef } from "react"; +import { ChevronRightIcon } from "../../utils/icons.tsx"; + +interface BreadcrumbItem { + label: string; + onClick?: () => void; +} + +interface BreadcrumbProps { + items: BreadcrumbItem[]; +} + +export default function Breadcrumb({ items }: BreadcrumbProps) { + const scrollRef = useRef(null); + + // Auto-scroll to the end so the current folder is always visible + useEffect(() => { + const el = scrollRef.current; + if (el) { + el.scrollLeft = el.scrollWidth; + } + }, [items]); + + return ( + + ); +} diff --git a/frontend/src/components/common/Modal.tsx b/frontend/src/components/common/Modal.tsx new file mode 100644 index 0000000..0b7bb29 --- /dev/null +++ b/frontend/src/components/common/Modal.tsx @@ -0,0 +1,60 @@ +import { useEffect, useRef, type ReactNode } from "react"; +import { XIcon } from "../../utils/icons.tsx"; + +interface ModalProps { + isOpen: boolean; + onClose: () => void; + title: string; + children: ReactNode; + footer?: ReactNode; +} + +export default function Modal({ isOpen, onClose, title, children, footer }: ModalProps) { + const backdropRef = useRef(null); + + useEffect(() => { + if (!isOpen) return; + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") onClose(); + } + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + function handleBackdropClick(e: React.MouseEvent) { + if (e.target === backdropRef.current) onClose(); + } + + return ( +
+
+ {/* Header */} +
+

{title}

+ +
+ + {/* Body */} +
{children}
+ + {/* Footer */} + {footer &&
{footer}
} +
+
+ ); +} diff --git a/frontend/src/components/common/Notification.tsx b/frontend/src/components/common/Notification.tsx new file mode 100644 index 0000000..2ae1ea3 --- /dev/null +++ b/frontend/src/components/common/Notification.tsx @@ -0,0 +1,47 @@ +import { useAppStore, type Notification as NotificationType } from "../../stores/appStore.ts"; +import { SuccessIcon, ErrorIcon, InfoIcon, WarningIcon, XIcon } from "../../utils/icons.tsx"; + +const typeStyles: Record = { + success: "bg-green-50 border-green-400 text-green-800", + error: "bg-red-50 border-red-400 text-red-800", + info: "bg-blue-50 border-blue-400 text-blue-800", + warning: "bg-yellow-50 border-yellow-400 text-yellow-800", +}; + +const iconMap: Record = { + success: SuccessIcon, + error: ErrorIcon, + info: InfoIcon, + warning: WarningIcon, +}; + +export default function NotificationStack() { + const notifications = useAppStore((s) => s.notifications); + const removeNotification = useAppStore((s) => s.removeNotification); + + if (notifications.length === 0) return null; + + return ( +
+ {notifications.map((n) => { + const Icon = iconMap[n.type]; + return ( +
+ + {n.message} + +
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/common/ProgressBar.tsx b/frontend/src/components/common/ProgressBar.tsx new file mode 100644 index 0000000..299b76c --- /dev/null +++ b/frontend/src/components/common/ProgressBar.tsx @@ -0,0 +1,34 @@ +interface ProgressBarProps { + percent: number; + label?: string; + color?: string; +} + +export default function ProgressBar({ + percent, + label, + color = "bg-nlr-blue", +}: ProgressBarProps) { + const clampedPercent = Math.min(100, Math.max(0, percent)); + + return ( +
+ {label && ( +
+ {label} + {Math.round(clampedPercent)}% +
+ )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/common/SortableHeader.tsx b/frontend/src/components/common/SortableHeader.tsx new file mode 100644 index 0000000..0a690af --- /dev/null +++ b/frontend/src/components/common/SortableHeader.tsx @@ -0,0 +1,29 @@ +import { ChevronUpIcon, ChevronDownIcon } from "../../utils/icons.tsx"; + +interface SortableHeaderProps { + label: string; + active: boolean; + ascending: boolean; + onSort: () => void; +} + +export default function SortableHeader({ label, active, ascending, onSort }: SortableHeaderProps) { + return ( + + + {label} + + + + + + + ); +} diff --git a/frontend/src/components/common/Spinner.tsx b/frontend/src/components/common/Spinner.tsx new file mode 100644 index 0000000..67436f9 --- /dev/null +++ b/frontend/src/components/common/Spinner.tsx @@ -0,0 +1,25 @@ +import { SpinnerIcon } from "../../utils/icons"; + +interface SpinnerProps { + message?: string; + size?: "sm" | "md" | "lg"; +} + +const sizeMap = { + sm: 20, + md: 32, + lg: 48, +}; + +export default function Spinner({ message, size = "md" }: SpinnerProps) { + return ( +
+ + {message &&

{message}

} +
+ ); +} diff --git a/frontend/src/components/common/StatCard.tsx b/frontend/src/components/common/StatCard.tsx new file mode 100644 index 0000000..185346c --- /dev/null +++ b/frontend/src/components/common/StatCard.tsx @@ -0,0 +1,14 @@ +interface StatCardProps { + value: string | number; + label: string; + color?: string; +} + +export default function StatCard({ value, label, color = "text-nlr-blue" }: StatCardProps) { + return ( +
+
{value}
+
{label}
+
+ ); +} diff --git a/frontend/src/components/common/Stepper.tsx b/frontend/src/components/common/Stepper.tsx new file mode 100644 index 0000000..0a9b244 --- /dev/null +++ b/frontend/src/components/common/Stepper.tsx @@ -0,0 +1,108 @@ +/** + * Unified step progress indicator for multi-step workflows. + * + * - Green circle + checkmark = completed step + * - Blue circle + number = active step + * - Gray circle + number = future step + * - Completed steps are clickable (go back) when not in active operation. + */ + +import { CheckIcon } from "../../utils/icons.tsx"; + +interface StepDef { + number: number; + label: string; +} + +interface StepperProps { + steps: StepDef[]; + currentStep: number; + onStepClick?: (step: number) => void; + /** When true, disables clicking back to earlier steps. */ + isOperating?: boolean; + /** Maximum step number that can be clicked back to (default: 2) */ + maxClickableStep?: number; + /** ARIA label for the navigation element */ + ariaLabel?: string; + /** Test ID prefix for step buttons */ + testIdPrefix?: string; +} + +export default function Stepper({ + steps, + currentStep, + onStepClick, + isOperating = false, + maxClickableStep = 2, + ariaLabel = "Progress steps", + testIdPrefix = "step", +}: StepperProps) { + return ( + + ); +} diff --git a/frontend/src/components/delete/DeleteConfirmation.tsx b/frontend/src/components/delete/DeleteConfirmation.tsx new file mode 100644 index 0000000..5479c1d --- /dev/null +++ b/frontend/src/components/delete/DeleteConfirmation.tsx @@ -0,0 +1,119 @@ +/** + * Confirmation step for the delete workflow (Step 3). + * + * Requires the user to type "DELETE" and check a confirmation checkbox + * before enabling the delete button. Emphasizes that S3 data is untouched. + */ + +import { useState } from "react"; +import { Link } from "react-router-dom"; + +import { formatBytes } from "../../utils/format/bytes.ts"; +import AlertBanner from "../common/AlertBanner.tsx"; + +interface DeleteConfirmationProps { + totalFiles: number; + totalSize: number; + onConfirm: () => void; + onBack: () => void; +} + +export default function DeleteConfirmation({ + totalFiles, + totalSize, + onConfirm, + onBack, +}: DeleteConfirmationProps) { + const [typedText, setTypedText] = useState(""); + const [checked, setChecked] = useState(false); + + const isConfirmed = typedText === "CLEAR" && checked; + + return ( +
+ {/* Warning banner */} + + You are about to permanently remove{" "} + {totalFiles} local file{totalFiles !== 1 ? "s" : ""}{" "} + ({formatBytes(totalSize)}) from this hard drive. + + } + /> + + {/* Cloud reassurance */} + + Only local copies are removed. Your files in{" "} + + cloud storage + + {" "}remain safe and unchanged. Each file is verified against the cloud upload before removal. + + } + /> + + {/* Type-to-confirm */} +
+ + setTypedText(e.target.value)} + className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:ring-2 focus:ring-red-500 focus:border-red-500" + placeholder="Type CLEAR here" + autoComplete="off" + /> +
+ + {/* Checkbox */} + + + {/* Buttons */} +
+ + +
+
+ ); +} diff --git a/frontend/src/components/delete/DeleteStepper.tsx b/frontend/src/components/delete/DeleteStepper.tsx new file mode 100644 index 0000000..56e53ed --- /dev/null +++ b/frontend/src/components/delete/DeleteStepper.tsx @@ -0,0 +1,39 @@ +/** + * 5-step progress indicator for the delete workflow. + * Wrapper around the unified Stepper component. + */ + +import Stepper from "../common/Stepper.tsx"; +import type { DeleteStep } from "../../stores/deleteStore.ts"; + +const steps = [ + { number: 1, label: "Select" }, + { number: 2, label: "Review" }, + { number: 3, label: "Confirm" }, + { number: 4, label: "Clear" }, + { number: 5, label: "Complete" }, +]; + +interface DeleteStepperProps { + currentStep: DeleteStep; + onStepClick?: (step: DeleteStep) => void; + isDeleting?: boolean; +} + +export default function DeleteStepper({ + currentStep, + onStepClick, + isDeleting = false, +}: DeleteStepperProps) { + return ( + void) | undefined} + isOperating={isDeleting} + maxClickableStep={2} + ariaLabel="Delete steps" + testIdPrefix="delete-step" + /> + ); +} diff --git a/frontend/src/components/delete/FixPermissionsModal.tsx b/frontend/src/components/delete/FixPermissionsModal.tsx new file mode 100644 index 0000000..f1437ba --- /dev/null +++ b/frontend/src/components/delete/FixPermissionsModal.tsx @@ -0,0 +1,148 @@ +import { useCallback, useState } from "react"; + +import { apiPost } from "../../api/client.ts"; +import { LockIcon, SpinnerIcon, SuccessIcon, WarningIcon } from "../../utils/icons.tsx"; +import Modal from "../common/Modal.tsx"; + +interface FixPermissionsModalProps { + isOpen: boolean; + onClose: () => void; + folderPath: string; + onFixed: () => void; +} + +export default function FixPermissionsModal({ + isOpen, + onClose, + folderPath, + onFixed, +}: FixPermissionsModalProps) { + const [password, setPassword] = useState(""); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [success, setSuccess] = useState(false); + + const handleSubmit = useCallback( + async (e: React.FormEvent) => { + e.preventDefault(); + if (!password.trim()) return; + + setIsLoading(true); + setError(null); + + try { + await apiPost("/api/delete/fix-permissions", { + folder_path: folderPath, + password, + }); + setSuccess(true); + setPassword(""); + onFixed(); + } catch (err) { + const msg = + err instanceof Error ? err.message : "Failed to fix permissions"; + setError(msg); + } finally { + setIsLoading(false); + } + }, + [password, folderPath, onFixed], + ); + + const handleClose = useCallback(() => { + setPassword(""); + setError(null); + setSuccess(false); + onClose(); + }, [onClose]); + + return ( + + + +
+ ) : ( + + ) + } + > + {success ? ( +
+ +

+ Permissions fixed successfully. The scan results will be refreshed. +

+
+ ) : ( +
+
+ +
+

+ The files on this drive are owned by a different user. Your sudo + password is needed to change ownership so files can be deleted. +

+

+ Your password is sent directly to the system and is never stored + or logged. +

+
+
+ +
+ + setPassword(e.target.value)} + placeholder="Enter your password" + autoFocus + className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm focus:outline-none focus:ring-2 focus:ring-nlr-blue focus:border-transparent" + /> +
+ + {error && ( +
+ +

{error}

+
+ )} +
+ )} + + ); +} diff --git a/frontend/src/components/files/FileList.tsx b/frontend/src/components/files/FileList.tsx new file mode 100644 index 0000000..619dd06 --- /dev/null +++ b/frontend/src/components/files/FileList.tsx @@ -0,0 +1,70 @@ +import type { S3File, S3Folder } from "../../types/api.ts"; +import { FolderIcon, FileIcon } from "../../utils/icons.tsx"; + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} + +function formatDate(iso: string): string { + const d = new Date(iso); + return d.toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} + +interface FileListProps { + folders: S3Folder[]; + files: S3File[]; + onNavigate: (prefix: string) => void; +} + +export default function FileList({ folders, files, onNavigate }: FileListProps) { + if (folders.length === 0 && files.length === 0) { + return ( +
+ +

No files or folders found at this location.

+
+ ); + } + + return ( +
+ {folders.map((folder) => ( + + ))} + + {files.map((file) => ( +
+ +
+

+ {file.name} +

+
+ {formatBytes(file.size)} + + {formatDate(file.last_modified)} + +
+ ))} +
+ ); +} diff --git a/frontend/src/components/files/S3Browser.tsx b/frontend/src/components/files/S3Browser.tsx new file mode 100644 index 0000000..ad96eea --- /dev/null +++ b/frontend/src/components/files/S3Browser.tsx @@ -0,0 +1,109 @@ +import { useCallback, useEffect, useState } from "react"; +import { apiGet } from "../../api/client.ts"; +import type { S3File, S3Folder, S3ListResponse } from "../../types/api.ts"; +import { CloudIcon } from "../../utils/icons.tsx"; +import Breadcrumb from "../common/Breadcrumb.tsx"; +import Spinner from "../common/Spinner.tsx"; +import FileList from "./FileList.tsx"; + +interface S3BrowserProps { + bucketName: string; + region: string; +} + +export default function S3Browser({ bucketName, region }: S3BrowserProps) { + const [prefix, setPrefix] = useState(""); + const [folders, setFolders] = useState([]); + const [files, setFiles] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Build breadcrumb items from the current prefix + const breadcrumbItems = (() => { + const items = [ + { + label: bucketName, + onClick: prefix ? () => setPrefix("") : undefined, + }, + ]; + + if (prefix) { + const parts = prefix.replace(/\/$/, "").split("/"); + for (let i = 0; i < parts.length; i++) { + const partPrefix = parts.slice(0, i + 1).join("/") + "/"; + const isLast = i === parts.length - 1; + items.push({ + label: parts[i], + onClick: isLast ? undefined : () => setPrefix(partPrefix), + }); + } + } + + return items; + })(); + + const fetchObjects = useCallback(async (currentPrefix: string) => { + setLoading(true); + setError(null); + try { + const params: Record = { delimiter: "/" }; + if (currentPrefix) params.prefix = currentPrefix; + const data = await apiGet("/api/files/list", params); + if (!data.success) { + setError(data.error ?? "Failed to list objects"); + return; + } + setFolders(data.folders); + setFiles(data.files); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to fetch files"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + void fetchObjects(prefix); + }, [prefix, fetchObjects]); + + const navigateToPrefix = useCallback((newPrefix: string) => { + setPrefix(newPrefix); + }, []); + + return ( +
+ {/* Header: Bucket info + breadcrumb */} +
+
+ + + {bucketName} + ({region}) + +
+ +
+ + {/* Content */} +
+ {loading ? ( +
+ +
+ ) : error ? ( +
+

{error}

+ +
+ ) : ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/layout/AboutModal.tsx b/frontend/src/components/layout/AboutModal.tsx new file mode 100644 index 0000000..bd29056 --- /dev/null +++ b/frontend/src/components/layout/AboutModal.tsx @@ -0,0 +1,48 @@ +import Modal from "../common/Modal.tsx"; +import { useAppStore } from "../../stores/appStore.ts"; + +interface AboutModalProps { + isOpen: boolean; + onClose: () => void; +} + +export default function AboutModal({ isOpen, onClose }: AboutModalProps) { + const version = useAppStore((s) => s.version); + + return ( + + Close + + } + > +
+
+ Version + + {version?.version ?? "loading..."} + +
+
+ Commit + + {version?.commit ? version.commit.slice(0, 7) : "-"} + +
+
+ Branch + + {version?.branch ?? "-"} + +
+
+
+ ); +} diff --git a/frontend/src/components/layout/Footer.tsx b/frontend/src/components/layout/Footer.tsx new file mode 100644 index 0000000..2a643a4 --- /dev/null +++ b/frontend/src/components/layout/Footer.tsx @@ -0,0 +1,60 @@ +export default function Footer() { + return ( + + ); +} diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx new file mode 100644 index 0000000..171a9c8 --- /dev/null +++ b/frontend/src/components/layout/Header.tsx @@ -0,0 +1,20 @@ +import { useAppStore } from "../../stores/appStore.ts"; + +export default function Header() { + const displayName = useAppStore((s) => s.settings?.display_name ?? "MODAQ Upload"); + + return ( +
+
+

{displayName}

+ + National Laboratory of the Rockies + +
+
+ ); +} diff --git a/frontend/src/components/layout/Layout.tsx b/frontend/src/components/layout/Layout.tsx new file mode 100644 index 0000000..24c5add --- /dev/null +++ b/frontend/src/components/layout/Layout.tsx @@ -0,0 +1,24 @@ +import { useState } from "react"; +import { Outlet } from "react-router-dom"; +import NotificationStack from "../common/Notification.tsx"; +import AboutModal from "./AboutModal.tsx"; +import Footer from "./Footer.tsx"; +import Header from "./Header.tsx"; +import NavBar from "./NavBar.tsx"; + +export default function Layout() { + const [aboutOpen, setAboutOpen] = useState(false); + + return ( +
+
+ setAboutOpen(true)} /> +
+ +
+
+ setAboutOpen(false)} /> + +
+ ); +} diff --git a/frontend/src/components/layout/NavBar.tsx b/frontend/src/components/layout/NavBar.tsx new file mode 100644 index 0000000..37e7bba --- /dev/null +++ b/frontend/src/components/layout/NavBar.tsx @@ -0,0 +1,61 @@ +import { NavLink } from "react-router-dom"; +import { useAppStore } from "../../stores/appStore.ts"; + +const navItems = [ + { to: "/", label: "Upload" }, + { to: "/files", label: "Browse Uploaded Files" }, + { to: "/logs", label: "History" }, +]; + +interface NavBarProps { + onAboutClick: () => void; +} + +export default function NavBar({ onAboutClick }: NavBarProps) { + const version = useAppStore((s) => s.version?.version); + + return ( + + ); +} diff --git a/frontend/src/components/logs/CsvPreview.tsx b/frontend/src/components/logs/CsvPreview.tsx new file mode 100644 index 0000000..523e188 --- /dev/null +++ b/frontend/src/components/logs/CsvPreview.tsx @@ -0,0 +1,182 @@ +import { useCallback, useState } from "react"; +import { apiGet } from "../../api/client.ts"; +import type { CsvFileInfo, CsvPreviewResponse } from "../../types/api.ts"; + +function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / 1024 ** i).toFixed(i === 0 ? 0 : 1)} ${units[i]}`; +} + +interface CsvPreviewProps { + csvFiles: CsvFileInfo[]; +} + +export default function CsvPreview({ csvFiles }: CsvPreviewProps) { + const [expanded, setExpanded] = useState(false); + const [previewPath, setPreviewPath] = useState(null); + const [previewData, setPreviewData] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewError, setPreviewError] = useState(null); + + const loadPreview = useCallback(async (path: string) => { + // Toggle off if clicking same file + if (previewPath === path) { + setPreviewPath(null); + setPreviewData(null); + return; + } + + setPreviewPath(path); + setPreviewLoading(true); + setPreviewError(null); + try { + const data = await apiGet("/api/logs/csv-preview", { path }); + setPreviewData(data); + } catch (err) { + setPreviewError(err instanceof Error ? err.message : "Failed to load preview"); + } finally { + setPreviewLoading(false); + } + }, [previewPath]); + + if (csvFiles.length === 0) { + return null; + } + + return ( +
+ {/* Collapsible header */} + + + {expanded && ( +
+ {/* CSV file list */} +
+ {csvFiles.map((file) => ( +
+
+ + + +
+

{file.filename}

+

+ {file.date} -- {formatBytes(file.size)} +

+
+
+ + + Download + +
+
+ + {/* Inline preview */} + {previewPath === file.path && ( +
+ {previewLoading && ( +
+ Loading preview... +
+ )} + {previewError && ( +
{previewError}
+ )} + {previewData && !previewLoading && ( +
+ + + + {previewData.columns.map((col) => ( + + ))} + + + + {previewData.rows.map((row, i) => ( + + {previewData.columns.map((col) => ( + + ))} + + ))} + +
+ {col} +
+ {row[col] ?? ""} +
+
+ )} +
+ )} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/logs/FilterBar.tsx b/frontend/src/components/logs/FilterBar.tsx new file mode 100644 index 0000000..013bfcb --- /dev/null +++ b/frontend/src/components/logs/FilterBar.tsx @@ -0,0 +1,128 @@ +import { useCallback, useEffect, useState } from "react"; +import { useDebounce } from "../../hooks/useDebounce.ts"; + +export interface LogFilters { + date: string; + level: string; + category: string; + search: string; +} + +const EMPTY_FILTERS: LogFilters = { + date: "", + level: "", + category: "", + search: "", +}; + +const LEVELS = ["All", "INFO", "WARNING", "ERROR"] as const; +const CATEGORIES = ["All", "upload", "analysis", "settings", "app", "sync"] as const; + +interface FilterBarProps { + onFilterChange: (filters: LogFilters) => void; +} + +export default function FilterBar({ onFilterChange }: FilterBarProps) { + const [filters, setFilters] = useState(EMPTY_FILTERS); + const debouncedSearch = useDebounce(filters.search, 300); + + // Notify parent when any filter changes (debounced for search) + const { date, level, category } = filters; + useEffect(() => { + onFilterChange({ date, level, category, search: debouncedSearch }); + }, [date, level, category, debouncedSearch, onFilterChange]); + + const updateFilter = useCallback((key: K, value: LogFilters[K]) => { + setFilters((prev) => ({ ...prev, [key]: value })); + }, []); + + const clearFilters = () => { + setFilters(EMPTY_FILTERS); + }; + + const hasActiveFilters = + filters.date !== "" || filters.level !== "" || filters.category !== "" || filters.search !== ""; + + return ( +
+
+ {/* Date picker */} +
+ + updateFilter("date", e.target.value)} + className="px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-nlr-blue focus:border-transparent" + /> +
+ + {/* Level dropdown */} +
+ + +
+ + {/* Category dropdown */} +
+ + +
+ + {/* Search input */} +
+ + updateFilter("search", e.target.value)} + placeholder="Search messages..." + className="px-3 py-2 border border-gray-300 rounded text-sm focus:outline-none focus:ring-2 focus:ring-nlr-blue focus:border-transparent" + /> +
+ + {/* Clear button */} + {hasActiveFilters && ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/logs/LogStatsBar.tsx b/frontend/src/components/logs/LogStatsBar.tsx new file mode 100644 index 0000000..a48e7c5 --- /dev/null +++ b/frontend/src/components/logs/LogStatsBar.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from "react"; +import { apiGet } from "../../api/client.ts"; +import type { LogStats } from "../../types/api.ts"; +import StatCard from "../common/StatCard.tsx"; + +interface LogStatsBarProps { + onStatsLoaded?: (stats: LogStats) => void; +} + +export default function LogStatsBar({ onStatsLoaded }: LogStatsBarProps) { + const [stats, setStats] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchStats() { + try { + const data = await apiGet("/api/logs/stats"); + setStats(data); + onStatsLoaded?.(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load stats"); + } + } + void fetchStats(); + }, [onStatsLoaded]); + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!stats) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ); + } + + return ( +
+ + + + +
+ ); +} diff --git a/frontend/src/components/logs/LogTable.tsx b/frontend/src/components/logs/LogTable.tsx new file mode 100644 index 0000000..a9d6d50 --- /dev/null +++ b/frontend/src/components/logs/LogTable.tsx @@ -0,0 +1,265 @@ +import { useCallback, useEffect, useState } from "react"; +import { apiGet } from "../../api/client.ts"; +import { usePagination } from "../../hooks/usePagination.ts"; +import type { LogEntriesResponse, LogEntry } from "../../types/api.ts"; +import SortableHeader from "../common/SortableHeader.tsx"; +import type { LogFilters } from "./FilterBar.tsx"; + +const LEVEL_BADGES: Record = { + INFO: "bg-blue-100 text-blue-800", + WARNING: "bg-yellow-100 text-yellow-800", + ERROR: "bg-red-100 text-red-800", +}; + +function LevelBadge({ level }: { level: string }) { + const classes = LEVEL_BADGES[level] ?? "bg-gray-100 text-gray-800"; + return ( + + {level} + + ); +} + +function formatTimestamp(iso: string): string { + const d = new Date(iso); + return d.toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +type SortColumn = "timestamp" | "level" | "category" | "event"; + +interface LogTableProps { + filters: LogFilters; +} + +export default function LogTable({ filters }: LogTableProps) { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expandedId, setExpandedId] = useState(null); + const [sortColumn, setSortColumn] = useState("timestamp"); + const [ascending, setAscending] = useState(false); + + const pagination = usePagination(50); + const { offset, limit, setTotal, reset: paginationReset } = pagination; + + const toggleSort = useCallback( + (column: SortColumn) => { + if (column === sortColumn) { + setAscending((prev) => !prev); + } else { + setSortColumn(column); + setAscending(column === "timestamp" ? false : true); + } + }, + [sortColumn], + ); + + const fetchEntries = useCallback(async () => { + setLoading(true); + setError(null); + try { + const params: Record = { + offset: String(offset), + limit: String(limit), + }; + if (filters.date) params.date = filters.date; + if (filters.level) params.level = filters.level; + if (filters.category) params.category = filters.category; + if (filters.search) params.search = filters.search; + + const data = await apiGet("/api/logs/entries", params); + setEntries(data.entries); + setTotal(data.total); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load log entries"); + } finally { + setLoading(false); + } + }, [filters, offset, limit, setTotal]); + + // Reset to page 1 when filters change + useEffect(() => { + paginationReset(); + }, [filters.date, filters.level, filters.category, filters.search, paginationReset]); + + useEffect(() => { + void fetchEntries(); + }, [fetchEntries]); + + // Client-side sort of the current page + const sortedEntries = [...entries].sort((a, b) => { + const aVal = a[sortColumn]; + const bVal = b[sortColumn]; + let cmp = 0; + if (typeof aVal === "string" && typeof bVal === "string") { + cmp = aVal.localeCompare(bVal); + } + return ascending ? cmp : -cmp; + }); + + const toggleExpand = (id: number) => { + setExpandedId((prev) => (prev === id ? null : id)); + }; + + return ( +
+ {/* Table */} +
+ + + + toggleSort("timestamp")} + /> + toggleSort("level")} + /> + toggleSort("category")} + /> + toggleSort("event")} + /> + + + + + {loading && entries.length === 0 ? ( + + + + ) : error ? ( + + + + ) : sortedEntries.length === 0 ? ( + + + + ) : ( + sortedEntries.map((entry) => ( + toggleExpand(entry.id)} + /> + )) + )} + +
+ Message +
+ Loading log entries... +
+ {error} +
+ No log entries found. +
+
+ + {/* Pagination */} + {pagination.totalPages > 1 && ( +
+ + Page {pagination.currentPage} of {pagination.totalPages} + +
+ + +
+
+ )} +
+ ); +} + +/** Single log row with expandable metadata */ +function LogRow({ + entry, + expanded, + onToggle, +}: { + entry: LogEntry; + expanded: boolean; + onToggle: () => void; +}) { + const hasMetadata = entry.metadata && Object.keys(entry.metadata).length > 0; + + return ( + <> + + + {formatTimestamp(entry.timestamp)} + + + + + {entry.category} + {entry.event} + +
+ {entry.message} + {hasMetadata && ( + + + + )} +
+ + + {expanded && hasMetadata && ( + + +
+              {JSON.stringify(entry.metadata, null, 2)}
+            
+ + + )} + + ); +} diff --git a/frontend/src/components/logs/UploadSessionList.tsx b/frontend/src/components/logs/UploadSessionList.tsx new file mode 100644 index 0000000..6275f3c --- /dev/null +++ b/frontend/src/components/logs/UploadSessionList.tsx @@ -0,0 +1,180 @@ +import { useState } from "react"; +import type { UploadSession, UploadSessionFile } from "../../types/api.ts"; + +interface UploadSessionListProps { + sessions: UploadSession[]; +} + +function StatusBadge({ status }: { status: string }) { + const colors: Record = { + completed: "bg-green-100 text-green-700", + skipped: "bg-gray-100 text-gray-600", + failed: "bg-red-100 text-red-700", + }; + return ( + + {status} + + ); +} + +function SessionStatusSummary({ session }: { session: UploadSession }) { + const parts: { text: string; color: string }[] = []; + if (session.completed > 0) parts.push({ text: `${session.completed} completed`, color: "text-green-600" }); + if (session.failed > 0) parts.push({ text: `${session.failed} failed`, color: "text-red-600" }); + if (session.skipped > 0) parts.push({ text: `${session.skipped} skipped`, color: "text-gray-500" }); + + return ( + + {parts.map((p, i) => ( + + {i > 0 && , } + {p.text} + + ))} + + ); +} + +function formatDuration(seconds: number): string { + if (seconds < 60) return `${Math.round(seconds)}s`; + const m = Math.floor(seconds / 60); + const s = Math.round(seconds % 60); + return s > 0 ? `${m}m ${s}s` : `${m}m`; +} + +function FileDetailTable({ files }: { files: UploadSessionFile[] }) { + return ( +
+ + + + + + + + + + + + {files.map((file) => ( + + + + + + + + ))} + {/* Show error rows separately for failed files */} + {files + .filter((f) => f.status === "failed" && f.error_message) + .map((f) => ( + + + + ))} + +
FilenameSizeStatusSpeedS3 Path
{file.filename}{file.file_size_formatted} + {file.upload_speed_mbps ? `${file.upload_speed_mbps} Mbps` : "-"} + + {file.s3_path || "-"} +
+ {f.filename}: {f.error_message} +
+
+ ); +} + +export default function UploadSessionList({ sessions }: UploadSessionListProps) { + const [expandedIndex, setExpandedIndex] = useState(null); + + if (sessions.length === 0) { + return ( +
+ No upload sessions found. Upload some files to see history here. +
+ ); + } + + return ( +
+ {sessions.map((session, i) => { + const isExpanded = expandedIndex === i; + return ( +
+ {/* Session header row */} + + + {/* Expanded file detail */} + {isExpanded && ( +
+ +
+ )} +
+ ); + })} +
+ ); +} diff --git a/frontend/src/components/logs/UploadStatsBar.tsx b/frontend/src/components/logs/UploadStatsBar.tsx new file mode 100644 index 0000000..1b434c1 --- /dev/null +++ b/frontend/src/components/logs/UploadStatsBar.tsx @@ -0,0 +1,72 @@ +import { useEffect, useState } from "react"; +import { apiGet } from "../../api/client.ts"; +import type { UploadStatsResponse } from "../../types/api.ts"; +import StatCard from "../common/StatCard.tsx"; + +interface UploadStatsBarProps { + onDataLoaded?: (data: UploadStatsResponse) => void; +} + +export default function UploadStatsBar({ onDataLoaded }: UploadStatsBarProps) { + const [stats, setStats] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + async function fetchStats() { + try { + const data = await apiGet("/api/logs/upload-stats"); + setStats(data); + onDataLoaded?.(data); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load upload stats"); + } + } + void fetchStats(); + }, [onDataLoaded]); + + if (error) { + return ( +
+ {error} +
+ ); + } + + if (!stats) { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ); + } + + return ( +
+ + + + +
+ ); +} diff --git a/frontend/src/components/settings/CacheSection.tsx b/frontend/src/components/settings/CacheSection.tsx new file mode 100644 index 0000000..15f490a --- /dev/null +++ b/frontend/src/components/settings/CacheSection.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from "react"; +import { apiGet, apiPost } from "../../api/client.ts"; +import { useAppStore } from "../../stores/appStore.ts"; +import type { CacheStats, CacheSyncResult } from "../../types/api.ts"; + +export default function CacheSection() { + const { addNotification } = useAppStore(); + + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + const [syncing, setSyncing] = useState(false); + const [syncResult, setSyncResult] = useState(null); + + async function loadStats() { + setLoading(true); + try { + const data = await apiGet("/api/settings/cache/stats"); + setStats(data); + } catch { + addNotification("error", "Failed to load cache statistics"); + } finally { + setLoading(false); + } + } + + useEffect(() => { + loadStats(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + async function handleSync() { + setSyncing(true); + setSyncResult(null); + try { + const result = await apiPost("/api/settings/cache/sync"); + setSyncResult(result); + if (result.success) { + addNotification("success", "Cache synced with AWS"); + loadStats(); + } else { + addNotification("error", result.error ?? "Cache sync failed"); + } + } catch { + addNotification("error", "Failed to sync cache"); + } finally { + setSyncing(false); + } + } + + return ( +
+

Upload Cache

+ + {loading ? ( +

Loading cache statistics...

+ ) : stats?.success ? ( +
+
+
Total Entries
+
+ {stats.stats.total_entries.toLocaleString()} +
+
+
+
Exists
+
+ {stats.stats.exists_count.toLocaleString()} +
+
+
+
Not Exists
+
+ {stats.stats.not_exists_count.toLocaleString()} +
+
+
+
Bucket
+
+ {stats.stats.bucket || "-"} +
+
+
+ ) : ( +

No cache data available.

+ )} + + {/* Sync result */} + {syncResult?.success && ( +
+ Synced: {syncResult.files_in_s3?.toLocaleString()} files in S3,{" "} + {syncResult.files_updated?.toLocaleString()} updated,{" "} + {syncResult.files_removed?.toLocaleString()} removed +
+ )} + + +
+ ); +} diff --git a/frontend/src/components/settings/DangerZone.tsx b/frontend/src/components/settings/DangerZone.tsx new file mode 100644 index 0000000..9f61bbd --- /dev/null +++ b/frontend/src/components/settings/DangerZone.tsx @@ -0,0 +1,145 @@ +import { useState } from "react"; +import { apiPost } from "../../api/client.ts"; +import { useAppStore } from "../../stores/appStore.ts"; +import { PowerIcon } from "../../utils/icons.tsx"; + +const DEFAULT_SETTINGS = { + aws_profile: "default", + aws_region: "us-west-2", + s3_bucket: "", + default_upload_folder: "", + display_name: "", + log_directory: "logs", +}; + +export default function DangerZone() { + const { updateSettings, addNotification } = useAppStore(); + + const [clearing, setClearing] = useState(false); + const [resetting, setResetting] = useState(false); + const [shuttingDown, setShuttingDown] = useState(false); + + async function handleClearCache() { + if (!window.confirm("Are you sure you want to clear the upload cache? This cannot be undone.")) { + return; + } + + setClearing(true); + try { + const result = await apiPost<{ success: boolean; deleted: number; message: string }>( + "/api/settings/cache/invalidate", + ); + if (result.success) { + addNotification("success", `Cleared ${result.deleted} cache entries`); + } else { + addNotification("error", "Failed to clear cache"); + } + } catch { + addNotification("error", "Failed to clear cache"); + } finally { + setClearing(false); + } + } + + async function handleResetSettings() { + if ( + !window.confirm( + "Are you sure you want to reset all settings to defaults? This cannot be undone.", + ) + ) { + return; + } + + setResetting(true); + try { + await updateSettings(DEFAULT_SETTINGS); + addNotification("info", "Settings reset to defaults"); + } catch { + addNotification("error", "Failed to reset settings"); + } finally { + setResetting(false); + } + } + + async function handleShutdown() { + if ( + !window.confirm( + "Are you sure you want to shut down the server? You will need to restart it manually.", + ) + ) { + return; + } + + setShuttingDown(true); + try { + await apiPost<{ success: boolean; message: string }>("/api/settings/shutdown"); + addNotification("info", "Server is shutting down..."); + } catch { + addNotification("error", "Failed to shut down server"); + setShuttingDown(false); + } + } + + return ( +
+

Danger Zone

+

+ These actions are destructive and cannot be undone. +

+ +
+
+
+
Clear Upload Cache
+
+ Removes all cached duplicate-check entries for the current bucket. +
+
+ +
+ +
+
+
Reset Settings
+
+ Restores all settings to their default values. +
+
+ +
+ +
+
+
Shutdown Server
+
+ Gracefully shuts down the application. You will need to restart it manually. +
+
+ +
+
+
+ ); +} diff --git a/frontend/src/components/settings/PerformanceSection.tsx b/frontend/src/components/settings/PerformanceSection.tsx new file mode 100644 index 0000000..9bb2543 --- /dev/null +++ b/frontend/src/components/settings/PerformanceSection.tsx @@ -0,0 +1,262 @@ +/** + * Performance settings section for batch processing configuration. + * + * Allows users to configure: + * - Skip MCAP validation (fast filename-only parsing) + * - Batch size for large uploads + * - Auto-tune workers based on CPU + * - Max worker count + * + * Settings auto-save after changes (debounced for continuous inputs like sliders). + */ + +import { useCallback, useEffect, useRef, useState } from "react"; +import { useAppStore } from "../../stores/appStore.ts"; +import type { BatchProcessingSettings, ValueSource } from "../../types/api.ts"; +import { CheckIcon, InfoIcon, SpinnerIcon } from "../../utils/icons.tsx"; + +function SectionSourceNote({ source }: { source?: ValueSource }) { + if (!source || source.source === "builtin") return null; + + if (source.source === "settings_file" || source.source === "default_file") { + const filename = source.path?.split("/").pop() ?? source.path ?? ""; + const label = + source.source === "default_file" + ? `Default values — ${filename}` + : `Saved in ${filename}`; + return ( +

+ {label} +

+ ); + } + + return null; // batch_processing has no env override support +} + +function getDefaultSettings(): BatchProcessingSettings { + return { + enabled: true, + batch_size: 100, + auto_tune_workers: true, + max_workers: 4, + target_cpu_percent: 70.0, + skip_mcap_validation: false, + use_database_for_large_jobs: true, + large_job_threshold: 1000, + }; +} + +type SaveStatus = "idle" | "saving" | "saved" | "error"; + +export default function PerformanceSection() { + const { settings: appSettings, updateSettings } = useAppStore(); + const batchSource = appSettings?.value_sources?.["batch_processing"]; + + const [settings, setSettings] = useState( + appSettings?.batch_processing ?? getDefaultSettings(), + ); + const [saveStatus, setSaveStatus] = useState("idle"); + + // Track whether a change originated from the user (vs. store sync). + const userChangedRef = useRef(false); + const debounceRef = useRef | null>(null); + const savedTimerRef = useRef | null>(null); + + // Re-sync from the store when settings are loaded externally (e.g. page + // navigation reload), but only when there is no pending user change. + useEffect(() => { + if (appSettings?.batch_processing && !userChangedRef.current) { + setSettings(appSettings.batch_processing); + } + }, [appSettings]); + + // Cleanup timers on unmount. + useEffect(() => { + return () => { + if (debounceRef.current !== null) clearTimeout(debounceRef.current); + if (savedTimerRef.current !== null) clearTimeout(savedTimerRef.current); + }; + }, []); + + const save = useCallback( + async (toSave: BatchProcessingSettings) => { + setSaveStatus("saving"); + try { + await updateSettings({ batch_processing: toSave }); + userChangedRef.current = false; + setSaveStatus("saved"); + // Clear "saved" indicator after 2 seconds. + savedTimerRef.current = setTimeout(() => setSaveStatus("idle"), 2000); + } catch { + setSaveStatus("error"); + } + }, + [updateSettings], + ); + + function handleChange(field: keyof BatchProcessingSettings, value: unknown) { + const next = { ...settings, [field]: value }; + setSettings(next); + userChangedRef.current = true; + + // Debounce the save so continuous inputs (sliders) don't fire on every tick. + if (debounceRef.current !== null) clearTimeout(debounceRef.current); + if (savedTimerRef.current !== null) clearTimeout(savedTimerRef.current); + debounceRef.current = setTimeout(() => save(next), 600); + } + + async function handleReset() { + const defaults = getDefaultSettings(); + setSettings(defaults); + userChangedRef.current = true; + if (debounceRef.current !== null) clearTimeout(debounceRef.current); + if (savedTimerRef.current !== null) clearTimeout(savedTimerRef.current); + await save(defaults); + } + + return ( +
+
+
+

Performance

+ {saveStatus === "saving" && ( + + + Saving... + + )} + {saveStatus === "saved" && ( + + + Saved + + )} + {saveStatus === "error" && ( + Save failed + )} +
+ +
+ +
+ {/* Skip MCAP Validation */} +
+ handleChange("skip_mcap_validation", e.target.checked)} + className="mt-1 h-4 w-4 text-nlr-blue border-gray-300 rounded focus:ring-nlr-blue" + /> +
+ +

+ Extract timestamps from filenames only (3000x faster). Use when filenames are correctly formatted. +

+
+
+ + {/* Batch Size */} +
+ + handleChange("batch_size", Number.parseInt(e.target.value))} + className="w-full h-2 bg-gray-200 rounded-lg appearance-none cursor-pointer accent-nlr-blue" + /> +

+ Number of files processed per batch (50-500). Lower values reduce memory usage. +

+
+ + {/* Auto-tune Workers */} +
+ handleChange("auto_tune_workers", e.target.checked)} + className="mt-1 h-4 w-4 text-nlr-blue border-gray-300 rounded focus:ring-nlr-blue" + /> +
+ +

+ Automatically adjust worker count based on CPU/memory utilization. Recommended for large jobs. +

+
+
+ + {/* Max Workers */} +
+ + handleChange("max_workers", Number.parseInt(e.target.value))} + className="w-32 px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-nlr-blue" + /> +

+ Maximum concurrent workers (2-16). Higher values increase throughput but use more resources. +

+
+ + {/* Large Job Threshold */} +
+ + handleChange("large_job_threshold", Number.parseInt(e.target.value))} + className="w-32 px-3 py-2 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-nlr-blue" + /> +

+ Jobs with more files than this threshold use batch processing and database storage. +

+
+ + {/* Info banner */} +
+ +
+ Batch processing optimizes memory usage and performance for large uploads (1000+ files). + Results are stored in a database and retrieved on demand. +
+
+ + {/* Reset button */} +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/settings/SettingsForm.tsx b/frontend/src/components/settings/SettingsForm.tsx new file mode 100644 index 0000000..9817cc8 --- /dev/null +++ b/frontend/src/components/settings/SettingsForm.tsx @@ -0,0 +1,320 @@ +import { useEffect, useState } from "react"; +import { apiGet, apiPost } from "../../api/client.ts"; +import { useAppStore } from "../../stores/appStore.ts"; +import type { AppSettings, ConnectionTestResult, ValueSource } from "../../types/api.ts"; +import { LockIcon } from "../../utils/icons.tsx"; + +const AWS_REGIONS = ["us-east-1", "us-east-2", "us-west-1", "us-west-2"]; + +/** Shows where a setting value comes from and locks the field if it is env-overridden. */ +function SourceBadge({ source }: { source?: ValueSource }) { + if (!source) return null; + + if (source.source === "env") { + return ( + + + Locked — set by environment variable{" "} + {source.env_var} + + ); + } + + if (source.source === "settings_file" || source.source === "default_file") { + const filename = source.path?.split("/").pop() ?? source.path ?? ""; + const label = + source.source === "default_file" + ? `Default — ${filename}` + : `Saved in ${filename}`; + return ( + + {label} + + ); + } + + return Built-in default; +} + +export default function SettingsForm() { + const { settings, updateSettings } = useAppStore(); + + const [profiles, setProfiles] = useState([]); + const [formValues, setFormValues] = useState>({}); + const [isDirty, setIsDirty] = useState(false); + const [saving, setSaving] = useState(false); + const [customRegion, setCustomRegion] = useState(false); + + // Connection test state + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState(null); + + // Load profiles on mount + useEffect(() => { + apiGet<{ profiles: string[] }>("/api/settings/profiles") + .then((data) => setProfiles(data.profiles)) + .catch(() => { + /* profiles are non-critical */ + }); + }, []); + + // Sync form values when settings load + useEffect(() => { + if (settings) { + setFormValues({ + aws_profile: settings.aws_profile, + aws_region: settings.aws_region, + s3_bucket: settings.s3_bucket, + default_upload_folder: settings.default_upload_folder, + display_name: settings.display_name, + log_directory: settings.log_directory, + }); + setCustomRegion(!AWS_REGIONS.includes(settings.aws_region)); + setIsDirty(false); + } + }, [settings]); + + function isLocked(field: keyof AppSettings): boolean { + return settings?.value_sources?.[field]?.source === "env"; + } + + function handleChange(field: keyof AppSettings, value: string) { + if (isLocked(field)) return; + setFormValues((prev) => ({ ...prev, [field]: value })); + setIsDirty(true); + setTestResult(null); + } + + function handleRegionSelect(value: string) { + if (value === "__other__") { + setCustomRegion(true); + handleChange("aws_region", ""); + } else { + setCustomRegion(false); + handleChange("aws_region", value); + } + } + + async function handleTestConnection() { + setTesting(true); + setTestResult(null); + try { + const result = await apiPost("/api/settings/validate", { + aws_profile: formValues.aws_profile, + aws_region: formValues.aws_region, + s3_bucket: formValues.s3_bucket, + }); + setTestResult(result); + } catch { + setTestResult({ success: false, error: "Connection test failed" }); + } finally { + setTesting(false); + } + } + + async function handleSave() { + setSaving(true); + try { + // Filter out env-overridden keys — they can't be changed anyway + const vsrc = settings?.value_sources ?? {}; + const toSave = Object.fromEntries( + Object.entries(formValues).filter(([k]) => vsrc[k]?.source !== "env"), + ); + await updateSettings(toSave); + setIsDirty(false); + } finally { + setSaving(false); + } + } + + const inputBase = + "w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-nlr-blue focus:ring-1 focus:ring-nlr-blue focus:outline-none"; + const inputLocked = "bg-gray-50 text-gray-500 cursor-not-allowed"; + + return ( +
+

AWS Configuration

+ +
+ {/* AWS Profile */} +
+ + + +
+ + {/* AWS Region */} +
+ + {!customRegion ? ( + + ) : ( +
+ handleChange("aws_region", e.target.value)} + placeholder="e.g., eu-north-1" + disabled={isLocked("aws_region")} + className={`flex-1 rounded-md border border-gray-300 px-3 py-2 text-sm focus:border-nlr-blue focus:ring-1 focus:ring-nlr-blue focus:outline-none ${isLocked("aws_region") ? inputLocked : ""}`} + /> + +
+ )} + +
+ + {/* S3 Bucket */} +
+ + handleChange("s3_bucket", e.target.value)} + placeholder="my-bucket-name" + disabled={isLocked("s3_bucket")} + className={`${inputBase} ${isLocked("s3_bucket") ? inputLocked : ""}`} + /> + +
+ + {/* Default Upload Folder */} +
+ + handleChange("default_upload_folder", e.target.value)} + placeholder="/path/to/mcap/files" + disabled={isLocked("default_upload_folder")} + className={`${inputBase} ${isLocked("default_upload_folder") ? inputLocked : ""}`} + /> + +
+ + {/* Display Name */} +
+ + handleChange("display_name", e.target.value)} + placeholder="SURF-WEC MODAQ Uploader" + disabled={isLocked("display_name")} + className={`${inputBase} ${isLocked("display_name") ? inputLocked : ""}`} + /> + +
+ + {/* Log Directory */} +
+ + handleChange("log_directory", e.target.value)} + placeholder="logs" + disabled={isLocked("log_directory")} + className={`${inputBase} ${isLocked("log_directory") ? inputLocked : ""}`} + /> + +
+
+ + {/* Connection test result */} + {testResult && ( +
+ {testResult.success ? testResult.message : testResult.error} +
+ )} + + {/* Buttons */} +
+ + + + + {isDirty && ( + Unsaved changes + )} +
+
+ ); +} diff --git a/frontend/src/components/settings/UpdateSection.tsx b/frontend/src/components/settings/UpdateSection.tsx new file mode 100644 index 0000000..ac7d3a6 --- /dev/null +++ b/frontend/src/components/settings/UpdateSection.tsx @@ -0,0 +1,143 @@ +import { useEffect, useState } from "react"; +import { apiGet, apiPost } from "../../api/client.ts"; +import { useAppStore } from "../../stores/appStore.ts"; +import type { UpdateCheckResult, UpdateResult } from "../../types/api.ts"; + +export default function UpdateSection() { + const { version, loadVersion, addNotification } = useAppStore(); + + const [checkResult, setCheckResult] = useState(null); + const [checking, setChecking] = useState(false); + const [updating, setUpdating] = useState(false); + const [updateResult, setUpdateResult] = useState(null); + + useEffect(() => { + loadVersion(); + }, [loadVersion]); + + async function handleCheckUpdates() { + setChecking(true); + setCheckResult(null); + try { + const result = await apiGet("/api/settings/check-updates"); + setCheckResult(result); + } catch { + addNotification("error", "Failed to check for updates"); + } finally { + setChecking(false); + } + } + + async function handleUpdate() { + setUpdating(true); + setUpdateResult(null); + try { + const result = await apiPost("/api/settings/update"); + setUpdateResult(result); + if (result.success) { + addNotification("success", "Application updated successfully"); + loadVersion(); + } else { + addNotification("warning", "Update completed with some errors"); + } + } catch { + addNotification("error", "Failed to update application"); + } finally { + setUpdating(false); + } + } + + return ( +
+

Application Updates

+ + {/* Version info grid */} + {version && ( +
+
+
Version
+
+ {version.version} +
+
+
+
Commit
+
+ {version.commit.slice(0, 7)} + {version.dirty && (dirty)} +
+
+
+
Branch
+
+ {version.branch} +
+
+
+ )} + + {/* Update check result */} + {checkResult && ( +
+ {checkResult.updates_available + ? `${checkResult.commits_behind} commit${checkResult.commits_behind !== 1 ? "s" : ""} behind remote` + : "Up to date"} +
+ )} + + {/* Update result log */} + {updateResult && ( +
+ {Object.entries(updateResult.results).map(([step, result]) => ( +
+
+ + {result.success ? "OK" : "FAIL"} + + {step} +
+
+                {result.output || "(no output)"}
+              
+
+ ))} +
+ )} + + {/* Buttons */} +
+ + + {checkResult?.updates_available && ( + + )} +
+
+ ); +} diff --git a/frontend/src/components/upload/ActiveFilesList.tsx b/frontend/src/components/upload/ActiveFilesList.tsx new file mode 100644 index 0000000..9d92eb5 --- /dev/null +++ b/frontend/src/components/upload/ActiveFilesList.tsx @@ -0,0 +1,104 @@ +/** + * Compact list showing only files currently being uploaded. + * + * Displays up to 8 active files with: + * - Filename + * - File size + * - Upload progress bar + * - Status indicator + * + * Used during upload phase to show real-time activity without + * overwhelming the UI with thousands of rows. + */ + +import type { FileUploadState } from "../../types/api.ts"; +import { formatBytes } from "../../utils/format/bytes.ts"; +import ProgressBar from "../common/ProgressBar.tsx"; +import Spinner from "../common/Spinner.tsx"; +import { CheckIcon, XIcon, WarningIcon } from "../../utils/icons.tsx"; + +interface ActiveFilesListProps { + /** Currently active files (max 8) */ + files: FileUploadState[]; +} + +export default function ActiveFilesList({ files }: ActiveFilesListProps) { + if (files.length === 0) { + return ( +
+

No files currently uploading

+
+ ); + } + + return ( +
+
+

Currently Uploading

+ {files.length} active +
+ +
+ {files.map((file) => ( + + ))} +
+
+ ); +} + +function ActiveFileCard({ file }: { file: FileUploadState }) { + const statusIcon = getStatusIcon(file.status); + + return ( +
+
+
+
+

{file.filename}

+ {statusIcon} +
+

+ {formatBytes(file.file_size)} + {file.status === "uploading" && file.bytes_uploaded > 0 && ( + + {formatBytes(file.bytes_uploaded)} uploaded + + )} +

+
+
+ + {/* Progress bar for uploading files */} + {file.status === "uploading" && ( +
+ +
+ )} + + {/* Error message for failed files */} + {file.status === "failed" && file.error_message && ( +
+ {file.error_message} +
+ )} +
+ ); +} + +function getStatusIcon(status: string) { + switch (status) { + case "uploading": + return ; + case "completed": + return ; + case "failed": + return ; + case "skipped": + return ; + case "analyzing": + return ; + default: + return null; + } +} diff --git a/frontend/src/components/upload/BatchProgress.tsx b/frontend/src/components/upload/BatchProgress.tsx new file mode 100644 index 0000000..89aeeb1 --- /dev/null +++ b/frontend/src/components/upload/BatchProgress.tsx @@ -0,0 +1,131 @@ +/** + * Batch progress indicator for large upload jobs. + * + * Shows: + * - Current batch number (e.g., "Batch 5/200") + * - Progress bar for current batch + * - Cumulative job statistics + * - Active files being processed (max 8) + * + * Used during upload phase for jobs processed in batches. + */ + +import type { BatchState } from "../../types/api.ts"; +import ProgressBar from "../common/ProgressBar.tsx"; +import Spinner from "../common/Spinner.tsx"; +import { InfoIcon } from "../../utils/icons.tsx"; + +interface BatchProgressProps { + /** Current batch state */ + batchState: BatchState | null; + + /** Overall job progress (0-100) */ + jobProgressPercent: number; + + /** Total files completed across all batches */ + jobFilesCompleted: number; + + /** Total files in entire job */ + jobFilesTotal: number; + + /** Total files uploaded successfully across all batches */ + jobFilesUploaded: number; + + /** Total files failed across all batches */ + jobFilesFailed: number; + + /** Whether the job is actively running */ + isRunning: boolean; +} + +export default function BatchProgress({ + batchState, + jobProgressPercent, + jobFilesCompleted, + jobFilesTotal, + jobFilesUploaded, + jobFilesFailed, + isRunning, +}: BatchProgressProps) { + if (!batchState) { + return null; + } + + const batchProgressPercent = batchState.files_in_batch > 0 + ? (batchState.files_processed / batchState.files_in_batch) * 100 + : 0; + + const currentBatch = batchState.batch_id + 1; // 0-indexed to 1-indexed + const totalBatches = batchState.total_batches; + + return ( +
+ {/* Batch indicator */} +
+
+
+

+ Batch {currentBatch} of {totalBatches} +

+ {isRunning && batchState.status === "processing" && } +
+
+ {batchState.files_in_batch} files in this batch +
+
+ + {/* Batch progress */} + + + {/* Batch stats */} +
+ + Uploaded: {batchState.files_uploaded} | Failed: {batchState.files_failed} + + {batchState.status === "completed" && batchState.duration_seconds && ( + {batchState.duration_seconds.toFixed(1)}s + )} +
+
+ + {/* Overall job progress */} +
+
+

Overall Progress

+
+ + + + {/* Job stats */} +
+ + Uploaded: {jobFilesUploaded} | Failed: {jobFilesFailed} + + + {totalBatches - currentBatch} batch{totalBatches - currentBatch !== 1 ? "es" : ""} remaining + +
+
+ + {/* Info banner */} + {totalBatches > 10 && ( +
+ +
+ Large job detected: Files are being processed in batches + to optimize memory usage and performance. Full results will be available + after completion. +
+
+ )} +
+ ); +} diff --git a/frontend/src/components/upload/CancelConfirmModal.tsx b/frontend/src/components/upload/CancelConfirmModal.tsx new file mode 100644 index 0000000..253149c --- /dev/null +++ b/frontend/src/components/upload/CancelConfirmModal.tsx @@ -0,0 +1,66 @@ +/** + * Confirmation modal shown when the user clicks "Cancel Upload". + */ + +import Modal from "../common/Modal.tsx"; +import { WarningIcon } from "../../utils/icons.tsx"; + +interface CancelConfirmModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + filesProcessed: number; + totalFiles: number; +} + +export default function CancelConfirmModal({ + isOpen, + onClose, + onConfirm, + filesProcessed, + totalFiles, +}: CancelConfirmModalProps) { + const remaining = totalFiles - filesProcessed; + + return ( + + + +
+ } + > +
+
+ +
+

+ {filesProcessed} of {totalFiles} files + have been processed so far. Cancelling will stop the remaining{" "} + {remaining} file{remaining !== 1 ? "s" : ""} from being uploaded. +

+

+ Files already uploaded will remain in cloud storage. +

+
+
+
+ + ); +} diff --git a/frontend/src/components/upload/CancelScanModal.tsx b/frontend/src/components/upload/CancelScanModal.tsx new file mode 100644 index 0000000..5545ef6 --- /dev/null +++ b/frontend/src/components/upload/CancelScanModal.tsx @@ -0,0 +1,63 @@ +/** + * Confirmation modal shown when the user tries to cancel a folder scan. + */ + +import Modal from "../common/Modal.tsx"; +import { WarningIcon } from "../../utils/icons.tsx"; + +interface CancelScanModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + foldersScanned: number; + filesFound: number; +} + +export default function CancelScanModal({ + isOpen, + onClose, + onConfirm, + foldersScanned, + filesFound, +}: CancelScanModalProps) { + return ( + + + +
+ } + > +
+
+ +
+

+ Scanning has found {filesFound} file{filesFound !== 1 ? "s" : ""} in{" "} + {foldersScanned} folder{foldersScanned !== 1 ? "s" : ""} so far. +

+

+ Cancelling will stop the scan and you'll need to start over if you want to continue. +

+
+
+
+ + ); +} diff --git a/frontend/src/components/upload/ConfirmModal.tsx b/frontend/src/components/upload/ConfirmModal.tsx new file mode 100644 index 0000000..734356b --- /dev/null +++ b/frontend/src/components/upload/ConfirmModal.tsx @@ -0,0 +1,111 @@ +/** + * Pre-upload confirmation modal. + * + * Shows file count and total size, with a "Force re-upload duplicates" toggle + * that dynamically updates the file count. + */ + +import { useMemo, useState } from "react"; + +import { formatBytes } from "../../utils/format/bytes.ts"; +import Modal from "../common/Modal.tsx"; + +interface ConfirmModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (skipDuplicates: boolean) => void; + totalFiles: number; + alreadyUploaded: number; + totalSize: number; +} + +export default function ConfirmModal({ + isOpen, + onClose, + onConfirm, + totalFiles, + alreadyUploaded, + totalSize, +}: ConfirmModalProps) { + const [forceReupload, setForceReupload] = useState(false); + + const filesToUpload = useMemo( + () => (forceReupload ? totalFiles : totalFiles - alreadyUploaded), + [forceReupload, totalFiles, alreadyUploaded], + ); + + function handleConfirm() { + onConfirm(!forceReupload); // skipDuplicates = NOT forceReupload + setForceReupload(false); + } + + function handleClose() { + setForceReupload(false); + onClose(); + } + + return ( + + + +
+ } + > +
+
+
+
+ {filesToUpload.toLocaleString()} +
+
Files to Upload
+
+
+
+ {formatBytes(totalSize)} +
+
Total Size
+
+
+ + {alreadyUploaded > 0 && ( + <> +
+ {alreadyUploaded.toLocaleString()} file{alreadyUploaded !== 1 ? "s" : ""} already + exist in S3 and will be skipped. +
+ + + + )} +
+ + ); +} diff --git a/frontend/src/components/upload/FolderBrowser.tsx b/frontend/src/components/upload/FolderBrowser.tsx new file mode 100644 index 0000000..51b9876 --- /dev/null +++ b/frontend/src/components/upload/FolderBrowser.tsx @@ -0,0 +1,769 @@ +/** + * Step 1: Filesystem browser for selecting a folder of MCAP files. + * + * Fetches `GET /api/files/browse?path=...` and renders: + * - Quick links sidebar + * - Breadcrumb navigation + * - Summary bar with file counts + upload button (top, always visible) + * - Folder list with upload progress indicators + * - MCAP file list with clear uploaded/new status + */ + +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { apiGet } from "../../api/client.ts"; +import type { BrowseResponse, LocalFile, LocalFolder } from "../../types/api.ts"; +import { formatBytes } from "../../utils/format/bytes.ts"; +import { formatDate } from "../../utils/format/date.ts"; +import Breadcrumb from "../common/Breadcrumb.tsx"; +import Spinner from "../common/Spinner.tsx"; +import { + FolderIcon, + FileIcon, + ChevronRightIcon, + CheckIcon, + PlusIcon, + RefreshIcon, + UploadIcon, +} from "../../utils/icons.tsx"; + +type FileSortKey = "filename" | "size" | "mtime" | "status"; +type FileSortDir = "asc" | "desc"; + +export type BrowserMode = "upload" | "delete"; + +export interface FolderExclusions { + subfolders: string[]; + files: string[]; +} + +interface FolderBrowserProps { + onFolderSelected: (folderPath: string, exclusions?: FolderExclusions) => void; + initialPath?: string; + /** Controls wording, colors, and which files are emphasised. Default: "upload". */ + mode?: BrowserMode; +} + +/** Mode-dependent text and styling. */ +const modeConfig = { + upload: { + title: "Select Folder to Upload", + subtitle: "Browse to a folder containing MCAP files.", + details: "", + /** Stat chip shown for the "actionable" count (new files for upload). */ + actionableLabel: "not uploaded", + actionableColor: "green" as const, + actionableIcon: "plus" as const, + /** Stat chip shown for the "other" count (already uploaded). */ + otherLabel: "already uploaded", + otherColor: "gray" as const, + otherIcon: "check" as const, + /** Action button wording — count is the "actionable" file count. */ + buttonLabel: (count: number) => + count > 0 ? `Upload ${count.toLocaleString()} file${count !== 1 ? "s" : ""}` : "Upload This Folder", + buttonColor: "bg-nlr-blue text-white hover:bg-blue-700", + /** For folders: dim when ALL files are already uploaded (nothing to upload). */ + dimFolder: (allUploaded: boolean, _noneUploaded: boolean) => allUploaded, + /** For file rows: dim files that are already uploaded. */ + dimFile: (uploaded: boolean) => uploaded, + /** Status badge for "uploaded" files. */ + uploadedBadge: { bg: "bg-green-50 text-green-700 border-green-200", label: "Uploaded" }, + /** Status badge for "not uploaded" files. */ + notUploadedBadge: { bg: "bg-amber-50 text-amber-700 border-amber-200", label: "Not Uploaded" }, + }, + delete: { + title: "Select Folder to Clear", + subtitle: "Free up disk space by removing local files that have already been safely uploaded. Each file is verified in the cloud before being removed.", + details: "Before removing a local file, the app checks that the uploaded file exists on the cloud server, that its size matches, and compares checksums. A checksum is a unique fingerprint computed from the file's contents — if both fingerprints match, the files are identical.", + actionableLabel: "uploaded (deletable)", + actionableColor: "green" as const, + actionableIcon: "check" as const, + otherLabel: "not uploaded", + otherColor: "gray" as const, + otherIcon: "plus" as const, + buttonLabel: (count: number) => + count > 0 ? `Clear ${count.toLocaleString()} file${count !== 1 ? "s" : ""}` : "Select Folder", + buttonColor: "bg-red-600 text-white hover:bg-red-700", + dimFolder: (_allUploaded: boolean, noneUploaded: boolean) => noneUploaded, + dimFile: (uploaded: boolean) => !uploaded, + uploadedBadge: { bg: "bg-green-50 text-green-700 border-green-200", label: "Uploaded" }, + notUploadedBadge: { bg: "bg-gray-100 text-gray-500 border-gray-200", label: "Not Uploaded" }, + }, +} as const; + +export default function FolderBrowser({ + onFolderSelected, + initialPath, + mode = "upload", +}: FolderBrowserProps) { + const cfg = modeConfig[mode]; + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [data, setData] = useState(null); + const [checkedFolders, setCheckedFolders] = useState>(new Set()); + const [checkedFiles, setCheckedFiles] = useState>(new Set()); + const [searchQuery, setSearchQuery] = useState(""); + const [selectFirstN, setSelectFirstN] = useState(""); + const [fileSortKey, setFileSortKey] = useState("filename"); + const [fileSortDir, setFileSortDir] = useState("asc"); + + const navigate = useCallback(async (path?: string) => { + setLoading(true); + setError(null); + try { + const params: Record = path ? { path } : {}; + const res = await apiGet("/api/files/browse", params); + setData(res); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to browse folder"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + navigate(initialPath); + }, [navigate, initialPath]); + + /** Re-fetch the current directory without resetting to initialPath. */ + const refresh = useCallback(() => { + if (data) { + navigate(data.current_path); + } else { + navigate(initialPath); + } + }, [data, navigate, initialPath]); + + // Reset checked state and search when data changes (new directory loaded) + useEffect(() => { + if (!data) return; + setCheckedFolders(new Set(data.folders.map((f) => f.name))); + setCheckedFiles(new Set(data.files.map((f) => f.name))); + setSearchQuery(""); + setSelectFirstN(""); + }, [data]); + + const breadcrumbItems = (data?.breadcrumbs ?? []).map((b, i, arr) => ({ + label: b.name, + onClick: i < arr.length - 1 ? () => navigate(b.path) : undefined, + })); + + const totalMcap = data?.total_mcap_count ?? 0; + const alreadyUploaded = data?.already_uploaded ?? 0; + const newFiles = totalMcap - alreadyUploaded; + const hasFiles = totalMcap > 0; + + // Mode-dependent counts: "actionable" = files the user will act on + const actionableCount = mode === "upload" ? newFiles : alreadyUploaded; + const otherCount = mode === "upload" ? alreadyUploaded : newFiles; + + const totalItems = (data?.folders.length ?? 0) + (data?.files.length ?? 0); + const checkedCount = checkedFolders.size + checkedFiles.size; + const allChecked = totalItems > 0 && checkedCount === totalItems; + + // Count actionable files among the user's current selection + const selectedActionableCount = useMemo(() => { + if (!data) return 0; + let count = 0; + // Checked folders: sum their actionable file counts + for (const folder of data.folders) { + if (checkedFolders.has(folder.name)) { + count += mode === "upload" + ? folder.mcap_count - folder.already_uploaded + : folder.already_uploaded; + } + } + // Checked loose files: count actionable ones + for (const file of data.files) { + if (checkedFiles.has(file.name)) { + const isUploaded = file.already_uploaded ?? false; + if (mode === "upload" ? !isUploaded : isUploaded) { + count += 1; + } + } + } + return count; + }, [data, checkedFolders, checkedFiles, mode]); + + const selectAll = useCallback(() => { + if (!data) return; + setCheckedFolders(new Set(data.folders.map((f) => f.name))); + setCheckedFiles(new Set(data.files.map((f) => f.name))); + }, [data]); + + const deselectAll = useCallback(() => { + setCheckedFolders(new Set()); + setCheckedFiles(new Set()); + }, []); + + const toggleFolder = useCallback((name: string) => { + setCheckedFolders((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + }, []); + + const toggleFile = useCallback((name: string) => { + setCheckedFiles((prev) => { + const next = new Set(prev); + if (next.has(name)) next.delete(name); + else next.add(name); + return next; + }); + }, []); + + // Filtered folders/files based on search query + const filteredFolders = useMemo(() => { + if (!data || !searchQuery) return data?.folders ?? []; + const q = searchQuery.toLowerCase(); + return data.folders.filter((f) => f.name.toLowerCase().includes(q)); + }, [data, searchQuery]); + + const filteredFiles = useMemo(() => { + if (!data || !searchQuery) return data?.files ?? []; + const q = searchQuery.toLowerCase(); + return data.files.filter((f) => f.name.toLowerCase().includes(q)); + }, [data, searchQuery]); + + // Sort the filtered files + const sortedFiles = useMemo(() => { + const sorted = [...filteredFiles]; + const dir = fileSortDir === "asc" ? 1 : -1; + sorted.sort((a, b) => { + switch (fileSortKey) { + case "filename": + return dir * a.name.localeCompare(b.name); + case "size": + return dir * (a.size - b.size); + case "mtime": + return dir * (a.mtime - b.mtime); + case "status": { + const sa = a.already_uploaded ? 1 : 0; + const sb = b.already_uploaded ? 1 : 0; + return dir * (sa - sb); + } + default: + return 0; + } + }); + return sorted; + }, [filteredFiles, fileSortKey, fileSortDir]); + + const handleFileSort = useCallback((key: FileSortKey) => { + if (fileSortKey === key) { + setFileSortDir((d) => (d === "asc" ? "desc" : "asc")); + } else { + setFileSortKey(key); + setFileSortDir("asc"); + } + }, [fileSortKey]); + + const isFiltering = searchQuery.length > 0; + + const selectShown = useCallback(() => { + setCheckedFolders(new Set(filteredFolders.map((f) => f.name))); + setCheckedFiles(new Set(filteredFiles.map((f) => f.name))); + }, [filteredFolders, filteredFiles]); + + const applySelectFirstN = useCallback(() => { + const n = Number.parseInt(selectFirstN, 10); + if (Number.isNaN(n) || n <= 0) return; + setCheckedFolders(new Set()); + setCheckedFiles(new Set(filteredFiles.slice(0, n).map((f) => f.name))); + setSelectFirstN(""); + }, [selectFirstN, filteredFiles]); + + // Build exclusions from unchecked items + const exclusions = useMemo((): FolderExclusions | undefined => { + if (!data) return undefined; + const uncheckedFolders = data.folders + .filter((f) => !checkedFolders.has(f.name)) + .map((f) => f.name); + const uncheckedFiles = data.files + .filter((f) => !checkedFiles.has(f.name)) + .map((f) => f.name); + if (uncheckedFolders.length === 0 && uncheckedFiles.length === 0) return undefined; + return { subfolders: uncheckedFolders, files: uncheckedFiles }; + }, [data, checkedFolders, checkedFiles]); + + const handleUploadClick = useCallback(() => { + if (!data) return; + onFolderSelected(data.current_path, exclusions); + }, [data, exclusions, onFolderSelected]); + + return ( +
+ {/* Header */} +
+

+ {cfg.title} +

+

+ {cfg.subtitle} +

+ {cfg.details && ( +
+ + How does verification work? + +

+ {cfg.details} +

+
+ )} +
+ +
+ {/* Quick links sidebar */} +
+

+ Quick Links +

+
    + {(data?.quick_links ?? []).map((link) => ( +
  • + +
  • + ))} +
+
+ + {/* Main content area */} +
+ {/* Breadcrumbs + refresh */} +
+ + +
+ + {/* Summary bar — always visible when we have data and MCAP files exist */} + {!loading && data && hasFiles && ( +
+
+ {/* Stat chips */} +
+ } + value={totalMcap} + label={totalMcap === 1 ? "MCAP file" : "MCAP files"} + color="blue" + /> + {actionableCount > 0 && ( + + : } + value={actionableCount} + label={cfg.actionableLabel} + color={cfg.actionableColor} + /> + )} + {otherCount > 0 && ( + + : } + value={otherCount} + label={cfg.otherLabel} + color={cfg.otherColor} + /> + )} + {totalMcap !== data.mcap_count && ( + + includes subfolders + + )} +
+ + {/* Action button */} + +
+
+ )} + + {/* Loading / Error */} + {loading && ( +
+ +
+ )} + + {error && ( +
+ {error} +
+ )} + + {/* Folder and file list */} + {!loading && !error && data && ( +
+ {/* Selection toolbar */} + {totalItems > 0 && ( +
+
+ setSearchQuery(e.target.value)} + placeholder="Search files and folders..." + className="border border-gray-300 rounded px-2 py-1 text-xs bg-white w-52" + /> +
+ Select: + + {isFiltering && ( + <> + | + + + )} + | + +
+
+ {data.files.length > 0 && ( +
+ Select first + setSelectFirstN(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") applySelectFirstN(); }} + placeholder="#" + className="border border-gray-300 rounded px-1.5 py-0.5 text-xs bg-white w-16 tabular-nums" + /> + + + {checkedCount} of {totalItems} selected + {isFiltering && ` (${filteredFolders.length + filteredFiles.length} shown)`} + +
+ )} +
+ )} + + {/* Folders */} + {filteredFolders.length > 0 && ( + + )} + + {/* Files */} + {sortedFiles.length > 0 && ( + + )} + + {/* Empty state */} + {filteredFolders.length === 0 && filteredFiles.length === 0 && ( +
+ {isFiltering + ? "No items match your search." + : "No folders or MCAP files found here."} +
+ )} +
+ )} + + {/* Minimal footer showing path */} + {!loading && data && ( +
+ + {data.current_path} + +
+ )} +
+
+
+ ); +} + +/* ─── Sub-components ─── */ + +/** Compact stat chip for the summary bar. */ +function StatChip({ + icon, + value, + label, + color, +}: { + icon: React.ReactNode; + value: number; + label: string; + color: "blue" | "green" | "gray"; +}) { + const colors = { + blue: "bg-blue-50 text-blue-700 border-blue-200", + green: "bg-green-50 text-green-700 border-green-200", + gray: "bg-gray-100 text-gray-600 border-gray-200", + }; + return ( + + {icon} + {value.toLocaleString()} + {label} + + ); +} + + +function FolderList({ + folders, + onNavigate, + checkedFolders, + onToggleFolder, + mode = "upload", +}: { + folders: LocalFolder[]; + onNavigate: (path: string) => void; + checkedFolders: Set; + onToggleFolder: (name: string) => void; + mode?: BrowserMode; +}) { + const cfg = modeConfig[mode]; + return ( +
+ {folders.map((folder) => { + const allUploaded = folder.mcap_count > 0 && folder.already_uploaded === folder.mcap_count; + const someUploaded = folder.already_uploaded > 0 && !allUploaded; + const noneUploaded = folder.mcap_count > 0 && folder.already_uploaded === 0; + const isDimmed = cfg.dimFolder(allUploaded, noneUploaded); + + return ( +
+ onToggleFolder(folder.name)} + className="h-3.5 w-3.5 rounded border-gray-300 text-nlr-blue focus:ring-nlr-blue cursor-pointer flex-shrink-0 mr-2" + /> + + {folder.mcap_count > 0 && ( + + {/* Upload status indicator */} + {allUploaded && ( + + + {folder.mcap_count}/{folder.mcap_count} uploaded + + )} + {someUploaded && ( + + + + {folder.already_uploaded}/{folder.mcap_count} uploaded + + + )} + {noneUploaded && ( + + {folder.mcap_count} file{folder.mcap_count !== 1 ? "s" : ""} + + )} + + )} +
+ ); + })} +
+ ); +} + +/** Simple progress bar showing upload fraction. */ +function UploadProgress({ uploaded, total }: { uploaded: number; total: number }) { + const pct = total > 0 ? (uploaded / total) * 100 : 0; + + return ( +
+
+
+ ); +} + +function FileList({ + files, + checkedFiles, + onToggleFile, + sortKey, + sortDir, + onSort, + mode = "upload", +}: { + files: LocalFile[]; + checkedFiles: Set; + onToggleFile: (name: string) => void; + sortKey: FileSortKey; + sortDir: FileSortDir; + onSort: (key: FileSortKey) => void; + mode?: BrowserMode; +}) { + const cfg = modeConfig[mode]; + return ( +
+ {/* Column headers */} +
+
+ + + + +
+ {/* File rows */} +
+ {files.map((file) => { + const uploaded = file.already_uploaded === true; + const isDimmed = cfg.dimFile(uploaded); + const badge = uploaded ? cfg.uploadedBadge : cfg.notUploadedBadge; + return ( +
+ onToggleFile(file.name)} + className="h-3.5 w-3.5 rounded border-gray-300 text-nlr-blue focus:ring-nlr-blue cursor-pointer" + /> + + {file.name} + + + {formatBytes(file.size)} + + + {formatDate(file.mtime)} + + + {uploaded ? ( + + + {badge.label} + + ) : ( + + + {badge.label} + + )} + +
+ ); + })} +
+
+ ); +} + +function FileSortHeader({ + label, + sortKey, + currentKey, + dir, + onSort, + className = "", +}: { + label: string; + sortKey: FileSortKey; + currentKey: FileSortKey; + dir: FileSortDir; + onSort: (key: FileSortKey) => void; + className?: string; +}) { + const active = sortKey === currentKey; + const arrow = active ? (dir === "asc" ? " \u2191" : " \u2193") : ""; + return ( + + ); +} diff --git a/frontend/src/components/upload/ReviewToolbar.tsx b/frontend/src/components/upload/ReviewToolbar.tsx new file mode 100644 index 0000000..dba346f --- /dev/null +++ b/frontend/src/components/upload/ReviewToolbar.tsx @@ -0,0 +1,194 @@ +/** + * Filter/search/selection toolbar for the review phase. + * + * During upload and summary phases, a simpler status filter bar is shown + * instead (handled separately in UploadPage). + */ + +import { useState } from "react"; + +import type { StatusFilter } from "../../types/upload.ts"; + +interface ReviewToolbarProps { + statusFilter: StatusFilter; + onStatusFilterChange: (f: StatusFilter) => void; + searchQuery: string; + onSearchChange: (q: string) => void; + selectedCount: number; + totalCount: number; + filteredCount: number; + showFilteredCount: boolean; + + // Selection actions + onSelectAll: () => void; + onSelectNewOnly: () => void; + onToggleAllFiltered: () => void; + onDeselectAll: () => void; + onSelectFirstN: (n: number) => void; + headerChecked: boolean; +} + +export default function ReviewToolbar({ + statusFilter, + onStatusFilterChange, + searchQuery, + onSearchChange, + selectedCount, + totalCount, + filteredCount, + showFilteredCount, + onSelectAll, + onSelectNewOnly, + onToggleAllFiltered, + onDeselectAll, + onSelectFirstN, + headerChecked, +}: ReviewToolbarProps) { + const [selectFirstNInput, setSelectFirstNInput] = useState(""); + + const handleSelectFirstN = () => { + const n = Number.parseInt(selectFirstNInput, 10); + if (Number.isNaN(n) || n <= 0) return; + onSelectFirstN(n); + setSelectFirstNInput(""); + }; + + return ( +
+ {/* Row 1: Filters + count */} +
+
+
+ + +
+ onSearchChange(e.target.value)} + placeholder="Search files..." + className="border border-gray-300 rounded px-2 py-1 text-sm bg-white w-48" + /> +
+
+ + {selectedCount.toLocaleString()} of {totalCount.toLocaleString()} selected + + {showFilteredCount && ( + ({filteredCount.toLocaleString()} shown) + )} +
+
+ + {/* Row 2: Selection actions */} +
+
+ Select: + + | + + | + + | + +
+
+ Select first + setSelectFirstNInput(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") handleSelectFirstN(); }} + placeholder="#" + className="border border-gray-300 rounded px-1.5 py-0.5 text-xs bg-white w-16 tabular-nums" + /> + +
+
+
+ ); +} + +/** + * Simplified status filter for upload/summary phases. + * Shows a dropdown to filter by upload status. + */ +export function StatusFilterBar({ + filter, + onFilterChange, + totalCount, + filteredCount, + searchQuery, + onSearchChange, +}: { + filter: StatusFilter; + onFilterChange: (f: StatusFilter) => void; + totalCount: number; + filteredCount: number; + searchQuery: string; + onSearchChange: (q: string) => void; +}) { + return ( +
+
+
+ + +
+ onSearchChange(e.target.value)} + placeholder="Search files..." + className="border border-gray-300 rounded px-2 py-1 text-sm bg-white w-48" + /> +
+ {filteredCount !== totalCount && ( + + {filteredCount.toLocaleString()} of {totalCount.toLocaleString()} shown + + )} +
+ ); +} diff --git a/frontend/src/components/upload/ScanProgressModal.tsx b/frontend/src/components/upload/ScanProgressModal.tsx new file mode 100644 index 0000000..f0073c0 --- /dev/null +++ b/frontend/src/components/upload/ScanProgressModal.tsx @@ -0,0 +1,169 @@ +/** + * Modal overlay that shows folder scan progress. + * + * Displayed when scanning a folder with many files to give users + * clear feedback about what's happening and how long it might take. + */ + +import { useEffect, useRef, useState } from "react"; +import { formatBytes } from "../../utils/format/bytes.ts"; +import ProgressBar from "../common/ProgressBar.tsx"; +import Spinner from "../common/Spinner.tsx"; +import { XIcon } from "../../utils/icons.tsx"; +import CancelScanModal from "./CancelScanModal.tsx"; + +interface ScanProgressModalProps { + isOpen: boolean; + foldersScanned: number; + foldersTotal: number; + totalFiles: number; + totalSize: number; + folderPath: string; + onCancel: () => void; +} + +export default function ScanProgressModal({ + isOpen, + foldersScanned, + foldersTotal, + totalFiles, + totalSize, + folderPath, + onCancel, +}: ScanProgressModalProps) { + const backdropRef = useRef(null); + const [showCancelConfirm, setShowCancelConfirm] = useState(false); + + // Calculate scan progress percentage + const progressPercent = foldersTotal > 0 ? Math.round((foldersScanned / foldersTotal) * 100) : 0; + + const handleCancelClick = () => { + setShowCancelConfirm(true); + }; + + const handleConfirmCancel = () => { + setShowCancelConfirm(false); + onCancel(); + }; + + // Handle Escape key - show confirmation + useEffect(() => { + if (!isOpen) return; + + function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") handleCancelClick(); + } + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [isOpen]); + + if (!isOpen) return null; + + function handleBackdropClick(e: React.MouseEvent) { + if (e.target === backdropRef.current) handleCancelClick(); + } + + return ( + <> +
+
+
+ {/* Header */} +
+
+ +
+

Scanning Folder

+

+ Searching for MCAP files and checking upload status... +

+
+
+ +
+ + {/* Folder path */} +
+

Scanning

+

+ {folderPath} +

+
+ + {/* Progress bar */} + {foldersTotal > 0 && ( +
+
+ Progress + {progressPercent}% +
+ +

+ {foldersScanned} of {foldersTotal} folders scanned +

+
+ )} + + {/* Progress stats */} +
+ + + +
+ + {/* Info message */} +
+

+ Note: Scanning large folders with thousands of files may take a few + minutes. Each file is checked against the upload cache to determine if it's already been + uploaded to S3. +

+
+ + {/* Cancel button */} +
+ +
+
+
+
+ {/* Cancel confirmation modal */} + setShowCancelConfirm(false)} + onConfirm={handleConfirmCancel} + foldersScanned={foldersScanned} + filesFound={totalFiles} + /> + + ); +} + +function StatCard({ value, label }: { value: number | string; label: string }) { + return ( +
+
+ {typeof value === "number" ? value.toLocaleString() : value} +
+
{label}
+
+ ); +} diff --git a/frontend/src/components/upload/Stepper.tsx b/frontend/src/components/upload/Stepper.tsx new file mode 100644 index 0000000..778af15 --- /dev/null +++ b/frontend/src/components/upload/Stepper.tsx @@ -0,0 +1,39 @@ +/** + * 4-step progress indicator for the upload workflow. + * Wrapper around the unified Stepper component. + */ + +import Stepper from "../common/Stepper.tsx"; +import type { UploadStep } from "../../stores/uploadStore.ts"; + +const steps = [ + { number: 1, label: "Select" }, + { number: 2, label: "Review" }, + { number: 3, label: "Upload" }, + { number: 4, label: "Complete" }, +]; + +interface UploadStepperProps { + currentStep: UploadStep; + onStepClick?: (step: UploadStep) => void; + /** When true, disables clicking back to earlier steps. */ + isUploading?: boolean; +} + +export default function UploadStepper({ + currentStep, + onStepClick, + isUploading = false, +}: UploadStepperProps) { + return ( + void) | undefined} + isOperating={isUploading} + maxClickableStep={2} + ariaLabel="Upload steps" + testIdPrefix="step" + /> + ); +} diff --git a/frontend/src/components/upload/UnifiedFileTable.tsx b/frontend/src/components/upload/UnifiedFileTable.tsx new file mode 100644 index 0000000..84ef595 --- /dev/null +++ b/frontend/src/components/upload/UnifiedFileTable.tsx @@ -0,0 +1,473 @@ +/** + * Unified file table that persists across Review / Upload / Summary phases. + * + * Fixed 7-column grid — column content adapts per phase, but layout is stable. + * Uses @tanstack/react-virtual for 20K+ file handling. + */ + +import { memo, useEffect, useRef, useState } from "react"; +import { useVirtualizer } from "@tanstack/react-virtual"; + +import { formatBytes } from "../../utils/format/bytes.ts"; +import { formatDate } from "../../utils/format/date.ts"; +import { SpinnerIcon, CheckIcon, XIcon, MinusIcon, CircleIcon, ChevronDownIcon } from "../../utils/icons.tsx"; +import type { + SortDir, + SortKey, + UnifiedFileRow, + UnifiedStatus, + UploadPhase, +} from "../../types/upload.ts"; +// ── Grid template (fixed across all phases) ── + +const GRID_COLS = "grid-cols-[42px_36px_1fr_1fr_80px_120px_1fr]"; +const ROW_HEIGHT = 40; + +// Row background colors by status (uploading + summary phases only) +const STATUS_ROW_BG: Record = { + completed: "bg-green-50/70", + failed: "bg-red-50/70", + skipped: "bg-yellow-50/50", + in_progress: "bg-blue-50/60", +}; + +// ── Props ── + +interface UnifiedFileTableProps { + phase: UploadPhase; + files: UnifiedFileRow[]; + sortKey: SortKey; + sortDir: SortDir; + onSort: (key: SortKey) => void; + isSortFrozen: boolean; + + // Selection (review phase only) + selectedPaths: Set; + onToggleFile: (path: string) => void; + onToggleAllFiltered: () => void; + headerChecked: boolean; + headerIndeterminate: boolean; +} + +export default function UnifiedFileTable({ + phase, + files, + sortKey, + sortDir, + onSort, + isSortFrozen, + selectedPaths, + onToggleFile, + onToggleAllFiltered, + headerChecked, + headerIndeterminate, +}: UnifiedFileTableProps) { + const scrollContainerRef = useRef(null); + + // eslint-disable-next-line react-hooks/incompatible-library + const rowVirtualizer = useVirtualizer({ + count: files.length, + getScrollElement: () => scrollContainerRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 20, + }); + + // Reset scroll when files change significantly (e.g., filter change) + const prevCountRef = useRef(files.length); + useEffect(() => { + if (Math.abs(files.length - prevCountRef.current) > 100) { + rowVirtualizer.scrollToIndex(0); + } + prevCountRef.current = files.length; + }, [files.length, rowVirtualizer]); + + // ── Auto-scroll: follow the active file during upload ── + + const [autoFollow, setAutoFollow] = useState(true); + const lastActivePathRef = useRef(null); + + // Reset auto-follow when entering upload phase + const prevPhaseRef = useRef(phase); + if (phase === "uploading" && prevPhaseRef.current !== "uploading") { + setAutoFollow(true); + lastActivePathRef.current = null; + } + prevPhaseRef.current = phase; + + useEffect(() => { + if (phase !== "uploading" || !autoFollow) return; + + const activeFile = files.find((f) => f.status === "in_progress"); + if (!activeFile || activeFile.path === lastActivePathRef.current) return; + + lastActivePathRef.current = activeFile.path; + const idx = files.indexOf(activeFile); + if (idx >= 0) { + rowVirtualizer.scrollToIndex(idx, { align: "center" }); + } + }, [phase, autoFollow, files, rowVirtualizer]); + + return ( +
+ {/* Header */} +
+ {/* Col 1: Row number */} +
#
+ + {/* Col 2: Checkbox / Status icon */} +
+ {phase === "review" ? ( + + ) : ( + + )} +
+ + {/* Col 3: Filename */} + + + {/* Col 4: Folder */} + + + {/* Col 5: Size */} + + + {/* Col 6: Status */} + + + {/* Col 7: Detail (context-dependent) */} +
+ {phase === "review" && "Modified"} + {phase === "uploading" && ""} + {phase === "summary" && "S3 Path"} +
+
+ + {/* Virtualized rows */} + {files.length === 0 ? ( +
+ No files match the current filter. +
+ ) : ( +
+
+ {rowVirtualizer.getVirtualItems().map((virtualRow) => { + const file = files[virtualRow.index]!; + return ( + + ); + })} +
+
+ )} + + {/* Footer */} +
+ {files.length.toLocaleString()} file{files.length !== 1 ? "s" : ""} +
+ + {/* Auto-scroll toggle (upload phase only) */} + {phase === "uploading" && ( + + )} +
+ ); +} + +// ── Row component (memoized — new object ref on mutation triggers re-render) ── + +interface FileRowProps { + row: UnifiedFileRow; + rowNumber: number; + phase: UploadPhase; + isSelected: boolean; + onToggle: (path: string) => void; + style: React.CSSProperties; +} + +const FileRow = memo(function FileRow({ + row, + rowNumber, + phase, + isSelected, + onToggle, + style, +}: FileRowProps) { + const statusBg = phase !== "review" ? (STATUS_ROW_BG[row.status] ?? "") : ""; + + return ( +
+ {/* Col 1: Row number */} + + {rowNumber} + + + {/* Col 2: Checkbox / Status icon */} +
+ {phase === "review" ? ( + onToggle(row.path)} + className="h-3.5 w-3.5 rounded border-gray-300 text-nlr-blue focus:ring-nlr-blue cursor-pointer" + /> + ) : ( + + )} +
+ + {/* Col 3: Filename */} + + {row.filename} + + + {/* Col 4: Folder */} + + {row.folder} + + + {/* Col 5: Size */} + + {formatBytes(row.size)} + + + {/* Col 6: Status */} +
+ +
+ + {/* Col 7: Detail */} +
+ {phase === "review" && formatDate(row.mtime)} + {phase === "uploading" && row.status === "in_progress" && ( + + )} + {phase === "summary" && ( + {row.s3Path} + )} +
+
+ ); +}); + +// ── Sub-components ── + +function StatusIcon({ status }: { status: UnifiedStatus }) { + switch (status) { + case "in_progress": + return ; + case "completed": + return ; + case "failed": + return ; + case "skipped": + return ; + default: + // queued, new, already_uploaded + return ; + } +} + +function StatusBadge({ + status, + progressPercent, + phase, +}: { + status: UnifiedStatus; + progressPercent: number; + phase: UploadPhase; +}) { + // Review phase: show "new" or "uploaded" based on status + if (phase === "review") { + if (status === "already_uploaded") { + return ( + + uploaded + + ); + } + return ( + + new + + ); + } + + // Upload / Summary phase + const styles: Record = { + queued: "bg-gray-100 text-gray-500", + in_progress: "bg-blue-100 text-nlr-blue", + completed: "bg-green-100 text-green-700", + skipped: "bg-yellow-100 text-yellow-700", + failed: "bg-red-100 text-red-700", + }; + + const labels: Record = { + queued: "queued", + in_progress: progressPercent > 0 ? `${Math.round(progressPercent)}%` : "analyzing", + completed: "uploaded", + skipped: "skipped", + failed: "failed", + }; + + return ( + + {labels[status] ?? status} + + ); +} + +function MiniProgressBar({ percent }: { percent: number }) { + return ( +
+
+
+ ); +} + +// ── Sortable column header ── + +function SortHeader({ + label, + sortKey, + currentKey, + dir, + onSort, + disabled = false, + className = "", +}: { + label: string; + sortKey: SortKey; + currentKey: SortKey; + dir: SortDir; + onSort: (key: SortKey) => void; + disabled?: boolean; + className?: string; +}) { + const active = sortKey === currentKey; + const arrow = active ? (dir === "asc" ? " \u2191" : " \u2193") : ""; + return ( + + ); +} + +// ── Indeterminate checkbox ── + +function IndeterminateCheckbox({ + checked, + indeterminate, + onChange, +}: { + checked: boolean; + indeterminate: boolean; + onChange: () => void; +}) { + const ref = useRef(null); + useEffect(() => { + if (ref.current) { + ref.current.indeterminate = indeterminate; + } + }, [indeterminate]); + return ( + + ); +} + diff --git a/frontend/src/components/upload/UploadFooter.tsx b/frontend/src/components/upload/UploadFooter.tsx new file mode 100644 index 0000000..fe60a94 --- /dev/null +++ b/frontend/src/components/upload/UploadFooter.tsx @@ -0,0 +1,111 @@ +/** + * Phase-aware footer with action buttons below the unified file table. + * + * - Review: Back + Start Upload + * - Upload: Cancel Upload + * - Summary: info blurb + Download CSV + Upload More + */ + +import { Link } from "react-router-dom"; +import type { UploadPhase } from "../../types/upload.ts"; +import { ChevronRightIcon, XCircleIcon, DownloadIcon, UploadIcon } from "../../utils/icons.tsx"; + +interface UploadFooterProps { + phase: UploadPhase; + + // Review + onBack?: () => void; + onStartUpload?: () => void; + selectedNewCount?: number; + + // Upload + onCancel?: () => void; + isRunning?: boolean; + + // Summary + onDownloadCsv?: () => void; + onUploadMore?: () => void; + failedCount?: number; +} + +export default function UploadFooter({ + phase, + onBack, + onStartUpload, + selectedNewCount = 0, + onCancel, + isRunning = false, + onDownloadCsv, + onUploadMore, +}: UploadFooterProps) { + if (phase === "review") { + return ( +
+ + +
+ ); + } + + if (phase === "uploading") { + return ( +
+ {isRunning && ( + + )} +
+ ); + } + + // Summary + return ( +
+

+ These results have been saved and can be reviewed anytime on the{" "} + History page. +

+
+ + +
+
+ ); +} diff --git a/frontend/src/components/upload/UploadHeader.tsx b/frontend/src/components/upload/UploadHeader.tsx new file mode 100644 index 0000000..79575c6 --- /dev/null +++ b/frontend/src/components/upload/UploadHeader.tsx @@ -0,0 +1,362 @@ +/** + * Phase-aware header that sits above the unified file table. + * + * - Review: stat cards + scanning indicator + * - Upload: progress bar + counters + ETA + * - Summary: summary stat cards + job status headline + * + * Uses crossfade transitions between phases. + */ + +import type { UploadJob } from "../../types/api.ts"; +import type { StatusFilter, UploadPhase } from "../../types/upload.ts"; +import { formatBytes } from "../../utils/format/bytes.ts"; +import { formatSpeed } from "../../utils/format/speed.ts"; +import { formatDuration, formatEta } from "../../utils/format/time.ts"; +import ProgressBar from "../common/ProgressBar.tsx"; +import Spinner from "../common/Spinner.tsx"; +import StatCard from "../common/StatCard.tsx"; +import { WarningIcon } from "../../utils/icons.tsx"; + +interface UploadHeaderProps { + phase: UploadPhase; + + // Review data + totals: { + toUpload: number; + uploadSize: number; + alreadyOnS3: number; + totalInFolder: number; + }; + isScanning: boolean; + foldersFound: number; + + // Upload data + progressPercent: number; + filesProcessed: number; + totalFiles: number; + statusCounts: { uploaded: number; skipped: number; failed: number }; + eta: number | null; + isRunning: boolean; + uploadedBytesFormatted: string; + totalBytesFormatted: string; + + // Summary data + job: UploadJob | null; + + // Clickable status counters + onFilterClick?: (filter: StatusFilter) => void; +} + +export default function UploadHeader({ + phase, + totals, + isScanning, + foldersFound, + progressPercent, + filesProcessed, + totalFiles, + statusCounts, + eta, + isRunning, + uploadedBytesFormatted, + totalBytesFormatted, + job, + onFilterClick, +}: UploadHeaderProps) { + if (phase === "review") { + return ( + + ); + } + + if (phase === "uploading") { + return ( + + ); + } + + return ; +} + +// ── Review phase ── + +function ReviewHeader({ + totals, + isScanning, + foldersFound, +}: { + totals: { + toUpload: number; + uploadSize: number; + alreadyOnS3: number; + totalInFolder: number; + }; + isScanning: boolean; + foldersFound: number; +}) { + return ( +
+
+ + + + +
+ + {isScanning && ( +
+ + + Scanning folders... ({foldersFound} folder + {foldersFound !== 1 ? "s" : ""} found so far) + +
+ )} +
+ ); +} + +// ── Upload phase ── + +function UploadingHeader({ + progressPercent, + filesProcessed, + totalFiles, + statusCounts, + eta, + isRunning, + uploadedBytesFormatted, + totalBytesFormatted, + onFilterClick, +}: { + progressPercent: number; + filesProcessed: number; + totalFiles: number; + statusCounts: { uploaded: number; skipped: number; failed: number }; + eta: number | null; + isRunning: boolean; + uploadedBytesFormatted: string; + totalBytesFormatted: string; + onFilterClick?: (filter: StatusFilter) => void; +}) { + return ( +
+ {/* Progress bar card */} +
+
+

+ {isRunning && filesProcessed < totalFiles + ? "Uploading..." + : "Upload Complete"} +

+ {isRunning && filesProcessed < totalFiles && } +
+ + + +
+ + {uploadedBytesFormatted || "0 B"} / {totalBytesFormatted || "0 B"} + + ETA: {formatEta(eta)} +
+
+ + {/* Clickable status counters */} +
+ onFilterClick?.("completed")} + /> + onFilterClick?.("skipped")} + /> + onFilterClick?.("failed")} + /> +
+
+ ); +} + +// ── Summary phase ── + +function SummaryHeader({ + job, + onFilterClick, +}: { + job: UploadJob | null; + onFilterClick?: (filter: StatusFilter) => void; +}) { + if (!job) return null; + + const headlineColor = + job.files_failed > 0 + ? "text-yellow-600" + : job.cancelled + ? "text-gray-600" + : "text-green-600"; + + const headlineText = job.cancelled + ? "Upload Cancelled" + : job.files_failed > 0 && job.files_uploaded > 0 + ? "Upload Partial Success" + : job.files_failed > 0 + ? "Upload Failed" + : "Upload Complete"; + + return ( +
+
+

{headlineText}

+
+ +
+ + + + + + +
+ + {job.files_failed > 0 && ( +
+ + + {job.files_failed} file + {job.files_failed !== 1 ? "s" : ""} failed to upload. + + +
+ )} +
+ ); +} + +// ── Clickable status counter ── + +function StatusCounterButton({ + count, + label, + color, + bgColor, + borderColor, + onClick, +}: { + count: number; + label: string; + color: string; + bgColor: string; + borderColor: string; + onClick: () => void; +}) { + return ( + + ); +} diff --git a/frontend/src/hooks/useDebounce.ts b/frontend/src/hooks/useDebounce.ts new file mode 100644 index 0000000..2f1985f --- /dev/null +++ b/frontend/src/hooks/useDebounce.ts @@ -0,0 +1,12 @@ +import { useEffect, useState } from "react"; + +export function useDebounce(value: T, delay = 300): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + const timer = setTimeout(() => setDebounced(value), delay); + return () => clearTimeout(timer); + }, [value, delay]); + + return debounced; +} diff --git a/frontend/src/hooks/useDeleteJob.ts b/frontend/src/hooks/useDeleteJob.ts new file mode 100644 index 0000000..49e4a80 --- /dev/null +++ b/frontend/src/hooks/useDeleteJob.ts @@ -0,0 +1,188 @@ +/** + * Hook for managing delete job execution and SSE progress. + * + * Mirrors the useUploadJob pattern: starts a delete job via POST, + * then listens on SSE for per-file progress updates. + */ + +import { useCallback, useMemo, useState } from "react"; + +import { apiPost } from "../api/client.ts"; +import { useDeleteStore } from "../stores/deleteStore.ts"; +import type { + DeleteCompleteEvent, + DeleteJobResult, + DeleteProgressEvent, +} from "../types/delete.ts"; +import { useSSE } from "./useSSE.ts"; + +interface StatusCounts { + deleted: number; + mismatch: number; + failed: number; + verified: number; + verifying: number; +} + +interface UseDeleteJobResult { + startDelete: () => Promise; + cancelDelete: () => Promise; + filesProcessed: number; + totalFiles: number; + statusCounts: StatusCounts; + totalDeletedSize: number; + isRunning: boolean; + isCancelling: boolean; + jobStatus: string; +} + +/** Type guard: is this a progress event? */ +function isProgressEvent( + data: Record, +): data is DeleteProgressEvent & Record { + return data.type === "delete_progress"; +} + +/** Type guard: is this a completion event? */ +function isCompleteEvent( + data: Record, +): data is DeleteCompleteEvent & Record { + return data.type === "delete_complete"; +} + +function extractCounts(counts: Record): StatusCounts { + return { + deleted: counts.deleted ?? 0, + mismatch: counts.mismatch ?? 0, + failed: counts.failed ?? 0, + verified: counts.verified ?? 0, + verifying: counts.verifying ?? 0, + }; +} + +export function useDeleteJob(): UseDeleteJobResult { + const [isRunning, setIsRunning] = useState(false); + const [filesProcessed, setFilesProcessed] = useState(0); + const [totalFiles, setTotalFiles] = useState(0); + const [statusCounts, setStatusCounts] = useState({ + deleted: 0, + mismatch: 0, + failed: 0, + verified: 0, + verifying: 0, + }); + const [totalDeletedSize, setTotalDeletedSize] = useState(0); + const [jobStatus, setJobStatus] = useState("pending"); + const [isCancelling, setIsCancelling] = useState(false); + const [activeJobId, setActiveJobId] = useState(null); + + const { deleteJobId, setCompletedJob, setIsDeleting } = useDeleteStore(); + + const handleMessage = useCallback( + (raw: unknown) => { + const data = raw as Record; + if (!data || typeof data !== "object") return; + + if (isProgressEvent(data)) { + const p = data as DeleteProgressEvent; + setFilesProcessed(p.files_processed); + setTotalFiles(p.total_files); + setStatusCounts(extractCounts(p.status_counts)); + setTotalDeletedSize(p.total_deleted_size); + setJobStatus(p.status); + return; + } + + if (isCompleteEvent(data)) { + const c = data as DeleteCompleteEvent; + setFilesProcessed(c.total_files); + setTotalFiles(c.total_files); + setStatusCounts(extractCounts(c.status_counts)); + setTotalDeletedSize(c.total_deleted_size); + setJobStatus(c.status); + setIsRunning(false); + setIsCancelling(false); + setIsDeleting(false); + setActiveJobId(null); + setCompletedJob(c as unknown as DeleteJobResult); + return; + } + }, + [setCompletedJob, setIsDeleting], + ); + + const sseUrl = useMemo( + () => + activeJobId && isRunning + ? `/api/delete/progress/${activeJobId}` + : null, + [activeJobId, isRunning], + ); + + useSSE({ + url: sseUrl, + onMessage: handleMessage, + onError: () => { + if (isRunning) { + setIsRunning(false); + setIsCancelling(false); + setIsDeleting(false); + setActiveJobId(null); + } + }, + }); + + const startDelete = useCallback(async () => { + if (!deleteJobId) return; + + setFilesProcessed(0); + setStatusCounts({ + deleted: 0, + mismatch: 0, + failed: 0, + verified: 0, + verifying: 0, + }); + setTotalDeletedSize(0); + setJobStatus("verifying"); + setIsCancelling(false); + setIsRunning(true); + setIsDeleting(true); + + try { + await apiPost(`/api/delete/start/${deleteJobId}`); + setActiveJobId(deleteJobId); + } catch { + setIsRunning(false); + setIsDeleting(false); + } + }, [deleteJobId, setIsDeleting]); + + /** Cancel the delete job. SSE terminal event handles cleanup. */ + const cancelDelete = useCallback(async () => { + const jobId = useDeleteStore.getState().deleteJobId; + if (!jobId) return; + setIsCancelling(true); + try { + await apiPost(`/api/delete/cancel/${jobId}`); + } catch { + // If cancel request fails, force-close + setIsRunning(false); + setIsCancelling(false); + setIsDeleting(false); + setActiveJobId(null); + } + }, [setIsDeleting]); + + return { + startDelete, + cancelDelete, + filesProcessed, + totalFiles, + statusCounts, + totalDeletedSize, + isRunning, + isCancelling, + jobStatus, + }; +} diff --git a/frontend/src/hooks/useDeleteScan.ts b/frontend/src/hooks/useDeleteScan.ts new file mode 100644 index 0000000..c9e8435 --- /dev/null +++ b/frontend/src/hooks/useDeleteScan.ts @@ -0,0 +1,48 @@ +/** + * Hook for scanning a folder for deletable MCAP files. + * + * Calls POST /api/delete/scan and returns scanned files + * that match entries in the upload cache. + */ + +import { useCallback } from "react"; + +import { apiPost } from "../api/client.ts"; +import { useDeleteStore } from "../stores/deleteStore.ts"; +import type { DeleteScanResponse } from "../types/delete.ts"; + +interface ScanExclusions { + subfolders: string[]; + files: string[]; +} + +interface UseDeleteScanResult { + scan: (folderPath: string, exclusions?: ScanExclusions) => Promise; + isScanning: boolean; +} + +export function useDeleteScan(): UseDeleteScanResult { + const { isScanning, setIsScanning, setScanResults, setDeleteJobId } = + useDeleteStore(); + + const scan = useCallback( + async (folderPath: string, exclusions?: ScanExclusions) => { + setIsScanning(true); + try { + const body: Record = { folder_path: folderPath }; + if (exclusions) { + body.excluded_subfolders = exclusions.subfolders; + body.excluded_files = exclusions.files; + } + const res = await apiPost("/api/delete/scan", body); + setDeleteJobId(res.job_id); + setScanResults(res.files, res.total_size, res.permission_warning); + } finally { + setIsScanning(false); + } + }, + [setIsScanning, setScanResults, setDeleteJobId], + ); + + return { scan, isScanning }; +} diff --git a/frontend/src/hooks/useFileStore.ts b/frontend/src/hooks/useFileStore.ts new file mode 100644 index 0000000..1cdbc18 --- /dev/null +++ b/frontend/src/hooks/useFileStore.ts @@ -0,0 +1,19 @@ +/** + * Thin hook wrapper around the FileStore singleton. + * + * Uses useSyncExternalStore so React re-renders only when the + * store's snapshot reference changes (rAF-batched). + */ + +import { useSyncExternalStore } from "react"; + +import { fileStore } from "../stores/fileStore.ts"; +import type { UnifiedFileRow } from "../types/upload.ts"; + +export function useFileStore(): { + files: UnifiedFileRow[]; + store: typeof fileStore; +} { + const files = useSyncExternalStore(fileStore.subscribe, fileStore.getSnapshot); + return { files, store: fileStore }; +} diff --git a/frontend/src/hooks/useFolderScan.ts b/frontend/src/hooks/useFolderScan.ts new file mode 100644 index 0000000..c54e413 --- /dev/null +++ b/frontend/src/hooks/useFolderScan.ts @@ -0,0 +1,161 @@ +/** + * Hook that wraps the scan-folder-async API + SSE progress stream. + * + * Starts a scan via POST, then listens on the SSE progress endpoint. + * Updates uploadStore with scan results as they arrive. + */ + +import { useCallback, useMemo, useState } from "react"; + +import { apiPost } from "../api/client.ts"; +import { useUploadStore } from "../stores/uploadStore.ts"; +import type { + ScannedFolder, + ScanEvent, + ScanStartedEvent, + ScanFolderCompleteEvent, + ScanCompleteEvent, +} from "../types/api.ts"; +import { useSSE } from "./useSSE.ts"; + +interface ScanTotals { + totalFiles: number; + alreadyUploaded: number; + totalSize: number; +} + +interface ScanExclusions { + subfolders: string[]; + files: string[]; +} + +interface UseFolderScanResult { + startScan: (folderPath: string, cacheOnly?: boolean, exclusions?: ScanExclusions) => Promise; + cancelScan: () => Promise; + folders: ScannedFolder[]; + foldersTotal: number; + isScanning: boolean; + scanComplete: boolean; + totals: ScanTotals; +} + +export function useFolderScan(): UseFolderScanResult { + const [jobId, setJobId] = useState(null); + + const { + scanFolders: folders, + isScanning, + scanComplete, + scanFoldersTotal: foldersTotal, + scanTotals: totals, + setScanJobId, + addScanFolder, + setScanComplete, + setIsScanning, + setScanFoldersTotal, + updateScanTotals, + } = useUploadStore(); + + /** Handle each SSE event from the scan stream. */ + const handleMessage = useCallback( + (raw: unknown) => { + const data = raw as ScanEvent & Record; + if (!data || typeof data !== "object") return; + + switch (data.type) { + case "scan_started": { + const evt = data as ScanStartedEvent; + setScanFoldersTotal(evt.folders_total); + break; + } + + case "scan_folder_complete": { + const evt = data as ScanFolderCompleteEvent; + addScanFolder(evt.folder); + updateScanTotals({ + totalFiles: evt.running_totals.total_files_found, + alreadyUploaded: evt.running_totals.total_already_uploaded, + totalSize: evt.running_totals.total_size, + }); + break; + } + + case "scan_complete": { + const evt = data as ScanCompleteEvent; + updateScanTotals({ + totalFiles: evt.total_files_found, + alreadyUploaded: evt.total_already_uploaded, + totalSize: evt.total_size, + }); + setScanComplete(true); + setIsScanning(false); + setJobId(null); + break; + } + } + }, + [addScanFolder, updateScanTotals, setScanComplete, setIsScanning, setScanFoldersTotal], + ); + + // Only connect when we have a jobId and are still scanning. + const sseUrl = useMemo( + () => (jobId && isScanning ? `/api/upload/progress/${jobId}` : null), + [jobId, isScanning], + ); + + useSSE({ + url: sseUrl, + onMessage: handleMessage, + onError: () => { + // Stream ended or errored — mark complete if not already. + if (isScanning) { + setScanComplete(true); + setIsScanning(false); + setJobId(null); + } + }, + }); + + /** Kick off a new folder scan. */ + const startScan = useCallback( + async (folderPath: string, cacheOnly = false, exclusions?: ScanExclusions) => { + setIsScanning(true); + setScanComplete(false); + + try { + const body: Record = { + folder_path: folderPath, + cache_only: cacheOnly, + }; + if (exclusions) { + body.excluded_subfolders = exclusions.subfolders; + body.excluded_files = exclusions.files; + } + const res = await apiPost<{ job_id: string }>("/api/upload/scan-folder-async", body); + setScanJobId(res.job_id); + setJobId(res.job_id); + } catch { + setIsScanning(false); + setScanComplete(false); + } + }, + [setScanJobId, setIsScanning, setScanComplete], + ); + + /** Cancel an in-progress scan. */ + const cancelScan = useCallback(async () => { + const currentJobId = useUploadStore.getState().scanJobId; + if (currentJobId) { + try { + await apiPost(`/api/upload/cancel/${currentJobId}`); + } catch { + // Ignore — may already be done. + } + } + setIsScanning(false); + setScanComplete(false); + setJobId(null); + }, [setIsScanning, setScanComplete]); + + return { startScan, cancelScan, folders, foldersTotal, isScanning, scanComplete, totals }; +} diff --git a/frontend/src/hooks/usePagination.ts b/frontend/src/hooks/usePagination.ts new file mode 100644 index 0000000..5997bd6 --- /dev/null +++ b/frontend/src/hooks/usePagination.ts @@ -0,0 +1,66 @@ +import { useCallback, useMemo, useState } from "react"; + +export interface UsePaginationResult { + offset: number; + limit: number; + currentPage: number; + totalPages: number; + setTotal: (total: number) => void; + goToPage: (page: number) => void; + nextPage: () => void; + prevPage: () => void; + reset: () => void; +} + +export function usePagination(pageSize = 100): UsePaginationResult { + const [currentPage, setCurrentPage] = useState(1); + const [total, setTotalRaw] = useState(0); + + const totalPages = useMemo( + () => Math.max(1, Math.ceil(total / pageSize)), + [total, pageSize], + ); + + const offset = (currentPage - 1) * pageSize; + + const setTotal = useCallback( + (newTotal: number) => { + setTotalRaw(newTotal); + // Clamp current page if total shrinks + const newTotalPages = Math.max(1, Math.ceil(newTotal / pageSize)); + setCurrentPage((prev) => Math.min(prev, newTotalPages)); + }, + [pageSize], + ); + + const goToPage = useCallback( + (page: number) => { + setCurrentPage(Math.max(1, Math.min(page, totalPages))); + }, + [totalPages], + ); + + const nextPage = useCallback(() => { + setCurrentPage((prev) => Math.min(prev + 1, totalPages)); + }, [totalPages]); + + const prevPage = useCallback(() => { + setCurrentPage((prev) => Math.max(prev - 1, 1)); + }, []); + + const reset = useCallback(() => { + setCurrentPage(1); + }, []); + + return { + offset, + limit: pageSize, + currentPage, + totalPages, + setTotal, + goToPage, + nextPage, + prevPage, + reset, + }; +} diff --git a/frontend/src/hooks/useSSE.ts b/frontend/src/hooks/useSSE.ts new file mode 100644 index 0000000..b28039a --- /dev/null +++ b/frontend/src/hooks/useSSE.ts @@ -0,0 +1,84 @@ +/** + * Generic hook for consuming Server-Sent Events (SSE) from a finite stream. + * + * Creates an EventSource when `url` is non-null; closes on cleanup or when + * the stream ends. No auto-reconnect — our SSE streams are finite. + * + * Features: + * - Automatic timeout detection (60s without messages triggers error) + * - Heartbeat support (server sends ": heartbeat" comments to keep alive) + * - Clean error handling and connection cleanup + */ + +import { useEffect, useRef } from "react"; + +export interface UseSSEOptions { + /** URL to connect to. Pass `null` to stay disconnected. */ + url: string | null; + /** Called for every `data:` line (already JSON-parsed). */ + onMessage: (data: unknown) => void; + /** Called on EventSource errors or timeout. */ + onError?: (error: Event) => void; + /** Timeout in milliseconds (default: 60000 = 60s). Set to 0 to disable. */ + timeout?: number; +} + +export function useSSE({ url, onMessage, onError, timeout = 60000 }: UseSSEOptions): void { + // Store latest callbacks in refs so we never re-open a connection just + // because the caller created a new closure. + const onMessageRef = useRef(onMessage); + const onErrorRef = useRef(onError); + + useEffect(() => { + onMessageRef.current = onMessage; + onErrorRef.current = onError; + }); + + useEffect(() => { + if (!url) return; + + const es = new EventSource(url); + let timeoutId: ReturnType | null = null; + + // Start timeout timer if enabled + const resetTimeout = () => { + if (timeout > 0) { + if (timeoutId) clearTimeout(timeoutId); + timeoutId = setTimeout(() => { + // No messages received for timeout duration — connection likely dead + const timeoutError = new Event('timeout'); + onErrorRef.current?.(timeoutError); + es.close(); + }, timeout); + } + }; + + resetTimeout(); // Initial timeout + + es.onmessage = (event: MessageEvent) => { + // Reset timeout on any message (including heartbeats) + resetTimeout(); + + try { + const data: unknown = JSON.parse(event.data as string); + onMessageRef.current(data); + } catch { + // Ignore non-JSON messages (e.g., ": heartbeat" comment lines) + // These still reset the timeout above, which is their purpose + } + }; + + es.onerror = (event: Event) => { + if (timeoutId) clearTimeout(timeoutId); + onErrorRef.current?.(event); + // The server closes the stream on terminal events, which fires an error + // event with readyState CLOSED. We just close our side too. + es.close(); + }; + + return () => { + if (timeoutId) clearTimeout(timeoutId); + es.close(); + }; + }, [url, timeout]); +} diff --git a/frontend/src/hooks/useSorting.ts b/frontend/src/hooks/useSorting.ts new file mode 100644 index 0000000..35d60f0 --- /dev/null +++ b/frontend/src/hooks/useSorting.ts @@ -0,0 +1,48 @@ +import { useCallback, useMemo, useState } from "react"; + +export interface UseSortingResult { + sortColumn: T; + ascending: boolean; + toggleSort: (column: T) => void; + sortFn: >(a: R, b: R) => number; +} + +export function useSorting( + defaultColumn: T, + defaultAscending = true, +): UseSortingResult { + const [sortColumn, setSortColumn] = useState(defaultColumn); + const [ascending, setAscending] = useState(defaultAscending); + + const toggleSort = useCallback( + (column: T) => { + if (column === sortColumn) { + setAscending((prev) => !prev); + } else { + setSortColumn(column); + setAscending(true); + } + }, + [sortColumn], + ); + + const sortFn = useMemo(() => { + return >(a: R, b: R): number => { + const aVal = a[sortColumn]; + const bVal = b[sortColumn]; + + let comparison = 0; + if (typeof aVal === "string" && typeof bVal === "string") { + comparison = aVal.localeCompare(bVal); + } else if (typeof aVal === "number" && typeof bVal === "number") { + comparison = aVal - bVal; + } else { + comparison = String(aVal ?? "").localeCompare(String(bVal ?? "")); + } + + return ascending ? comparison : -comparison; + }; + }, [sortColumn, ascending]); + + return { sortColumn, ascending, toggleSort, sortFn }; +} diff --git a/frontend/src/hooks/useUploadJob.ts b/frontend/src/hooks/useUploadJob.ts new file mode 100644 index 0000000..7a1f667 --- /dev/null +++ b/frontend/src/hooks/useUploadJob.ts @@ -0,0 +1,401 @@ +/** + * Hook that wraps bulk-analyze + auto-upload SSE progress. + * + * The SSE stream has two phases (analysis then upload) but this hook + * exposes a single, simple interface. During upload only "active" files + * (status=uploading|analyzing) are tracked — the full file list arrives + * only in the terminal event and is stored in uploadStore.completedJob. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { apiPost } from "../api/client.ts"; +import { useUploadStore } from "../stores/uploadStore.ts"; +import type { + AnalysisCompleteEvent, + AnalysisProgressEvent, + AutoUploadStartingEvent, + BatchCompletedEvent, + BatchProgressEvent, + BatchStartedEvent, + FileUploadState, + JobCompletedEvent, + UploadJob, + UploadJobProgress, +} from "../types/api.ts"; +import { useSSE } from "./useSSE.ts"; + +interface StatusCounts { + uploaded: number; + skipped: number; + failed: number; +} + +interface UseUploadJobOptions { + /** Called for each file update during analysis/upload. */ + onFileUpdate?: (file: FileUploadState) => void; + /** Called with the full file list on completion. */ + onCompletion?: (files: FileUploadState[]) => void; +} + +interface UseUploadJobResult { + startUpload: ( + filePaths: string[], + skipDuplicates?: boolean, + ) => Promise; + cancelUpload: () => Promise; + filesProcessed: number; + totalFiles: number; + activeFiles: FileUploadState[]; + statusCounts: StatusCounts; + progressPercent: number; + eta: number | null; + isRunning: boolean; + isCancelling: boolean; + uploadedBytesFormatted: string; + totalBytesFormatted: string; +} + +type SSEEvent = + | AnalysisProgressEvent + | AnalysisCompleteEvent + | AutoUploadStartingEvent + | BatchStartedEvent + | BatchProgressEvent + | BatchCompletedEvent + | JobCompletedEvent + | UploadJobProgress + | UploadJob; + +/** Type guard: does this look like a progress dict (lightweight, during upload)? */ +function isProgressDict(data: Record): data is UploadJobProgress & Record { + return "job_id" in data && "total_files" in data && !("total_bytes" in data) && !("type" in data); +} + +/** Type guard: does this look like a full UploadJob dict (terminal)? */ +function isFullJobDict(data: Record): data is UploadJob & Record { + return "job_id" in data && "total_bytes" in data && !("type" in data); +} + +export function useUploadJob(options: UseUploadJobOptions = {}): UseUploadJobResult { + const [jobId, setJobId] = useState(null); + const [isRunning, setIsRunning] = useState(false); + const [filesProcessed, setFilesProcessed] = useState(0); + const [totalFiles, setTotalFiles] = useState(0); + const [activeFiles, setActiveFiles] = useState([]); + const [statusCounts, setStatusCounts] = useState({ + uploaded: 0, + skipped: 0, + failed: 0, + }); + const [progressPercent, setProgressPercent] = useState(0); + const [eta, setEta] = useState(null); + const [uploadedBytesFormatted, setUploadedBytesFormatted] = useState(""); + const [totalBytesFormatted, setTotalBytesFormatted] = useState(""); + const [isCancelling, setIsCancelling] = useState(false); + + const { + setUploadJobId, + setCompletedJob, + setCurrentBatch, + setTotalBatches, + setBatchState, + setIsBatchProcessing, + batchState, + } = useUploadStore(); + + // Callback refs — kept in sync with latest options via effect + const onFileUpdateRef = useRef(options.onFileUpdate); + const onCompletionRef = useRef(options.onCompletion); + + useEffect(() => { + onFileUpdateRef.current = options.onFileUpdate; + onCompletionRef.current = options.onCompletion; + }); + + /** Handle each SSE event. */ + const handleMessage = useCallback( + (raw: unknown) => { + const data = raw as SSEEvent & Record; + if (!data || typeof data !== "object") return; + + // Typed events (analysis phase) + if ("type" in data) { + switch (data.type) { + case "analysis_progress": { + const evt = data as AnalysisProgressEvent; + setTotalFiles(evt.total_files); + // Show the file being analyzed as an active file. + setActiveFiles((prev) => { + const filtered = prev.filter( + (f) => f.local_path !== evt.file.local_path, + ); + if ( + evt.file.status === "analyzing" || + evt.file.status === "uploading" + ) { + return [...filtered, evt.file]; + } + return filtered; + }); + // Notify unified table + onFileUpdateRef.current?.(evt.file); + break; + } + + case "analysis_complete": { + const evt = data as AnalysisCompleteEvent; + setTotalFiles(evt.job.total_files); + if (!evt.auto_upload) { + // Analysis-only mode — this is terminal. + setIsRunning(false); + setJobId(null); + setCompletedJob(evt.job); + setActiveFiles([]); + } + // When auto_upload is true, don't clear activeFiles — uploads + // may already be in progress via the pipeline. + break; + } + + case "auto_upload_starting": + // Upload phase beginning — keep running, active files will + // arrive via progress dicts. + break; + + case "batch_started": { + const evt = data as BatchStartedEvent; + setCurrentBatch(evt.batch_id); + setTotalBatches(evt.total_batches); + setIsBatchProcessing(true); + setBatchState({ + batch_id: evt.batch_id, + total_batches: evt.total_batches, + files_in_batch: evt.files_in_batch, + status: "processing", + files_processed: 0, + files_uploaded: 0, + files_failed: 0, + bytes_uploaded: 0, + started_at: new Date().toISOString(), + completed_at: null, + duration_seconds: null, + error_message: "", + }); + break; + } + + case "batch_progress": { + const evt = data as BatchProgressEvent; + setCurrentBatch(evt.batch_id); + // Limit active files to 8 items max + setActiveFiles(evt.active_files.slice(0, 8)); + setFilesProcessed(evt.job_files_completed); + setTotalFiles(evt.job_files_total); + setProgressPercent(evt.job_progress_percent); + + // Update batch state with current progress + if (batchState) { + setBatchState({ + ...batchState, + files_processed: evt.batch_files_completed, + }); + } + + // Notify unified table for each active file + if (onFileUpdateRef.current) { + for (const file of evt.active_files.slice(0, 8)) { + onFileUpdateRef.current(file); + } + } + break; + } + + case "batch_completed": { + const evt = data as BatchCompletedEvent; + if (batchState) { + setBatchState({ + ...batchState, + status: "completed", + files_uploaded: evt.files_uploaded, + files_failed: evt.files_failed, + completed_at: new Date().toISOString(), + }); + } + break; + } + + case "job_completed": { + // Job completed event - reset batch state + setIsRunning(false); + setIsCancelling(false); + setJobId(null); + setIsBatchProcessing(false); + setCurrentBatch(null); + setTotalBatches(null); + setBatchState(null); + + // For large jobs, fetch paginated results instead of receiving all files + // The full file list would be too large for SSE + // Client should call /api/upload/results/ for pagination + break; + } + + default: + break; + } + return; + } + + // Lightweight progress dict (during upload) + if (isProgressDict(data)) { + const p = data as UploadJobProgress; + setFilesProcessed(p.files_completed); + setTotalFiles(p.total_files); + setStatusCounts({ + uploaded: p.files_uploaded, + skipped: p.files_skipped, + failed: p.files_failed, + }); + setProgressPercent(p.progress_percent); + setEta(p.eta_seconds); + setActiveFiles(p.files); + setUploadedBytesFormatted(p.uploaded_bytes_formatted); + setTotalBytesFormatted(p.total_bytes_formatted); + + // Notify unified table for each active file + if (onFileUpdateRef.current) { + for (const file of p.files) { + onFileUpdateRef.current(file); + } + } + + // Terminal statuses in progress dict + if ( + p.status === "completed" || + p.status === "failed" || + p.status === "cancelled" + ) { + setIsRunning(false); + setIsCancelling(false); + setJobId(null); + } + return; + } + + // Full job dict (terminal event) + if (isFullJobDict(data)) { + const job = data as UploadJob; + setFilesProcessed(job.files_completed); + setTotalFiles(job.total_files); + setStatusCounts({ + uploaded: job.files_uploaded, + skipped: job.files_skipped, + failed: job.files_failed, + }); + setProgressPercent(job.progress_percent); + setEta(null); + setActiveFiles([]); + setUploadedBytesFormatted(job.uploaded_bytes_formatted); + setTotalBytesFormatted(job.total_bytes_formatted); + setCompletedJob(job); + // Notify unified table with all completion data + onCompletionRef.current?.(job.files); + setIsRunning(false); + setIsCancelling(false); + setJobId(null); + return; + } + }, + [ + setCompletedJob, + setCurrentBatch, + setTotalBatches, + setBatchState, + setIsBatchProcessing, + batchState, + ], + ); + + const sseUrl = useMemo( + () => (jobId && isRunning ? `/api/upload/progress/${jobId}` : null), + [jobId, isRunning], + ); + + useSSE({ + url: sseUrl, + onMessage: handleMessage, + onError: () => { + // Stream closed — if we're still "running" the server ended it. + if (isRunning) { + setIsRunning(false); + setIsCancelling(false); + setJobId(null); + } + }, + }); + + /** Kick off a bulk-analyze + auto-upload job. */ + const startUpload = useCallback( + async (filePaths: string[], skipDuplicates = true) => { + // Reset state + setFilesProcessed(0); + setTotalFiles(0); + setActiveFiles([]); + setStatusCounts({ uploaded: 0, skipped: 0, failed: 0 }); + setProgressPercent(0); + setEta(null); + setUploadedBytesFormatted(""); + setTotalBytesFormatted(""); + setIsCancelling(false); + setIsRunning(true); + + try { + const res = await apiPost<{ job_id: string; total_files: number }>( + "/api/upload/bulk-analyze", + { + file_paths: filePaths, + auto_upload: true, + skip_duplicates: skipDuplicates, + }, + ); + setUploadJobId(res.job_id); + setTotalFiles(res.total_files); + setJobId(res.job_id); + } catch { + setIsRunning(false); + } + }, + [setUploadJobId], + ); + + /** Cancel the current upload job. SSE terminal event handles cleanup. */ + const cancelUpload = useCallback(async () => { + const currentJobId = useUploadStore.getState().uploadJobId; + if (!currentJobId) return; + setIsCancelling(true); + try { + await apiPost(`/api/upload/cancel/${currentJobId}`); + } catch { + // If the cancel request itself fails, force-close + setIsRunning(false); + setJobId(null); + setIsCancelling(false); + } + }, []); + + return { + startUpload, + cancelUpload, + filesProcessed, + totalFiles, + activeFiles, + statusCounts, + progressPercent, + eta, + isRunning, + isCancelling, + uploadedBytesFormatted, + totalBytesFormatted, + }; +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..6dc8e85 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,186 @@ +@import "tailwindcss"; + +/* ── NLR Brand Colors (Tailwind v4 @theme) ── */ +@theme { + --color-nlr-blue: #0079C2; + --color-nlr-blue-light: #00A3E4; + --color-nlr-yellow: #EE9521; + --color-nlr-yellow-light: #FFC423; + --color-nlr-green: #5D9732; + --color-nlr-green-light: #9ECE42; + --color-nlr-gray: #5E6A71; + --color-nlr-gray-light: #D1D5D8; + --color-nlr-gray-bg: #EDEDED; + --color-nlr-gray-footer: #E3E6E8; + --color-nlr-text: #212224; + --font-family-roboto: "Roboto", "Helvetica Neue", Helvetica, sans-serif; +} + +/* ── Base ── */ +body { + font-family: var(--font-family-roboto); +} + +/* ── NLR Header ── */ +.nlr-header { + background: #fff; + width: 100%; +} + +.nlr-header-top { + display: flex; + align-items: center; + justify-content: space-between; + height: 100px; + max-width: 1140px; + margin: 0 auto; + padding: 0 15px; +} + +.nlr-header-title { + font-weight: normal; + color: #212224; + font-size: 24px; + letter-spacing: 0.5px; +} + +.nlr-logo-image { + width: 200px; +} + +/* ── Menu bar ── */ +.nlr-menu-bar { + background: #EDEDED; + border-top: 1px solid #D6D4D4; + height: 50px; +} + +.nlr-menu-container { + max-width: 1140px; + margin: 0 auto; + display: flex; + align-items: center; + height: 100%; + padding: 0 15px; +} + +.nlr-menu-item { + color: #2F2F2F; + padding: 14px 20px; + font-size: 14px; + text-decoration: none; + transition: all 0.2s; +} + +.nlr-menu-item:hover, +.nlr-menu-item.active { + background: #5E6A71; + color: #fff; +} + +/* ── NLR Footer ── */ +.nlr-footer { + background-color: #E3E6E8; + font-size: 14px; + font-weight: 400; + line-height: 1.2; +} + +.nlr-footer-top { + background-color: #D1D5D8; + padding: 1.5em 0; +} + +.nlr-footer-bottom { + padding: 1.5em 0 3em 0; +} + +.nlr-footer a { + color: #000; + text-decoration: none; +} + +.nlr-footer a:hover { + color: #C60; + text-decoration: underline; +} + +.nlr-attr { + font-size: 12px; +} + +/* ── Scrollbar-hide utility ── */ +.scrollbar-hide { + -ms-overflow-style: none; + scrollbar-width: none; +} +.scrollbar-hide::-webkit-scrollbar { + display: none; +} + +/* ── Drag and drop ── */ +.drop-zone { + transition: all 0.3s ease; +} + +.drop-zone.drag-over { + border-color: #0079C2; + background-color: rgba(0, 121, 194, 0.1); +} + +/* ── Progress bar animation ── */ +.progress-bar { + transition: width 0.4s ease-out; +} + +/* ── File item hover ── */ +.file-item:hover { + background-color: rgba(0, 121, 194, 0.05); +} + +/* ── Spinner ── */ +@keyframes spin { + to { transform: rotate(360deg); } +} + +.spinner { + animation: spin 1s linear infinite; +} + +/* ── Step indicator transitions ── */ +.step-circle, +.step-label, +.step-connector { + transition: all 0.3s ease; +} + +/* ── Responsive ── */ +@media (max-width: 991px) { + .nlr-header-top, + .nlr-menu-container { + max-width: 720px; + } +} + +@media (max-width: 767px) { + .nlr-header-top { + flex-direction: column; + height: auto; + padding: 15px; + text-align: center; + } + + .nlr-header-title { + font-size: 20px; + margin-top: 10px; + } + + .nlr-logo-image { + width: 180px; + } + + .nlr-menu-item { + padding: 12px 15px; + font-size: 13px; + } +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..a721344 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,13 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import App from "./App.tsx"; +import "./index.css"; + +createRoot(document.getElementById("root")!).render( + + + + + , +); diff --git a/frontend/src/pages/DeletePage.tsx b/frontend/src/pages/DeletePage.tsx new file mode 100644 index 0000000..992074f --- /dev/null +++ b/frontend/src/pages/DeletePage.tsx @@ -0,0 +1,500 @@ +/** + * Delete page — 5-step workflow for deleting local files after S3 upload. + * + * Step 1: FolderBrowser — select a folder + * Step 2: Review matched files with stats + * Step 3: Confirmation (type DELETE + checkbox) + * Step 4: Progress (verification + deletion with SSE) + * Step 5: Summary with results + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import ProgressBar from "../components/common/ProgressBar.tsx"; +import Spinner from "../components/common/Spinner.tsx"; +import StatCard from "../components/common/StatCard.tsx"; +import DeleteConfirmation from "../components/delete/DeleteConfirmation.tsx"; +import DeleteStepper from "../components/delete/DeleteStepper.tsx"; +import FixPermissionsModal from "../components/delete/FixPermissionsModal.tsx"; +import CancelConfirmModal from "../components/upload/CancelConfirmModal.tsx"; +import FolderBrowser from "../components/upload/FolderBrowser.tsx"; +import type { FolderExclusions } from "../components/upload/FolderBrowser.tsx"; +import { useDeleteJob } from "../hooks/useDeleteJob.ts"; +import { useDeleteScan } from "../hooks/useDeleteScan.ts"; +import { useAppStore } from "../stores/appStore.ts"; +import { useDeleteStore, type DeleteStep } from "../stores/deleteStore.ts"; +import type { DeleteScanFile } from "../types/delete.ts"; +import { LockIcon, WarningIcon } from "../utils/icons.tsx"; +import { formatBytes } from "../utils/format/bytes.ts"; + +/** Status badge colors for file table. */ +function statusBadge(status: string) { + switch (status) { + case "deleted": + return "bg-green-100 text-green-700"; + case "verified": + return "bg-blue-100 text-blue-700"; + case "verifying": + case "deleting": + return "bg-yellow-100 text-yellow-700"; + case "mismatch": + return "bg-orange-100 text-orange-700"; + case "failed": + return "bg-red-100 text-red-700"; + case "cancelled": + return "bg-gray-100 text-gray-600"; + default: + return "bg-gray-100 text-gray-600"; + } +} + +/** Truncate path for display, showing last N segments. */ +function truncatePath(path: string, segments = 3): string { + const parts = path.split("/"); + if (parts.length <= segments) return path; + return `.../${parts.slice(-segments).join("/")}`; +} + +export default function DeletePage() { + const { + step, + setStep, + folderPath, + setFolderPath, + scanResults, + scanTotalSize, + permissionWarning, + completedJob, + isDeleting, + reset, + } = useDeleteStore(); + + const defaultUploadFolder = useAppStore( + (s) => s.settings?.default_upload_folder, + ); + + const { scan, isScanning } = useDeleteScan(); + const deleteJob = useDeleteJob(); + + // Pagination for file table + const [page, setPage] = useState(0); + const pageSize = 50; + const [showCancelModal, setShowCancelModal] = useState(false); + const [showFixPermModal, setShowFixPermModal] = useState(false); + const [refreshKey, setRefreshKey] = useState(0); + + // Track exclusions so re-scan after permission fix uses the same filters + const lastExclusions = useRef(undefined); + + // ── Step transitions ── + + const handleFolderSelected = useCallback( + async (path: string, exclusions?: FolderExclusions) => { + lastExclusions.current = exclusions; + setFolderPath(path); + setStep(2); + await scan(path, exclusions); + }, + [setFolderPath, setStep, scan], + ); + + const handlePermissionsFixed = useCallback(async () => { + if (folderPath) { + await scan(folderPath, lastExclusions.current); + } + }, [folderPath, scan]); + + const handleConfirmDelete = useCallback(async () => { + setStep(4); + await deleteJob.startDelete(); + }, [setStep, deleteJob]); + + const handleCancelClick = useCallback(() => { + setShowCancelModal(true); + }, []); + + const handleConfirmCancel = useCallback(async () => { + setShowCancelModal(false); + await deleteJob.cancelDelete(); + }, [deleteJob]); + + // Auto-advance from Step 4 to Step 5 when deletion completes + useEffect(() => { + if (step === 4 && !deleteJob.isRunning && completedJob) { + setStep(5); + } + }, [step, deleteJob.isRunning, completedJob, setStep]); + + const handleStartOver = useCallback(() => { + const previousPath = folderPath; + reset(); + // Preserve the folder path so the browser reopens at the same location + if (previousPath) setFolderPath(previousPath); + // Bump key to force FolderBrowser to re-mount with fresh data + setRefreshKey((k) => k + 1); + }, [reset, folderPath, setFolderPath]); + + const handleBack = useCallback(() => { + if (step === 2) { + reset(); + } else if (step === 3) { + setStep(2); + } + }, [step, reset, setStep]); + + const handleStepClick = useCallback( + (s: DeleteStep) => { + if (s === 1) reset(); + else if (s === 2 && step > 2) setStep(2); + }, + [reset, step, setStep], + ); + + // ── Derived data ── + + const progressPercent = useMemo(() => { + if (deleteJob.totalFiles === 0) return 0; + return Math.round( + (deleteJob.filesProcessed / deleteJob.totalFiles) * 100, + ); + }, [deleteJob.filesProcessed, deleteJob.totalFiles]); + + const completionFiles: DeleteScanFile[] = completedJob?.files ?? []; + + // Paginated files for review step + const paginatedReviewFiles = useMemo(() => { + const start = page * pageSize; + return scanResults.slice(start, start + pageSize); + }, [scanResults, page]); + + const totalPages = Math.ceil(scanResults.length / pageSize); + + // ── Render ── + + return ( +
+ + + {/* Step 1: Folder Selection */} + {step === 1 && ( + + )} + + {/* Step 2: Review */} + {step === 2 && ( +
+
+ + + +
+ + {permissionWarning && ( +
+ +
+

+ Permission issues detected +

+

+ Some files on this drive are owned by a different user and + cannot be deleted without fixing permissions first. +

+ +
+
+ )} + + {scanResults.length === 0 && !isScanning && ( +
+ No uploaded MCAP files found in this folder. Only files previously + uploaded through this application can be cleared. +
+ )} + + {scanResults.length > 0 && ( + <> +
+ + + + + + + + + + {paginatedReviewFiles.map((f) => ( + + + + + + ))} + +
+ Filename + + Size + + Cloud Path +
+ {f.filename} + + {formatBytes(f.file_size)} + + {truncatePath(f.s3_path)} +
+
+ + {/* Pagination */} + {totalPages > 1 && ( +
+ + Showing {page * pageSize + 1}– + {Math.min((page + 1) * pageSize, scanResults.length)} of{" "} + {scanResults.length} + +
+ + +
+
+ )} + + )} + +
+ + {scanResults.length > 0 && ( + + )} +
+ + setShowFixPermModal(false)} + folderPath={folderPath} + onFixed={handlePermissionsFixed} + /> +
+ )} + + {/* Step 3: Confirmation */} + {step === 3 && ( + + )} + + {/* Step 4: Deletion Progress */} + {step === 4 && ( +
+
+ + + +
+ + + + {!deleteJob.isCancelling && ( + + )} + + {/* Cancel confirmation modal */} + setShowCancelModal(false)} + onConfirm={handleConfirmCancel} + filesProcessed={deleteJob.filesProcessed} + totalFiles={deleteJob.totalFiles} + /> + + {/* Cancelling overlay */} + {deleteJob.isCancelling && ( +
+
+ +

Cancelling...

+

Waiting for in-progress operations to finish.

+
+
+ )} +
+ )} + + {/* Step 5: Summary */} + {step === 5 && completedJob && ( +
+
+ + + + +
+ + {/* Result file table */} + {completionFiles.length > 0 && ( +
+ + + + + + + + + + + {completionFiles.map((f) => ( + + + + + + + ))} + +
+ Filename + + Size + + Status + + Details +
+ {f.filename} + + {formatBytes(f.file_size)} + + + {f.status} + + + {f.error_message || (f.verification === "md5+size" ? "Verified: MD5 + size" : f.verification === "size" ? "Verified: size (multipart ETag)" : "—")} +
+
+ )} + + +
+ )} +
+ ); +} diff --git a/frontend/src/pages/FilesPage.tsx b/frontend/src/pages/FilesPage.tsx new file mode 100644 index 0000000..1c4e83f --- /dev/null +++ b/frontend/src/pages/FilesPage.tsx @@ -0,0 +1,38 @@ +import { useEffect } from "react"; +import S3Browser from "../components/files/S3Browser.tsx"; +import { useAppStore } from "../stores/appStore.ts"; + +export default function FilesPage() { + const settings = useAppStore((s) => s.settings); + const loadSettings = useAppStore((s) => s.loadSettings); + + useEffect(() => { + if (!settings) { + void loadSettings(); + } + }, [settings, loadSettings]); + + if (!settings) { + return ( +
Loading settings...
+ ); + } + + if (!settings.s3_bucket) { + return ( +
+

S3 bucket is not configured.

+ + Go to Settings + +
+ ); + } + + return ( +
+

Browse Uploaded Files

+ +
+ ); +} diff --git a/frontend/src/pages/LogsPage.tsx b/frontend/src/pages/LogsPage.tsx new file mode 100644 index 0000000..8b0b997 --- /dev/null +++ b/frontend/src/pages/LogsPage.tsx @@ -0,0 +1,78 @@ +import { useCallback, useState } from "react"; +import FilterBar from "../components/logs/FilterBar.tsx"; +import type { LogFilters } from "../components/logs/FilterBar.tsx"; +import LogStatsBar from "../components/logs/LogStatsBar.tsx"; +import LogTable from "../components/logs/LogTable.tsx"; +import UploadSessionList from "../components/logs/UploadSessionList.tsx"; +import UploadStatsBar from "../components/logs/UploadStatsBar.tsx"; +import type { UploadSession, UploadStatsResponse } from "../types/api.ts"; + +type ActiveTab = "uploads" | "events"; + +const DEFAULT_FILTERS: LogFilters = { + date: "", + level: "", + category: "", + search: "", +}; + +const tabs: { key: ActiveTab; label: string }[] = [ + { key: "uploads", label: "Upload History" }, + { key: "events", label: "Event Log" }, +]; + +export default function LogsPage() { + const [activeTab, setActiveTab] = useState("uploads"); + const [filters, setFilters] = useState(DEFAULT_FILTERS); + const [sessions, setSessions] = useState([]); + + const handleUploadDataLoaded = useCallback((data: UploadStatsResponse) => { + setSessions(data.sessions); + }, []); + + const handleFilterChange = useCallback((newFilters: LogFilters) => { + setFilters(newFilters); + }, []); + + const title = activeTab === "uploads" ? "Upload History" : "Event Log"; + + return ( +
+

{title}

+ + {/* Tab bar */} +
+ {tabs.map((tab) => ( + + ))} +
+ + {/* Upload History tab */} + {activeTab === "uploads" && ( +
+ + +
+ )} + + {/* Event Log tab */} + {activeTab === "events" && ( +
+ + + +
+ )} +
+ ); +} diff --git a/frontend/src/pages/SettingsPage.tsx b/frontend/src/pages/SettingsPage.tsx new file mode 100644 index 0000000..dfe8055 --- /dev/null +++ b/frontend/src/pages/SettingsPage.tsx @@ -0,0 +1,34 @@ +import { useEffect } from "react"; +import CacheSection from "../components/settings/CacheSection.tsx"; +import DangerZone from "../components/settings/DangerZone.tsx"; +import PerformanceSection from "../components/settings/PerformanceSection.tsx"; +import SettingsForm from "../components/settings/SettingsForm.tsx"; +import UpdateSection from "../components/settings/UpdateSection.tsx"; +import { useAppStore } from "../stores/appStore.ts"; + +export default function SettingsPage() { + const { loadSettings, settingsLoading } = useAppStore(); + + useEffect(() => { + loadSettings(); + }, [loadSettings]); + + if (settingsLoading) { + return ( +
+
+
+ ); + } + + return ( +
+

Settings

+ + + + + +
+ ); +} diff --git a/frontend/src/pages/UploadPage.tsx b/frontend/src/pages/UploadPage.tsx new file mode 100644 index 0000000..d4ff7a4 --- /dev/null +++ b/frontend/src/pages/UploadPage.tsx @@ -0,0 +1,515 @@ +/** + * Upload page — the 4-step upload workflow with a unified file table. + * + * Step 1: FolderBrowser — select a folder of MCAP files + * Steps 2-4: Unified table that persists across Review, Upload, and Summary, + * with phase-aware header, toolbar, and footer. + */ + +import { useCallback, useEffect, useMemo, useState } from "react"; + +import Spinner from "../components/common/Spinner.tsx"; +import ActiveFilesList from "../components/upload/ActiveFilesList.tsx"; +import BatchProgress from "../components/upload/BatchProgress.tsx"; +import CancelConfirmModal from "../components/upload/CancelConfirmModal.tsx"; +import ConfirmModal from "../components/upload/ConfirmModal.tsx"; +import FolderBrowser from "../components/upload/FolderBrowser.tsx"; +import type { FolderExclusions } from "../components/upload/FolderBrowser.tsx"; +import ReviewToolbar, { StatusFilterBar } from "../components/upload/ReviewToolbar.tsx"; +import ScanProgressModal from "../components/upload/ScanProgressModal.tsx"; +import Stepper from "../components/upload/Stepper.tsx"; +import UnifiedFileTable from "../components/upload/UnifiedFileTable.tsx"; +import UploadFooter from "../components/upload/UploadFooter.tsx"; +import UploadHeader from "../components/upload/UploadHeader.tsx"; +import { useFileStore } from "../hooks/useFileStore.ts"; +import { useFolderScan } from "../hooks/useFolderScan.ts"; +import { useUploadJob } from "../hooks/useUploadJob.ts"; +import { useAppStore } from "../stores/appStore.ts"; +import { useUploadStore, type UploadStep } from "../stores/uploadStore.ts"; +import type { FileUploadState, ScannedFileInfo } from "../types/api.ts"; +import type { StatusFilter, UploadPhase } from "../types/upload.ts"; +import { downloadUploadCsv } from "../utils/csv.ts"; + +export default function UploadPage() { + const { + step, + setStep, + folderPath, + setFolderPath, + scanFolders, + completedJob, + batchState, + isBatchProcessing, + reset, + } = useUploadStore(); + + const defaultUploadFolder = useAppStore((s) => s.settings?.default_upload_folder); + + const { + startScan, + cancelScan, + isScanning, + scanComplete, + folders, + foldersTotal, + totals, + } = useFolderScan(); + + // Unified file store + const { files, store } = useFileStore(); + + // SSE callback: map each file event to the FileStore + const handleFileUpdate = useCallback((file: FileUploadState) => { + const statusMap: Record = { + analyzing: "in_progress", + uploading: "in_progress", + completed: "completed", + skipped: "skipped", + failed: "failed", + cancelled: "failed", + }; + store.updateFile(file.local_path, { + status: statusMap[file.status] ?? "queued", + progressPercent: file.progress_percent, + s3Path: file.s3_path || undefined, + error: file.error_message || undefined, + duration: file.upload_duration_seconds, + speed: file.upload_speed_mbps, + }); + }, [store]); + + // SSE callback: merge full completion data into the FileStore + const handleCompletion = useCallback((completionFiles: FileUploadState[]) => { + store.mergeCompletion(completionFiles); + }, [store]); + + const uploadJob = useUploadJob({ + onFileUpdate: handleFileUpdate, + onCompletion: handleCompletion, + }); + + // Local state + const [showConfirmModal, setShowConfirmModal] = useState(false); + const [showCancelModal, setShowCancelModal] = useState(false); + const [pendingSelectedPaths, setPendingSelectedPaths] = useState([]); + const [selectedPaths, setSelectedPaths] = useState>(new Set()); + + // Delay scan modal by 500 ms — fast/cached scans complete before the timer + // fires, so the modal never flashes for them. + const [showScanModal, setShowScanModal] = useState(false); + useEffect(() => { + if (!isScanning) { + setShowScanModal(false); + return; + } + const timer = setTimeout(() => setShowScanModal(true), 500); + return () => clearTimeout(timer); + }, [isScanning]); + + // Derive phase from step + const phase: UploadPhase = step <= 2 ? "review" : step === 3 ? "uploading" : "summary"; + + // ── Freeze/unfreeze sort on phase transitions ── + + useEffect(() => { + if (step === 3) store.freezeSort(); + if (step === 4) store.unfreezeSort(); + }, [step, store]); + + // ── Step transitions ── + + /** Step 1: User selected a folder. Start scanning (stay on Step 1 while scanning). */ + const handleFolderSelected = useCallback( + async (path: string, exclusions?: FolderExclusions) => { + store.clear(); + setFolderPath(path); + // Stay on Step 1 while scanning - auto-advance when complete + await startScan(path, false, exclusions); + }, + [setFolderPath, startScan, store], + ); + + /** Auto-advance to Step 2 when scan completes. + * + * We populate the FileStore here, right before advancing, because by the time + * scanComplete becomes true the folders array reference has already settled — + * any render-time ref-comparison trick would have already consumed the change + * while step was still 1 and skipped the buildFromScan call. + */ + useEffect(() => { + if (step === 1 && scanComplete && folders.length > 0) { + store.buildFromScan(folders); + const newSelected = new Set(); + for (const folder of folders) { + for (const file of folder.files) { + if (!file.already_uploaded) { + newSelected.add(file.path); + } + } + } + setSelectedPaths(newSelected); + setStep(2); + } + }, [step, scanComplete, folders, store, setSelectedPaths, setStep]); + + /** Step 2: User clicks "Start Upload" — show confirmation modal. */ + const handleStartUploadClick = useCallback(() => { + setPendingSelectedPaths(Array.from(selectedPaths)); + setShowConfirmModal(true); + }, [selectedPaths]); + + /** Confirm modal: Start the actual upload, advance to step 3. */ + const handleConfirmUpload = useCallback( + async (skipDuplicates: boolean) => { + setShowConfirmModal(false); + if (pendingSelectedPaths.length === 0) return; + + // Mark selected files as queued, remove unselected + store.markSelectedAsPending(new Set(pendingSelectedPaths)); + // Reset filter for upload phase + store.setFilter("all"); + store.setSearch(""); + + setStep(3); + await uploadJob.startUpload(pendingSelectedPaths, skipDuplicates); + }, + [pendingSelectedPaths, setStep, uploadJob, store], + ); + + /** Cancel button → show confirmation modal. */ + const handleCancelClick = useCallback(() => { + setShowCancelModal(true); + }, []); + + /** Cancel confirmed → send cancel to backend, overlay locks the screen. */ + const handleConfirmCancel = useCallback(async () => { + setShowCancelModal(false); + await uploadJob.cancelUpload(); + }, [uploadJob]); + + /** Step 3 -> 4: Upload finished (or cancelled), advance to completion. */ + useEffect(() => { + if (step === 3 && !uploadJob.isRunning && completedJob) { + setStep(4); + } + }, [step, uploadJob.isRunning, completedJob, setStep]); + + /** Step 4 -> 1: Reset and start over. */ + const handleUploadMore = useCallback(() => { + store.clear(); + reset(); + }, [reset, store]); + + /** Cancel scan and return to Step 1 if needed. */ + const handleCancelScan = useCallback(async () => { + await cancelScan(); + // If we're on a later step during scan (shouldn't happen with new flow, but handle it) + if (step > 1) { + setStep(1); + } + }, [cancelScan, step, setStep]); + + /** Back from step 2 -> step 1. */ + const handleBack = useCallback(async () => { + if (isScanning) { + await cancelScan(); + } + store.clear(); + reset(); + }, [isScanning, cancelScan, reset, store]); + + /** Stepper click — only steps 1 and 2 are clickable, and only when not uploading. */ + const handleStepClick = useCallback( + (s: UploadStep) => { + if (s === 1) { + handleBack(); + } else if (s === 2 && step > 2) { + setStep(2); + } + }, + [handleBack, step, setStep], + ); + + // ── Selection helpers ── + + const toggleFile = useCallback((path: string) => { + setSelectedPaths((prev) => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }, []); + + // `files` is listed as a dep to re-derive when the store snapshot changes + // (store itself is a stable singleton). + // eslint-disable-next-line react-hooks/exhaustive-deps + const allFiles = useMemo(() => Array.from(store.getAllRows().values()), [store, files]); + + const toggleAllFiltered = useCallback(() => { + const filteredPaths = files.map((f) => f.path); + const allSelected = filteredPaths.every((p) => selectedPaths.has(p)); + setSelectedPaths((prev) => { + const next = new Set(prev); + for (const p of filteredPaths) { + if (allSelected) next.delete(p); + else next.add(p); + } + return next; + }); + }, [files, selectedPaths]); + + const selectAll = useCallback(() => { + setSelectedPaths(new Set(allFiles.map((f) => f.path))); + }, [allFiles]); + + const selectNewOnly = useCallback(() => { + setSelectedPaths(new Set(allFiles.filter((f) => !f.alreadyUploaded).map((f) => f.path))); + }, [allFiles]); + + const deselectAll = useCallback(() => { + setSelectedPaths(new Set()); + }, []); + + const selectFirstN = useCallback((n: number) => { + const paths = files.slice(0, n).map((f) => f.path); + setSelectedPaths(new Set(paths)); + }, [files]); + + // ── Header checkbox state ── + + const filteredSelectedCount = files.filter((f) => selectedPaths.has(f.path)).length; + const headerChecked = files.length > 0 && filteredSelectedCount === files.length; + const headerIndeterminate = filteredSelectedCount > 0 && filteredSelectedCount < files.length; + + // ── Selected new count (for start upload button) ── + + const selectedNewCount = allFiles.filter( + (f) => selectedPaths.has(f.path) && !f.alreadyUploaded, + ).length; + + // ── Confirm modal totals ── + + const selectedTotals = useMemo(() => { + const selectedSet = new Set(pendingSelectedPaths); + let totalFiles = 0; + let alreadyUploaded = 0; + let totalSize = 0; + for (const folder of scanFolders) { + for (const file of folder.files as ScannedFileInfo[]) { + if (selectedSet.has(file.path)) { + totalFiles++; + totalSize += file.size; + if (file.already_uploaded) alreadyUploaded++; + } + } + } + return { totalFiles, alreadyUploaded, totalSize }; + }, [pendingSelectedPaths, scanFolders]); + + // ── Filter click handler (from clickable status counters) ── + + const handleFilterClick = useCallback((filter: StatusFilter) => { + store.setFilter(filter); + }, [store]); + + // ── CSV download ── + + const handleDownloadCsv = useCallback(() => { + const jobId = completedJob?.job_id ?? "unknown"; + downloadUploadCsv(Array.from(store.getAllRows().values()), jobId); + }, [completedJob, store]); + + // ── Review KPIs: what is actually going to happen when the user clicks Upload ── + // + // toUpload — new selected files (will be sent to S3) + // uploadSize — bytes of those new files + // alreadyOnS3 — selected files already there (will be skipped) + // totalInFolder — all files found in the scan (context denominator) + const activeTotals = useMemo(() => { + let toUpload = 0; + let uploadSize = 0; + let alreadyOnS3 = 0; + for (const file of allFiles) { + if (selectedPaths.has(file.path)) { + if (file.alreadyUploaded) { + alreadyOnS3++; + } else { + toUpload++; + uploadSize += file.size; + } + } + } + return { toUpload, uploadSize, alreadyOnS3, totalInFolder: allFiles.length }; + // `files` dep ensures we recompute when the store snapshot changes + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [allFiles, selectedPaths, files]); + + // ── Render ── + + return ( +
+ + + {step === 1 && ( + + )} + + {step >= 2 && ( +
+ {/* Phase-aware header (stat cards / progress / summary) */} + + + {/* Phase-aware toolbar */} + {phase === "review" ? ( + store.setFilter(f)} + searchQuery={store.getSearch()} + onSearchChange={(q) => store.setSearch(q)} + selectedCount={selectedPaths.size} + totalCount={store.getSize()} + filteredCount={files.length} + showFilteredCount={store.getFilter() !== "all" || store.getSearch() !== ""} + onSelectAll={selectAll} + onSelectNewOnly={selectNewOnly} + onToggleAllFiltered={toggleAllFiltered} + onDeselectAll={deselectAll} + onSelectFirstN={selectFirstN} + headerChecked={headerChecked} + /> + ) : ( + store.setFilter(f)} + totalCount={store.getSize()} + filteredCount={files.length} + searchQuery={store.getSearch()} + onSearchChange={(q) => store.setSearch(q)} + /> + )} + + {/* Top action buttons (review phase only) */} + {phase === "review" && ( + + )} + + {/* Batch processing UI for upload phase with large jobs */} + {phase === "uploading" && isBatchProcessing ? ( + <> + + + + ) : ( + /* The unified file table — stays mounted across phases */ + store.setSort(key)} + isSortFrozen={store.isFrozen()} + selectedPaths={selectedPaths} + onToggleFile={toggleFile} + onToggleAllFiltered={toggleAllFiltered} + headerChecked={headerChecked} + headerIndeterminate={headerIndeterminate} + /> + )} + + {/* Bottom action buttons */} + + + {/* Confirm modal */} + {step === 2 && ( + setShowConfirmModal(false)} + onConfirm={handleConfirmUpload} + totalFiles={selectedTotals.totalFiles} + alreadyUploaded={selectedTotals.alreadyUploaded} + totalSize={selectedTotals.totalSize} + /> + )} + + {/* Cancel confirmation modal */} + setShowCancelModal(false)} + onConfirm={handleConfirmCancel} + filesProcessed={uploadJob.filesProcessed} + totalFiles={uploadJob.totalFiles} + /> + + {/* Cancelling overlay — locks the screen while backend winds down */} + {uploadJob.isCancelling && ( +
+
+ +

Cancelling upload...

+

Waiting for in-progress files to finish.

+
+
+ )} +
+ )} + + {/* Scan progress modal - only shown after 500 ms delay so fast/cached + scans never flash the modal at all. */} + +
+ ); +} diff --git a/frontend/src/stores/appStore.ts b/frontend/src/stores/appStore.ts new file mode 100644 index 0000000..1b5f965 --- /dev/null +++ b/frontend/src/stores/appStore.ts @@ -0,0 +1,79 @@ +import { create } from "zustand"; +import { apiGet, apiPut } from "../api/client.ts"; +import type { AppSettings, VersionInfo } from "../types/api.ts"; + +export interface Notification { + id: string; + type: "success" | "error" | "info" | "warning"; + message: string; +} + +interface AppState { + // Settings + settings: AppSettings | null; + settingsLoading: boolean; + loadSettings: () => Promise; + updateSettings: (updates: Partial) => Promise; + + // Version + version: VersionInfo | null; + loadVersion: () => Promise; + + // Notifications + notifications: Notification[]; + addNotification: (type: Notification["type"], message: string) => void; + removeNotification: (id: string) => void; +} + +export const useAppStore = create((set, get) => ({ + // Settings + settings: null, + settingsLoading: false, + + loadSettings: async () => { + set({ settingsLoading: true }); + try { + const settings = await apiGet("/api/settings"); + set({ settings, settingsLoading: false }); + } catch { + set({ settingsLoading: false }); + get().addNotification("error", "Failed to load settings"); + } + }, + + updateSettings: async (updates) => { + try { + const settings = await apiPut("/api/settings", updates); + set({ settings }); + get().addNotification("success", "Settings saved"); + } catch { + get().addNotification("error", "Failed to save settings"); + } + }, + + // Version + version: null, + + loadVersion: async () => { + try { + const version = await apiGet("/api/settings/version"); + set({ version }); + } catch { + // Silently fail — version is non-critical + } + }, + + // Notifications + notifications: [], + + addNotification: (type, message) => { + const id = crypto.randomUUID(); + set((s) => ({ notifications: [...s.notifications, { id, type, message }] })); + // Auto-remove after 5 seconds + setTimeout(() => get().removeNotification(id), 5000); + }, + + removeNotification: (id) => { + set((s) => ({ notifications: s.notifications.filter((n) => n.id !== id) })); + }, +})); diff --git a/frontend/src/stores/deleteStore.ts b/frontend/src/stores/deleteStore.ts new file mode 100644 index 0000000..09ddcb9 --- /dev/null +++ b/frontend/src/stores/deleteStore.ts @@ -0,0 +1,68 @@ +import { create } from "zustand"; +import type { DeleteJobResult, DeleteScanFile } from "../types/delete.ts"; + +export type DeleteStep = 1 | 2 | 3 | 4 | 5; + +interface DeleteState { + // Current step + step: DeleteStep; + setStep: (step: DeleteStep) => void; + + // Selected folder + folderPath: string; + setFolderPath: (path: string) => void; + + // Scan results + scanResults: DeleteScanFile[]; + scanTotalSize: number; + permissionWarning: boolean; + isScanning: boolean; + setScanResults: ( + files: DeleteScanFile[], + totalSize: number, + permissionWarning: boolean, + ) => void; + setPermissionWarning: (warning: boolean) => void; + setIsScanning: (scanning: boolean) => void; + + // Delete job + deleteJobId: string | null; + completedJob: DeleteJobResult | null; + isDeleting: boolean; + setDeleteJobId: (id: string | null) => void; + setCompletedJob: (job: DeleteJobResult | null) => void; + setIsDeleting: (deleting: boolean) => void; + + // Reset + reset: () => void; +} + +const initialState = { + step: 1 as DeleteStep, + folderPath: "", + scanResults: [] as DeleteScanFile[], + scanTotalSize: 0, + permissionWarning: false, + isScanning: false, + deleteJobId: null, + completedJob: null, + isDeleting: false, +}; + +export const useDeleteStore = create((set) => ({ + ...initialState, + + setStep: (step) => set({ step }), + setFolderPath: (folderPath) => set({ folderPath }), + + setScanResults: (scanResults, scanTotalSize, permissionWarning) => + set({ scanResults, scanTotalSize, permissionWarning }), + setPermissionWarning: (permissionWarning) => set({ permissionWarning }), + setIsScanning: (isScanning) => set({ isScanning }), + + setDeleteJobId: (deleteJobId) => set({ deleteJobId }), + setCompletedJob: (completedJob) => set({ completedJob }), + setIsDeleting: (isDeleting) => set({ isDeleting }), + + reset: () => set(initialState), +})); diff --git a/frontend/src/stores/fileStore.ts b/frontend/src/stores/fileStore.ts new file mode 100644 index 0000000..919ec2a --- /dev/null +++ b/frontend/src/stores/fileStore.ts @@ -0,0 +1,397 @@ +/** + * FileStore — manages the Map for the unified upload table. + * + * Exposes a useSyncExternalStore-compatible interface. Uses rAF batching + * so that multiple SSE updates within a single frame coalesce into one + * React re-render. + */ + +import type { FileUploadState, ScannedFolder } from "../types/api.ts"; +import type { + SortDir, + SortKey, + StatusFilter, + UnifiedFileRow, + UnifiedStatus, +} from "../types/upload.ts"; + +type Listener = () => void; + +export class FileStore { + private rows = new Map(); + private listeners = new Set(); + private snapshot: UnifiedFileRow[] = []; + private dirty = true; + + // Sort / filter state + private sortKey: SortKey = "filename"; + private sortDir: SortDir = "asc"; + private filter: StatusFilter = "all"; + private search = ""; + + // Frozen sort — during upload, positions stay fixed + private frozen = false; + private frozenArray: UnifiedFileRow[] = []; + + // rAF batching + private rafId: number | null = null; + + // ── useSyncExternalStore interface ── + + subscribe = (listener: Listener): (() => void) => { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + }; + + getSnapshot = (): UnifiedFileRow[] => { + if (this.dirty) { + this.snapshot = this.buildSnapshot(); + this.dirty = false; + } + return this.snapshot; + }; + + // ── Public getters ── + + getRow(path: string): UnifiedFileRow | undefined { + return this.rows.get(path); + } + + getAllRows(): Map { + return this.rows; + } + + getSortKey(): SortKey { + return this.sortKey; + } + + getSortDir(): SortDir { + return this.sortDir; + } + + getFilter(): StatusFilter { + return this.filter; + } + + getSearch(): string { + return this.search; + } + + getSize(): number { + return this.rows.size; + } + + isFrozen(): boolean { + return this.frozen; + } + + // ── Populate from scan ── + + buildFromScan(folders: ScannedFolder[]): void { + this.rows.clear(); + for (const folder of folders) { + this.addFolderInternal(folder); + } + this.invalidate(); + this.notify(); + } + + addFolderFromScan(folder: ScannedFolder): void { + this.addFolderInternal(folder); + this.invalidate(); + this.notify(); + } + + private addFolderInternal(folder: ScannedFolder): void { + for (const file of folder.files) { + if (this.rows.has(file.path)) continue; + const uploaded = file.already_uploaded ?? false; + this.rows.set(file.path, { + path: file.path, + filename: file.filename, + size: file.size, + folder: folder.relative_path === "." ? "" : folder.relative_path, + mtime: file.mtime, + alreadyUploaded: uploaded, + status: uploaded ? "already_uploaded" : "new", + progressPercent: 0, + s3Path: "", + duration: null, + speed: null, + error: "", + }); + } + } + + // ── Per-file update (called from SSE callbacks) ── + + updateFile(path: string, update: Partial): void { + const existing = this.rows.get(path); + if (!existing) return; + + // Shallow-clone so React.memo detects a new reference + const updated = { ...existing, ...update }; + this.rows.set(path, updated); + + // If frozen, update the frozen array in-place at O(1) + if (this.frozen && updated._frozenIndex != null) { + this.frozenArray[updated._frozenIndex] = updated; + } + + this.invalidate(); + this.scheduleNotify(); + } + + // ── Bulk merge from terminal UploadJob ── + + mergeCompletion(files: FileUploadState[]): void { + for (const f of files) { + const existing = this.rows.get(f.local_path); + if (!existing) continue; + + this.rows.set(f.local_path, { + ...existing, + status: mapApiStatus(f.status), + progressPercent: f.progress_percent, + s3Path: f.s3_path, + duration: f.upload_duration_seconds, + speed: f.upload_speed_mbps, + error: f.error_message, + }); + } + this.invalidate(); + this.notify(); + } + + // ── Transition: review → upload ── + + markSelectedAsPending(paths: Set): void { + // Remove unselected rows, keep only selected + for (const [key] of this.rows) { + if (!paths.has(key)) { + this.rows.delete(key); + } + } + // All remaining rows are "queued" + for (const [key, row] of this.rows) { + this.rows.set(key, { ...row, status: "queued" }); + } + this.invalidate(); + this.notify(); + } + + // ── Freeze / unfreeze sort ── + + freezeSort(): void { + // Build the frozen array from the current snapshot and stamp indices + const current = this.buildSnapshot(); + this.frozenArray = [...current]; + for (let i = 0; i < this.frozenArray.length; i++) { + const row = this.frozenArray[i]!; + const updated = { ...row, _frozenIndex: i }; + this.frozenArray[i] = updated; + this.rows.set(row.path, updated); + } + this.frozen = true; + this.invalidate(); + this.notify(); + } + + unfreezeSort(): void { + this.frozen = false; + this.frozenArray = []; + // Clear frozen indices + for (const [key, row] of this.rows) { + if (row._frozenIndex != null) { + this.rows.set(key, { ...row, _frozenIndex: undefined }); + } + } + this.invalidate(); + this.notify(); + } + + // ── Sort / filter / search ── + + setSort(key: SortKey, dir?: SortDir): void { + if (dir) { + this.sortKey = key; + this.sortDir = dir; + } else if (this.sortKey === key) { + this.sortDir = this.sortDir === "asc" ? "desc" : "asc"; + } else { + this.sortKey = key; + this.sortDir = "asc"; + } + // Re-freeze with new sort if frozen + if (this.frozen) { + this.frozen = false; // temporarily unfreeze to rebuild + this.freezeSort(); + return; + } + this.invalidate(); + this.notify(); + } + + setFilter(filter: StatusFilter): void { + this.filter = filter; + this.invalidate(); + this.notify(); + } + + setSearch(query: string): void { + this.search = query; + this.invalidate(); + this.notify(); + } + + // ── Reset ── + + clear(): void { + this.rows.clear(); + this.snapshot = []; + this.dirty = false; + this.frozen = false; + this.frozenArray = []; + this.sortKey = "filename"; + this.sortDir = "asc"; + this.filter = "all"; + this.search = ""; + if (this.rafId != null) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + this.notify(); + } + + // ── Internals ── + + private buildSnapshot(): UnifiedFileRow[] { + const source = this.frozen ? this.frozenArray : Array.from(this.rows.values()); + let result = source; + + // Filter + result = this.applyFilter(result); + + // Search + if (this.search) { + const q = this.search.toLowerCase(); + result = result.filter( + (r) => + r.filename.toLowerCase().includes(q) || + r.folder.toLowerCase().includes(q), + ); + } + + // Sort (skip if frozen — positions are fixed) + if (!this.frozen) { + result = this.applySort(result); + } + + return result; + } + + private applyFilter(rows: UnifiedFileRow[]): UnifiedFileRow[] { + switch (this.filter) { + case "all": + return rows; + case "new": + return rows.filter((r) => r.status === "new" || !r.alreadyUploaded); + case "uploaded": + return rows.filter((r) => r.status === "already_uploaded" || r.alreadyUploaded); + case "queued": + return rows.filter((r) => r.status === "queued"); + case "in_progress": + return rows.filter((r) => r.status === "in_progress"); + case "completed": + return rows.filter((r) => r.status === "completed"); + case "skipped": + return rows.filter((r) => r.status === "skipped"); + case "failed": + return rows.filter((r) => r.status === "failed"); + default: + return rows; + } + } + + private applySort(rows: UnifiedFileRow[]): UnifiedFileRow[] { + const dir = this.sortDir === "asc" ? 1 : -1; + return [...rows].sort((a, b) => { + switch (this.sortKey) { + case "filename": + return dir * a.filename.localeCompare(b.filename); + case "folder": + return dir * a.folder.localeCompare(b.folder); + case "size": + return dir * (a.size - b.size); + case "mtime": + return dir * (a.mtime - b.mtime); + case "status": { + return dir * (statusOrder(a.status) - statusOrder(b.status)); + } + default: + return 0; + } + }); + } + + private invalidate(): void { + this.dirty = true; + } + + private notify(): void { + for (const listener of this.listeners) { + listener(); + } + } + + private scheduleNotify(): void { + if (this.rafId != null) return; + this.rafId = requestAnimationFrame(() => { + this.rafId = null; + this.notify(); + }); + } +} + +// ── Helpers ── + +function mapApiStatus(status: string): UnifiedStatus { + switch (status) { + case "completed": + return "completed"; + case "skipped": + return "skipped"; + case "failed": + case "cancelled": + return "failed"; + case "uploading": + case "analyzing": + return "in_progress"; + default: + return "queued"; + } +} + +function statusOrder(status: UnifiedStatus): number { + switch (status) { + case "new": + return 0; + case "already_uploaded": + return 1; + case "in_progress": + return 2; + case "queued": + return 3; + case "completed": + return 4; + case "skipped": + return 5; + case "failed": + return 6; + default: + return 7; + } +} + +/** Singleton instance */ +export const fileStore = new FileStore(); diff --git a/frontend/src/stores/uploadStore.ts b/frontend/src/stores/uploadStore.ts new file mode 100644 index 0000000..cc48b63 --- /dev/null +++ b/frontend/src/stores/uploadStore.ts @@ -0,0 +1,93 @@ +import { create } from "zustand"; +import type { BatchState, ScannedFolder, UploadJob } from "../types/api.ts"; + +export type UploadStep = 1 | 2 | 3 | 4; + +interface UploadState { + // Current step + step: UploadStep; + setStep: (step: UploadStep) => void; + + // Selected folder + folderPath: string; + setFolderPath: (path: string) => void; + + // Scan results + scanJobId: string | null; + scanFolders: ScannedFolder[]; + scanComplete: boolean; + isScanning: boolean; + scanFoldersTotal: number; + scanTotals: { + totalFiles: number; + alreadyUploaded: number; + totalSize: number; + }; + setScanJobId: (id: string | null) => void; + addScanFolder: (folder: ScannedFolder) => void; + setScanComplete: (complete: boolean) => void; + setIsScanning: (scanning: boolean) => void; + setScanFoldersTotal: (total: number) => void; + updateScanTotals: (totals: { totalFiles: number; alreadyUploaded: number; totalSize: number }) => void; + + // Upload job + uploadJobId: string | null; + completedJob: UploadJob | null; + setUploadJobId: (id: string | null) => void; + setCompletedJob: (job: UploadJob | null) => void; + + // Batch processing state + currentBatch: number | null; + totalBatches: number | null; + batchState: BatchState | null; + isBatchProcessing: boolean; + setCurrentBatch: (batch: number | null) => void; + setTotalBatches: (batches: number | null) => void; + setBatchState: (state: BatchState | null) => void; + setIsBatchProcessing: (processing: boolean) => void; + + // Reset + reset: () => void; +} + +const initialState = { + step: 1 as UploadStep, + folderPath: "", + scanJobId: null, + scanFolders: [], + scanComplete: false, + isScanning: false, + scanFoldersTotal: 0, + scanTotals: { totalFiles: 0, alreadyUploaded: 0, totalSize: 0 }, + uploadJobId: null, + completedJob: null, + currentBatch: null, + totalBatches: null, + batchState: null, + isBatchProcessing: false, +}; + +export const useUploadStore = create((set) => ({ + ...initialState, + + setStep: (step) => set({ step }), + setFolderPath: (folderPath) => set({ folderPath }), + + setScanJobId: (scanJobId) => set({ scanJobId }), + addScanFolder: (folder) => + set((s) => ({ scanFolders: [...s.scanFolders, folder] })), + setScanComplete: (scanComplete) => set({ scanComplete }), + setIsScanning: (isScanning) => set({ isScanning }), + setScanFoldersTotal: (scanFoldersTotal) => set({ scanFoldersTotal }), + updateScanTotals: (scanTotals) => set({ scanTotals }), + + setUploadJobId: (uploadJobId) => set({ uploadJobId }), + setCompletedJob: (completedJob) => set({ completedJob }), + + setCurrentBatch: (currentBatch) => set({ currentBatch }), + setTotalBatches: (totalBatches) => set({ totalBatches }), + setBatchState: (batchState) => set({ batchState }), + setIsBatchProcessing: (isBatchProcessing) => set({ isBatchProcessing }), + + reset: () => set(initialState), +})); diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts new file mode 100644 index 0000000..a753444 --- /dev/null +++ b/frontend/src/types/api.ts @@ -0,0 +1,484 @@ +/** TypeScript types matching the Flask API response shapes. */ + +// ── Upload types ── + +export type UploadStatus = + | "pending" + | "analyzing" + | "ready" + | "uploading" + | "completed" + | "failed" + | "skipped" + | "cancelled"; + +export interface FileUploadState { + filename: string; + local_path: string; + file_size: number; + file_size_formatted: string; + status: UploadStatus; + s3_path: string; + start_time: string | null; + bytes_uploaded: number; + progress_percent: number; + error_message: string; + is_duplicate: boolean; + is_valid: boolean; + upload_started_at: string | null; + upload_completed_at: string | null; + upload_duration_seconds: number | null; + upload_speed_mbps: number | null; +} + +export interface UploadJobProgress { + job_id: string; + status: UploadStatus; + progress_percent: number; + files_completed: number; + total_files: number; + uploaded_bytes_formatted: string; + total_bytes_formatted: string; + eta_seconds: number | null; + files_failed: number; + files_skipped: number; + files_uploaded: number; + cancelled: boolean; + files: FileUploadState[]; +} + +export interface UploadJob { + job_id: string; + status: UploadStatus; + files: FileUploadState[]; + total_files: number; + files_completed: number; + files_failed: number; + files_skipped: number; + files_uploaded: number; + total_bytes: number; + total_bytes_formatted: string; + uploaded_bytes: number; + uploaded_bytes_formatted: string; + successfully_uploaded_bytes: number; + successfully_uploaded_bytes_formatted: string; + progress_percent: number; + eta_seconds: number | null; + created_at: string; + started_at: string | null; + completed_at: string | null; + total_upload_duration_seconds: number | null; + total_upload_duration_formatted: string | null; + average_upload_speed_mbps: number | null; + cancelled: boolean; + auto_upload: boolean; + has_valid_uploadable_files: boolean; + pre_filter_stats: PreFilterStats; +} + +export interface PreFilterStats { + total: number; + cache_hits: number; + cache_skipped: number; + s3_hits: number; + no_timestamp: number; + to_analyze: number; + file_statuses?: FilePreFilterStatus[]; +} + +export interface FilePreFilterStatus { + path: string; + filename: string; + size: number; + mtime: number; + already_uploaded: boolean; + s3_path?: string; +} + +// ── SSE event types ── + +export interface AnalysisProgressEvent { + type: "analysis_progress"; + job_id: string; + job_status: UploadStatus; + file: FileUploadState; + total_files: number; + analysis_complete: boolean; +} + +export interface AnalysisCompleteEvent { + type: "analysis_complete"; + job: UploadJob; + auto_upload?: boolean; +} + +export interface AutoUploadStartingEvent { + type: "auto_upload_starting"; + job_id: string; +} + +// ── Batch processing types ── + +export interface BatchProcessingSettings { + enabled: boolean; + batch_size: number; + auto_tune_workers: boolean; + max_workers: number; + target_cpu_percent: number; + skip_mcap_validation: boolean; + use_database_for_large_jobs: boolean; + large_job_threshold: number; +} + +export interface BatchState { + batch_id: number; + total_batches: number; + files_in_batch: number; + status: "pending" | "processing" | "completed" | "failed" | "cancelled"; + files_processed: number; + files_uploaded: number; + files_failed: number; + bytes_uploaded: number; + started_at: string | null; + completed_at: string | null; + duration_seconds: number | null; + error_message: string; +} + +export interface BatchStartedEvent { + type: "batch_started"; + batch_id: number; + total_batches: number; + files_in_batch: number; +} + +export interface BatchProgressEvent { + type: "batch_progress"; + batch_id: number; + active_files: FileUploadState[]; // Max 8 items + batch_files_completed: number; + batch_files_total: number; + job_files_completed: number; + job_files_total: number; + job_progress_percent: number; +} + +export interface BatchCompletedEvent { + type: "batch_completed"; + batch_id: number; + files_uploaded: number; + files_failed: number; +} + +export interface JobCompletedEvent { + type: "job_completed"; + job_id: string; + status: "completed" | "failed" | "cancelled"; + total_files: number; + files_uploaded: number; + files_failed: number; + duration_seconds: number; +} + +export interface PaginatedResults { + job_id: string; + files: FileUploadState[]; + pagination: { + page: number; + per_page: number; + total_files: number; + total_pages: number; + has_next: boolean; + has_prev: boolean; + }; + job_metadata: { + job_id: string; + status: string; + total_files: number; + files_uploaded: number; + files_failed: number; + total_bytes: number; + }; +} + +// ── Scan types ── + +export interface ScannedFileInfo { + path: string; + filename: string; + size: number; + mtime: number; + relative_path: string; + already_uploaded?: boolean; +} + +export interface ScannedFolder { + relative_path: string; + files: ScannedFileInfo[]; + total_files: number; + already_uploaded: number; + all_uploaded: boolean; + error: string | null; +} + +export interface ScanStartedEvent { + type: "scan_started"; + folders_total: number; + root_folder: string; +} + +export interface ScanFolderCompleteEvent { + type: "scan_folder_complete"; + folder: ScannedFolder; + folders_scanned: number; + folders_total: number; + running_totals: { + total_files_found: number; + total_already_uploaded: number; + total_size: number; + }; +} + +export interface ScanCompleteEvent { + type: "scan_complete"; + status: string; + folders_scanned: number; + folders_total: number; + total_files_found: number; + total_already_uploaded: number; + total_size: number; + error?: string; +} + +export type ScanEvent = ScanStartedEvent | ScanFolderCompleteEvent | ScanCompleteEvent; + +// ── Bulk analyze response ── + +export interface BulkAnalyzeResponse { + job_id: string; + status: string; + total_files: number; + pre_filter_stats: PreFilterStats; + auto_upload: boolean; +} + +// ── File browser types ── + +export interface BrowseResponse { + success: boolean; + current_path: string; + parent_path: string | null; + breadcrumbs: BreadcrumbItem[]; + quick_links: QuickLink[]; + folders: LocalFolder[]; + files: LocalFile[]; + mcap_count: number; + total_mcap_count: number; + already_uploaded: number; +} + +export interface BreadcrumbItem { + name: string; + path: string; +} + +export interface QuickLink { + name: string; + path: string; +} + +export interface LocalFolder { + name: string; + path: string; + mcap_count: number; + already_uploaded: number; +} + +export interface LocalFile { + name: string; + path: string; + size: number; + mtime: number; + already_uploaded?: boolean; +} + +// ── S3 browser types ── + +export interface S3ListResponse { + success: boolean; + folders: S3Folder[]; + files: S3File[]; + breadcrumbs: S3Breadcrumb[]; + prefix?: string; + error?: string; +} + +export interface S3Folder { + name: string; + prefix: string; +} + +export interface S3File { + name: string; + key: string; + size: number; + last_modified: string; +} + +export interface S3Breadcrumb { + name: string; + prefix: string; +} + +// ── Settings types ── + +export type ValueSourceType = "builtin" | "default_file" | "settings_file" | "env"; + +export interface ValueSource { + source: ValueSourceType; + /** Absolute path to the file (present for default_file and settings_file). */ + path?: string; + /** Environment variable name (present for env). */ + env_var?: string; +} + +export interface AppSettings { + aws_profile: string; + aws_region: string; + s3_bucket: string; + default_upload_folder: string; + display_name: string; + log_directory: string; + batch_processing?: BatchProcessingSettings; + /** Provenance metadata returned by the API — not sent on PUT. */ + value_sources?: Record; +} + +export interface VersionInfo { + version: string; + commit: string; + branch: string; + dirty: boolean; +} + +export interface UpdateCheckResult { + updates_available: boolean; + current_commit: string; + remote_commit: string; + commits_behind: number; +} + +export interface UpdateResult { + success: boolean; + results: { + git_pull: { success: boolean; output: string }; + pip_install: { success: boolean; output: string }; + modaq_toolkit: { success: boolean; output: string }; + }; + message: string; +} + +export interface ConnectionTestResult { + success: boolean; + message?: string; + error?: string; +} + +export interface CacheStats { + success: boolean; + stats: { + total_entries: number; + exists_count: number; + not_exists_count: number; + bucket: string; + }; +} + +export interface CacheSyncResult { + success: boolean; + bucket?: string; + files_in_s3?: number; + files_updated?: number; + files_removed?: number; + message?: string; + error?: string; +} + +// ── Log types ── + +export interface LogEntry { + id: number; + timestamp: string; + level: string; + category: string; + event: string; + message: string; + metadata: Record; +} + +export interface LogEntriesResponse { + entries: LogEntry[]; + total: number; + offset: number; + limit: number; +} + +export interface LogStats { + total_entries: number; + today_entries: number; + total_size_bytes: number; + level_counts: Record; + category_counts: Record; + date_range: { earliest: string | null; latest: string | null }; + file_count: number; + csv_count: number; + csv_files: CsvFileInfo[]; +} + +export interface CsvFileInfo { + path: string; + filename: string; + date: string; + size: number; +} + +export interface CsvPreviewResponse { + columns: string[]; + rows: Record[]; +} + +// ── Upload stats types ── + +export interface UploadSessionFile { + filename: string; + file_size_formatted: string; + status: string; + upload_speed_mbps: string; + s3_path: string; + error_message: string; +} + +export interface UploadSession { + csv_path: string; + date: string; + time: string; + total_files: number; + completed: number; + failed: number; + skipped: number; + total_bytes: number; + total_bytes_formatted: string; + total_duration_seconds: number; + avg_speed_mbps: number; + files: UploadSessionFile[]; +} + +export interface UploadStatsResponse { + total_files_uploaded: number; + total_files_failed: number; + total_files_skipped: number; + total_bytes_uploaded: number; + total_bytes_uploaded_formatted: string; + total_sessions: number; + sessions: UploadSession[]; +} diff --git a/frontend/src/types/delete.ts b/frontend/src/types/delete.ts new file mode 100644 index 0000000..7f1e21d --- /dev/null +++ b/frontend/src/types/delete.ts @@ -0,0 +1,85 @@ +/** TypeScript types for the Local Delete feature. */ + +// ── File status ── + +export type DeleteFileStatus = + | "pending" + | "scanning" + | "verifying" + | "verified" + | "deleting" + | "deleted" + | "mismatch" + | "failed" + | "cancelled"; + +// ── Scan response ── + +export interface DeleteScanFile { + filename: string; + local_path: string; + file_size: number; + s3_path: string; + s3_bucket: string; + writable: boolean; + status: DeleteFileStatus; + local_md5: string; + s3_etag: string; + s3_size: number; + verification: string; + error_message: string; +} + +export interface DeleteScanResponse { + success: boolean; + job_id: string; + folder_path: string; + files: DeleteScanFile[]; + total_files: number; + total_size: number; + permission_warning: boolean; +} + +// ── SSE event types ── + +export interface DeleteProgressEvent { + type: "delete_progress"; + job_id: string; + status: string; + total_files: number; + files_processed: number; + status_counts: Record; + total_deleted_size: number; + cancelled: boolean; +} + +export interface DeleteCompleteEvent { + type: "delete_complete"; + job_id: string; + status: string; + total_files: number; + files: DeleteScanFile[]; + status_counts: Record; + total_deleted_size: number; + created_at: string; + started_at: string | null; + completed_at: string | null; + cancelled: boolean; +} + +export type DeleteSSEEvent = DeleteProgressEvent | DeleteCompleteEvent; + +// ── Job result (full response) ── + +export interface DeleteJobResult { + job_id: string; + status: string; + total_files: number; + files: DeleteScanFile[]; + status_counts: Record; + total_deleted_size: number; + created_at: string; + started_at: string | null; + completed_at: string | null; + cancelled: boolean; +} diff --git a/frontend/src/types/upload.ts b/frontend/src/types/upload.ts new file mode 100644 index 0000000..d2c8fad --- /dev/null +++ b/frontend/src/types/upload.ts @@ -0,0 +1,49 @@ +/** + * Types for the unified upload table that persists from Step 2 through Step 4. + */ + +export type UnifiedStatus = + | "new" + | "already_uploaded" + | "queued" + | "in_progress" + | "completed" + | "skipped" + | "failed"; +export type UploadPhase = "review" | "uploading" | "summary"; +export type SortKey = "filename" | "folder" | "size" | "mtime" | "status"; +export type SortDir = "asc" | "desc"; +export type StatusFilter = + | "all" + | "new" + | "uploaded" + | "queued" + | "in_progress" + | "completed" + | "skipped" + | "failed"; + +export interface UnifiedFileRow { + /** Stable key — the local filesystem path. */ + path: string; + filename: string; + size: number; + folder: string; + mtime: number; + /** Whether the file was already on S3 when scanned. */ + alreadyUploaded: boolean; + /** Upload lifecycle status. */ + status: UnifiedStatus; + /** 0–100 during upload. */ + progressPercent: number; + /** Populated after successful upload. */ + s3Path: string; + /** Upload duration in seconds. */ + duration: number | null; + /** Upload speed in Mbps. */ + speed: number | null; + /** Error message if failed. */ + error: string; + /** Position in the frozen sort array (set during upload phase). */ + _frozenIndex?: number; +} diff --git a/frontend/src/utils/csv.ts b/frontend/src/utils/csv.ts new file mode 100644 index 0000000..9a7948f --- /dev/null +++ b/frontend/src/utils/csv.ts @@ -0,0 +1,40 @@ +/** CSV export utility for the upload summary. */ + +import type { UnifiedFileRow } from "../types/upload.ts"; + +/** + * Generate and download a CSV file from the unified file rows. + */ +export function downloadUploadCsv(files: UnifiedFileRow[], jobId: string): void { + const header = "Filename,Folder,Size,Status,S3 Path,Duration (s),Speed (Mbps),Error\n"; + const rows = files + .map((f) => { + const cols = [ + csvEscape(f.filename), + csvEscape(f.folder), + f.size.toString(), + f.status, + csvEscape(f.s3Path), + f.duration?.toFixed(2) ?? "", + f.speed?.toFixed(2) ?? "", + csvEscape(f.error), + ]; + return cols.join(","); + }) + .join("\n"); + + const blob = new Blob([header + rows], { type: "text/csv" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `upload-summary-${jobId.slice(0, 8)}.csv`; + a.click(); + URL.revokeObjectURL(url); +} + +function csvEscape(value: string): string { + if (value.includes(",") || value.includes('"') || value.includes("\n")) { + return `"${value.replace(/"/g, '""')}"`; + } + return value; +} diff --git a/frontend/src/utils/format/bytes.ts b/frontend/src/utils/format/bytes.ts new file mode 100644 index 0000000..f6a1799 --- /dev/null +++ b/frontend/src/utils/format/bytes.ts @@ -0,0 +1,10 @@ +/** + * Format bytes into a human-readable string (e.g., "1.23 GB"). + */ +export function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + const value = bytes / 1024 ** i; + return `${value.toFixed(i === 0 ? 0 : 2)} ${units[i]}`; +} diff --git a/frontend/src/utils/format/date.ts b/frontend/src/utils/format/date.ts new file mode 100644 index 0000000..c7c0635 --- /dev/null +++ b/frontend/src/utils/format/date.ts @@ -0,0 +1,25 @@ +/** + * Format a Unix timestamp (seconds) to a locale date string (date only). + */ +export function formatDate(mtime: number): string { + return new Date(mtime * 1000).toLocaleDateString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + }); +} + +/** + * Format a Unix timestamp (seconds) to a locale datetime string (date + time). + * Returns "-" for null/undefined values. + */ +export function formatDateTime(epochSeconds: number | null | undefined): string { + if (!epochSeconds) return "-"; + return new Date(epochSeconds * 1000).toLocaleString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} diff --git a/frontend/src/utils/format/speed.ts b/frontend/src/utils/format/speed.ts new file mode 100644 index 0000000..ea77e3e --- /dev/null +++ b/frontend/src/utils/format/speed.ts @@ -0,0 +1,7 @@ +/** + * Format a speed in Mbps. + */ +export function formatSpeed(mbps: number | null): string { + if (mbps == null) return "--"; + return `${mbps.toFixed(2)} Mbps`; +} diff --git a/frontend/src/utils/format/time.ts b/frontend/src/utils/format/time.ts new file mode 100644 index 0000000..2faea65 --- /dev/null +++ b/frontend/src/utils/format/time.ts @@ -0,0 +1,32 @@ +/** + * Format seconds into a human-readable ETA string. + * Returns "--" for null/invalid values. + */ +export function formatEta(seconds: number | null | undefined): string { + if (seconds == null || seconds <= 0) return "--"; + if (seconds < 60) return `${Math.round(seconds)}s`; + if (seconds < 3600) { + const m = Math.floor(seconds / 60); + const s = Math.round(seconds % 60); + return `${m}m ${s}s`; + } + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + return `${h}h ${m}m`; +} + +/** + * Format a duration in seconds to "Xms", "Xs", "Xm Ys", or "Xh Ym". + * Includes millisecond precision for durations less than 1 second. + */ +export function formatDuration(seconds: number | null): string { + if (seconds == null || seconds <= 0) return "--"; + if (seconds < 1) return `${Math.round(seconds * 1000)}ms`; + if (seconds < 60) return `${seconds.toFixed(1)}s`; + const m = Math.floor(seconds / 60); + const s = Math.round(seconds % 60); + if (m < 60) return `${m}m ${s}s`; + const h = Math.floor(m / 60); + const rm = m % 60; + return `${h}h ${rm}m`; +} diff --git a/frontend/src/utils/icons.tsx b/frontend/src/utils/icons.tsx new file mode 100644 index 0000000..4a5a2c2 --- /dev/null +++ b/frontend/src/utils/icons.tsx @@ -0,0 +1,69 @@ +/** + * Icon exports using lucide-react. + * Centralized icon library for consistent styling across the app. + */ + +import { + File, + Folder, + Check, + ChevronRight, + X, + AlertTriangle, + AlertCircle, + Info, + CheckCircle, + ChevronUp, + ChevronDown, + Shield, + Upload, + Download, + Search, + Filter, + MoreVertical, + Trash2, + Settings, + RefreshCw, + XCircle, + Loader2, + Plus, + Minus, + Cloud, + Circle, + Lock, + Power, + type LucideProps, +} from "lucide-react"; + +// Re-export with consistent names +export const FileIcon = File; +export const FolderIcon = Folder; +export const CheckIcon = Check; +export const ChevronRightIcon = ChevronRight; +export const XIcon = X; +export const WarningIcon = AlertTriangle; +export const ErrorIcon = AlertCircle; +export const InfoIcon = Info; +export const SuccessIcon = CheckCircle; +export const ChevronUpIcon = ChevronUp; +export const ChevronDownIcon = ChevronDown; +export const ShieldIcon = Shield; +export const UploadIcon = Upload; +export const DownloadIcon = Download; +export const SearchIcon = Search; +export const FilterIcon = Filter; +export const MoreIcon = MoreVertical; +export const TrashIcon = Trash2; +export const SettingsIcon = Settings; +export const RefreshIcon = RefreshCw; +export const XCircleIcon = XCircle; +export const SpinnerIcon = Loader2; +export const PlusIcon = Plus; +export const MinusIcon = Minus; +export const CloudIcon = Cloud; +export const CircleIcon = Circle; +export const LockIcon = Lock; +export const PowerIcon = Power; + +// Export type for icon props +export type { LucideProps as IconProps }; diff --git a/frontend/tests/components/S3Browser.test.tsx b/frontend/tests/components/S3Browser.test.tsx new file mode 100644 index 0000000..497ce3b --- /dev/null +++ b/frontend/tests/components/S3Browser.test.tsx @@ -0,0 +1,117 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import S3Browser from "../../src/components/files/S3Browser.tsx"; + +// Mock the API client +vi.mock("../../src/api/client.ts", () => ({ + apiGet: vi.fn(), +})); + +import { apiGet } from "../../src/api/client.ts"; + +const mockApiGet = vi.mocked(apiGet); + +const MOCK_LIST_RESPONSE = { + success: true, + folders: [ + { name: "year=2024", prefix: "year=2024/" }, + { name: "year=2025", prefix: "year=2025/" }, + ], + files: [ + { name: "test.mcap", key: "test.mcap", size: 1024, last_modified: "2024-01-15T10:30:00Z" }, + ], + breadcrumbs: [], +}; + +const MOCK_SUBFOLDER_RESPONSE = { + success: true, + folders: [ + { name: "month=01", prefix: "year=2024/month=01/" }, + { name: "month=02", prefix: "year=2024/month=02/" }, + ], + files: [], + breadcrumbs: [{ name: "year=2024", prefix: "year=2024/" }], +}; + +describe("S3Browser", () => { + beforeEach(() => { + mockApiGet.mockReset(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("shows loading spinner initially", () => { + mockApiGet.mockReturnValue(new Promise(() => {})); // Never resolves + render(); + expect(screen.getByText("Loading files...")).toBeInTheDocument(); + }); + + it("renders bucket name and region", async () => { + mockApiGet.mockResolvedValue(MOCK_LIST_RESPONSE); + render(); + + await waitFor(() => { + expect(screen.getAllByText("my-bucket").length).toBeGreaterThanOrEqual(1); + }); + expect(screen.getByText("(us-west-2)")).toBeInTheDocument(); + }); + + it("renders folders and files from the API response", async () => { + mockApiGet.mockResolvedValue(MOCK_LIST_RESPONSE); + render(); + + await waitFor(() => { + expect(screen.getByText("year=2024")).toBeInTheDocument(); + }); + expect(screen.getByText("year=2025")).toBeInTheDocument(); + expect(screen.getByText("test.mcap")).toBeInTheDocument(); + expect(screen.getByText("1.0 KB")).toBeInTheDocument(); + }); + + it("navigates into a folder when clicked", async () => { + const user = userEvent.setup(); + mockApiGet + .mockResolvedValueOnce(MOCK_LIST_RESPONSE) // Initial load + .mockResolvedValueOnce(MOCK_SUBFOLDER_RESPONSE); // After click + + render(); + + await waitFor(() => { + expect(screen.getByText("year=2024")).toBeInTheDocument(); + }); + + await user.click(screen.getByText("year=2024")); + + await waitFor(() => { + expect(screen.getByText("month=01")).toBeInTheDocument(); + }); + expect(screen.getByText("month=02")).toBeInTheDocument(); + }); + + it("shows an error message and retry button on API failure", async () => { + mockApiGet.mockRejectedValue(new Error("Network error")); + render(); + + await waitFor(() => { + expect(screen.getByText("Network error")).toBeInTheDocument(); + }); + expect(screen.getByText("Retry")).toBeInTheDocument(); + }); + + it("shows empty state when no files or folders", async () => { + mockApiGet.mockResolvedValue({ + success: true, + folders: [], + files: [], + breadcrumbs: [], + }); + render(); + + await waitFor(() => { + expect(screen.getByText("No files or folders found at this location.")).toBeInTheDocument(); + }); + }); +}); diff --git a/frontend/tests/components/Stepper.test.tsx b/frontend/tests/components/Stepper.test.tsx new file mode 100644 index 0000000..c554c35 --- /dev/null +++ b/frontend/tests/components/Stepper.test.tsx @@ -0,0 +1,135 @@ +/** + * Tests for the Stepper component. + */ + +import { render, screen, cleanup } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, it, expect, vi, afterEach } from "vitest"; + +import Stepper from "../../src/components/upload/Stepper.tsx"; + +afterEach(() => { + cleanup(); +}); + +describe("Stepper", () => { + it("renders all four steps", () => { + render(); + + expect(screen.getByTestId("step-1")).toBeInTheDocument(); + expect(screen.getByTestId("step-2")).toBeInTheDocument(); + expect(screen.getByTestId("step-3")).toBeInTheDocument(); + expect(screen.getByTestId("step-4")).toBeInTheDocument(); + }); + + it("displays step labels", () => { + render(); + + expect(screen.getByText("Select")).toBeInTheDocument(); + expect(screen.getByText("Review")).toBeInTheDocument(); + expect(screen.getByText("Upload")).toBeInTheDocument(); + expect(screen.getByText("Complete")).toBeInTheDocument(); + }); + + it("marks the current step as active", () => { + render(); + + const step2 = screen.getByTestId("step-2"); + expect(step2).toHaveAttribute("aria-current", "step"); + }); + + it("shows checkmark icon for completed steps", () => { + render(); + + // Steps 1 and 2 should have checkmarks + const checkmarks = screen.getAllByTestId("checkmark-icon"); + expect(checkmarks).toHaveLength(2); + }); + + it("shows numbers for active and future steps", () => { + render(); + + // Step 2 (active) should show "2" + expect(screen.getByTestId("step-2")).toHaveTextContent("2"); + // Step 3 (future) should show "3" + expect(screen.getByTestId("step-3")).toHaveTextContent("3"); + // Step 4 (future) should show "4" + expect(screen.getByTestId("step-4")).toHaveTextContent("4"); + }); + + it("allows clicking completed steps 1 and 2 when not uploading", async () => { + const user = userEvent.setup(); + const onStepClick = vi.fn(); + + render( + , + ); + + // Step 1 (completed) should be clickable + await user.click(screen.getByTestId("step-1")); + expect(onStepClick).toHaveBeenCalledWith(1); + + // Step 2 (completed) should be clickable + await user.click(screen.getByTestId("step-2")); + expect(onStepClick).toHaveBeenCalledWith(2); + }); + + it("disables clicking completed steps when uploading", async () => { + const user = userEvent.setup(); + const onStepClick = vi.fn(); + + render( + , + ); + + // Step 1 should be disabled + const step1 = screen.getByTestId("step-1"); + expect(step1).toBeDisabled(); + + await user.click(step1); + expect(onStepClick).not.toHaveBeenCalled(); + }); + + it("does not allow clicking future steps", async () => { + const user = userEvent.setup(); + const onStepClick = vi.fn(); + + render( + , + ); + + // Step 3 (future) should be disabled + const step3 = screen.getByTestId("step-3"); + expect(step3).toBeDisabled(); + + await user.click(step3); + expect(onStepClick).not.toHaveBeenCalled(); + }); + + it("does not allow clicking the active step", async () => { + const user = userEvent.setup(); + const onStepClick = vi.fn(); + + render( + , + ); + + const step2 = screen.getByTestId("step-2"); + expect(step2).toBeDisabled(); + + await user.click(step2); + expect(onStepClick).not.toHaveBeenCalled(); + }); + + it("step 4 renders at correct position", () => { + render(); + + // All four previous steps should have checkmarks + const checkmarks = screen.getAllByTestId("checkmark-icon"); + expect(checkmarks).toHaveLength(3); // Steps 1, 2, 3 + + // Step 4 is active + const step4 = screen.getByTestId("step-4"); + expect(step4).toHaveAttribute("aria-current", "step"); + }); +}); diff --git a/frontend/tests/components/common.test.tsx b/frontend/tests/components/common.test.tsx new file mode 100644 index 0000000..eac956a --- /dev/null +++ b/frontend/tests/components/common.test.tsx @@ -0,0 +1,225 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it, vi } from "vitest"; +import Modal from "../../src/components/common/Modal.tsx"; +import ProgressBar from "../../src/components/common/ProgressBar.tsx"; +import StatCard from "../../src/components/common/StatCard.tsx"; +import Breadcrumb from "../../src/components/common/Breadcrumb.tsx"; +import Spinner from "../../src/components/common/Spinner.tsx"; +import SortableHeader from "../../src/components/common/SortableHeader.tsx"; + +describe("Modal", () => { + it("renders nothing when isOpen is false", () => { + render( + {}} title="Test"> +

body

+
, + ); + expect(screen.queryByText("Test")).not.toBeInTheDocument(); + }); + + it("renders title, body, and footer when open", () => { + render( + {}} title="My Modal" footer={}> +

Hello world

+
, + ); + expect(screen.getByText("My Modal")).toBeInTheDocument(); + expect(screen.getByText("Hello world")).toBeInTheDocument(); + expect(screen.getByText("OK")).toBeInTheDocument(); + }); + + it("calls onClose when Escape is pressed", async () => { + const user = userEvent.setup(); + const handleClose = vi.fn(); + render( + +

content

+
, + ); + + await user.keyboard("{Escape}"); + expect(handleClose).toHaveBeenCalledOnce(); + }); + + it("calls onClose when backdrop is clicked", async () => { + const user = userEvent.setup(); + const handleClose = vi.fn(); + render( + +

content

+
, + ); + + await user.click(screen.getByTestId("modal-backdrop")); + expect(handleClose).toHaveBeenCalledOnce(); + }); + + it("does not call onClose when modal content is clicked", async () => { + const user = userEvent.setup(); + const handleClose = vi.fn(); + render( + +

content

+
, + ); + + await user.click(screen.getByText("content")); + expect(handleClose).not.toHaveBeenCalled(); + }); + + it("calls onClose when close button (X) is clicked", async () => { + const user = userEvent.setup(); + const handleClose = vi.fn(); + render( + +

content

+
, + ); + + await user.click(screen.getByLabelText("Close modal")); + expect(handleClose).toHaveBeenCalledOnce(); + }); +}); + +describe("StatCard", () => { + it("renders value and label", () => { + render(); + expect(screen.getByText("42")).toBeInTheDocument(); + expect(screen.getByText("Total Files")).toBeInTheDocument(); + }); + + it("renders string values", () => { + render(); + expect(screen.getByText("1.5 GB")).toBeInTheDocument(); + expect(screen.getByText("Total Size")).toBeInTheDocument(); + }); + + it("applies default text color to value", () => { + render(); + const value = screen.getByText("0"); + expect(value.className).toContain("text-nlr-blue"); + }); + + it("applies custom text color to value", () => { + render(); + const value = screen.getByText("0"); + expect(value.className).toContain("text-red-500"); + }); +}); + +describe("ProgressBar", () => { + it("shows correct width style", () => { + render(); + const bar = screen.getByRole("progressbar"); + expect(bar).toHaveStyle({ width: "65%" }); + }); + + it("shows percentage text when label is provided", () => { + render(); + expect(screen.getByText("Uploading")).toBeInTheDocument(); + expect(screen.getByText("42%")).toBeInTheDocument(); + }); + + it("clamps percent to 0-100 range", () => { + render(); + const bar = screen.getByRole("progressbar"); + expect(bar).toHaveStyle({ width: "100%" }); + expect(bar.getAttribute("aria-valuenow")).toBe("100"); + }); + + it("clamps negative percent to 0", () => { + render(); + const bar = screen.getByRole("progressbar"); + expect(bar).toHaveStyle({ width: "0%" }); + expect(bar.getAttribute("aria-valuenow")).toBe("0"); + }); + + it("applies default color", () => { + render(); + const bar = screen.getByRole("progressbar"); + expect(bar.className).toContain("bg-nlr-blue"); + }); +}); + +describe("Breadcrumb", () => { + it("renders all items", () => { + render( + {} }, + { label: "Files", onClick: () => {} }, + { label: "Current" }, + ]} + />, + ); + expect(screen.getByText("Home")).toBeInTheDocument(); + expect(screen.getByText("Files")).toBeInTheDocument(); + expect(screen.getByText("Current")).toBeInTheDocument(); + }); + + it("makes the last item non-clickable", () => { + render( + {} }, + { label: "Current" }, + ]} + />, + ); + // Last item should be a span, not a button + expect(screen.getByText("Current").tagName).toBe("SPAN"); + // First item should be a button + expect(screen.getByText("Home").tagName).toBe("BUTTON"); + }); + + it("calls onClick when a breadcrumb item is clicked", async () => { + const user = userEvent.setup(); + const handleClick = vi.fn(); + render( + , + ); + + await user.click(screen.getByText("Home")); + expect(handleClick).toHaveBeenCalledOnce(); + }); +}); + +describe("Spinner", () => { + it("renders without a message", () => { + render(); + expect(screen.getByTestId("spinner")).toBeInTheDocument(); + }); + + it("renders with a message", () => { + render(); + expect(screen.getByText("Loading data...")).toBeInTheDocument(); + }); +}); + +describe("SortableHeader", () => { + it("renders label and calls onSort when clicked", async () => { + const user = userEvent.setup(); + const handleSort = vi.fn(); + const { container } = render( + + + + + + +
, + ); + expect(screen.getByText("Name")).toBeInTheDocument(); + + const th = container.querySelector("th"); + expect(th).not.toBeNull(); + await user.click(th!); + expect(handleSort).toHaveBeenCalledOnce(); + }); +}); diff --git a/frontend/tests/components/layout.test.tsx b/frontend/tests/components/layout.test.tsx new file mode 100644 index 0000000..cdafec8 --- /dev/null +++ b/frontend/tests/components/layout.test.tsx @@ -0,0 +1,112 @@ +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MemoryRouter } from "react-router-dom"; +import { describe, expect, it, vi, beforeEach } from "vitest"; +import Layout from "../../src/components/layout/Layout.tsx"; +import NavBar from "../../src/components/layout/NavBar.tsx"; +import { useAppStore } from "../../src/stores/appStore.ts"; + +// Mock react-router-dom's Outlet so Layout renders without child routes +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual("react-router-dom"); + return { + ...actual, + Outlet: () =>
page content
, + }; +}); + +function renderWithRouter(ui: React.ReactElement) { + return render({ui}); +} + +describe("Layout", () => { + beforeEach(() => { + useAppStore.setState({ + settings: null, + version: { version: "1.2.3", commit: "abc1234def5678", branch: "main", dirty: false }, + notifications: [], + }); + }); + + it("renders Header, NavBar, Footer, and Outlet", () => { + renderWithRouter(); + + // Header - title defaults to MODAQ Upload when settings are null + expect(screen.getByText("MODAQ Upload")).toBeInTheDocument(); + + // NavBar links + expect(screen.getByText("Upload")).toBeInTheDocument(); + expect(screen.getByText("Browse Uploaded Files")).toBeInTheDocument(); + expect(screen.getByText("History")).toBeInTheDocument(); + expect(screen.getByText("Settings")).toBeInTheDocument(); + + // Footer + expect(screen.getByText("National Laboratory of the Rockies")).toBeInTheDocument(); + + // Outlet (mocked) + expect(screen.getByTestId("outlet")).toBeInTheDocument(); + }); + + it("opens AboutModal when version badge is clicked", async () => { + const user = userEvent.setup(); + renderWithRouter(); + + const badge = screen.getByText("v1.2.3"); + await user.click(badge); + + // Modal should now be open - check for the modal backdrop and version info + expect(screen.getByTestId("modal-backdrop")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "About" })).toBeInTheDocument(); + expect(screen.getByText("1.2.3")).toBeInTheDocument(); + expect(screen.getByText("abc1234")).toBeInTheDocument(); + }); + + it("closes AboutModal when close button is clicked", async () => { + const user = userEvent.setup(); + renderWithRouter(); + + await user.click(screen.getByText("v1.2.3")); + expect(screen.getByTestId("modal-backdrop")).toBeInTheDocument(); + + // Click the footer Close button + const closeButtons = screen.getAllByText("Close"); + await user.click(closeButtons[0]); + expect(screen.queryByTestId("modal-backdrop")).not.toBeInTheDocument(); + }); +}); + +describe("NavBar", () => { + beforeEach(() => { + useAppStore.setState({ + version: { version: "2.0.0", commit: "deadbeef", branch: "develop", dirty: false }, + }); + }); + + it("shows all 4 nav links", () => { + renderWithRouter( {}} />); + + expect(screen.getByText("Upload")).toBeInTheDocument(); + expect(screen.getByText("Browse Uploaded Files")).toBeInTheDocument(); + expect(screen.getByText("History")).toBeInTheDocument(); + expect(screen.getByText("Settings")).toBeInTheDocument(); + }); + + it("shows version badge and calls onAboutClick when clicked", async () => { + const user = userEvent.setup(); + const handleAboutClick = vi.fn(); + renderWithRouter(); + + const badge = screen.getByText("v2.0.0"); + expect(badge).toBeInTheDocument(); + + await user.click(badge); + expect(handleAboutClick).toHaveBeenCalledOnce(); + }); + + it("hides version badge when version is not loaded", () => { + useAppStore.setState({ version: null }); + renderWithRouter( {}} />); + + expect(screen.queryByText(/^v/)).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/tests/components/settings.test.tsx b/frontend/tests/components/settings.test.tsx new file mode 100644 index 0000000..62330c5 --- /dev/null +++ b/frontend/tests/components/settings.test.tsx @@ -0,0 +1,82 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import SettingsForm from "../../src/components/settings/SettingsForm.tsx"; +import { useAppStore } from "../../src/stores/appStore.ts"; + +// Mock the API client +vi.mock("../../src/api/client.ts", () => ({ + apiGet: vi.fn(), + apiPost: vi.fn(), + apiPut: vi.fn(), +})); + +// Import mocked functions for control +import { apiGet } from "../../src/api/client.ts"; + +const mockApiGet = vi.mocked(apiGet); + +describe("SettingsForm", () => { + beforeEach(() => { + vi.clearAllMocks(); + + // Set up store with settings + useAppStore.setState({ + settings: { + aws_profile: "default", + aws_region: "us-west-2", + s3_bucket: "test-bucket", + default_upload_folder: "/data/mcap", + display_name: "Test App", + log_directory: "logs", + }, + settingsLoading: false, + }); + + // Mock profiles endpoint + mockApiGet.mockResolvedValue({ profiles: ["default", "production"] }); + }); + + it("renders all form fields", async () => { + render(); + + await waitFor(() => { + expect(screen.getByLabelText("AWS Profile")).toBeInTheDocument(); + }); + + expect(screen.getByLabelText("AWS Region")).toBeInTheDocument(); + expect(screen.getByLabelText("S3 Bucket")).toBeInTheDocument(); + expect(screen.getByLabelText("Default Upload Folder")).toBeInTheDocument(); + expect(screen.getByLabelText("Display Name")).toBeInTheDocument(); + expect(screen.getByLabelText("Log Directory")).toBeInTheDocument(); + }); + + it("shows Test Connection and Save Settings buttons", async () => { + render(); + + await waitFor(() => { + expect(screen.getByText("Test Connection")).toBeInTheDocument(); + }); + + expect(screen.getByText("Save Settings")).toBeInTheDocument(); + }); + + it("populates form fields from settings", async () => { + render(); + + await waitFor(() => { + expect(screen.getByLabelText("S3 Bucket")).toHaveValue("test-bucket"); + }); + + expect(screen.getByLabelText("Default Upload Folder")).toHaveValue("/data/mcap"); + expect(screen.getByLabelText("Display Name")).toHaveValue("Test App"); + expect(screen.getByLabelText("Log Directory")).toHaveValue("logs"); + }); + + it("fetches profiles on mount", async () => { + render(); + + await waitFor(() => { + expect(mockApiGet).toHaveBeenCalledWith("/api/settings/profiles"); + }); + }); +}); diff --git a/frontend/tests/hooks/useDebounce.test.ts b/frontend/tests/hooks/useDebounce.test.ts new file mode 100644 index 0000000..ac9ee75 --- /dev/null +++ b/frontend/tests/hooks/useDebounce.test.ts @@ -0,0 +1,91 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useDebounce } from "../../src/hooks/useDebounce.ts"; + +describe("useDebounce", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns the initial value immediately", () => { + const { result } = renderHook(() => useDebounce("hello", 300)); + expect(result.current).toBe("hello"); + }); + + it("does not update the debounced value before the delay", () => { + const { result, rerender } = renderHook(({ value }) => useDebounce(value, 300), { + initialProps: { value: "hello" }, + }); + + rerender({ value: "world" }); + + act(() => { + vi.advanceTimersByTime(100); + }); + + expect(result.current).toBe("hello"); + }); + + it("updates the debounced value after the delay", () => { + const { result, rerender } = renderHook(({ value }) => useDebounce(value, 300), { + initialProps: { value: "hello" }, + }); + + rerender({ value: "world" }); + + act(() => { + vi.advanceTimersByTime(300); + }); + + expect(result.current).toBe("world"); + }); + + it("uses the default delay of 300ms", () => { + const { result, rerender } = renderHook(({ value }) => useDebounce(value), { + initialProps: { value: "a" }, + }); + + rerender({ value: "b" }); + + act(() => { + vi.advanceTimersByTime(299); + }); + expect(result.current).toBe("a"); + + act(() => { + vi.advanceTimersByTime(1); + }); + expect(result.current).toBe("b"); + }); + + it("resets the timer on rapid changes and only takes the last value", () => { + const { result, rerender } = renderHook(({ value }) => useDebounce(value, 200), { + initialProps: { value: "a" }, + }); + + rerender({ value: "b" }); + act(() => { + vi.advanceTimersByTime(100); + }); + + rerender({ value: "c" }); + act(() => { + vi.advanceTimersByTime(100); + }); + + // Still the initial value since each change reset the timer + expect(result.current).toBe("a"); + + rerender({ value: "d" }); + act(() => { + vi.advanceTimersByTime(200); + }); + + // Now it should be the latest value + expect(result.current).toBe("d"); + }); +}); diff --git a/frontend/tests/hooks/usePagination.test.ts b/frontend/tests/hooks/usePagination.test.ts new file mode 100644 index 0000000..c14eb94 --- /dev/null +++ b/frontend/tests/hooks/usePagination.test.ts @@ -0,0 +1,121 @@ +import { act, renderHook } from "@testing-library/react"; +import { describe, expect, it } from "vitest"; +import { usePagination } from "../../src/hooks/usePagination.ts"; + +describe("usePagination", () => { + it("starts at page 1 with default limit of 100", () => { + const { result } = renderHook(() => usePagination()); + expect(result.current.currentPage).toBe(1); + expect(result.current.limit).toBe(100); + expect(result.current.offset).toBe(0); + expect(result.current.totalPages).toBe(1); + }); + + it("respects custom page size", () => { + const { result } = renderHook(() => usePagination(25)); + expect(result.current.limit).toBe(25); + }); + + it("computes totalPages when setTotal is called", () => { + const { result } = renderHook(() => usePagination(50)); + act(() => { + result.current.setTotal(120); + }); + expect(result.current.totalPages).toBe(3); // ceil(120/50) + }); + + it("navigates with nextPage and prevPage", () => { + const { result } = renderHook(() => usePagination(10)); + act(() => { + result.current.setTotal(50); + }); + expect(result.current.totalPages).toBe(5); + + act(() => { + result.current.nextPage(); + }); + expect(result.current.currentPage).toBe(2); + expect(result.current.offset).toBe(10); + + act(() => { + result.current.nextPage(); + }); + expect(result.current.currentPage).toBe(3); + expect(result.current.offset).toBe(20); + + act(() => { + result.current.prevPage(); + }); + expect(result.current.currentPage).toBe(2); + expect(result.current.offset).toBe(10); + }); + + it("does not go below page 1", () => { + const { result } = renderHook(() => usePagination(10)); + act(() => { + result.current.setTotal(30); + }); + act(() => { + result.current.prevPage(); + }); + expect(result.current.currentPage).toBe(1); + }); + + it("does not go above totalPages", () => { + const { result } = renderHook(() => usePagination(10)); + act(() => { + result.current.setTotal(20); + }); + // totalPages = 2 + act(() => { + result.current.goToPage(5); + }); + expect(result.current.currentPage).toBe(2); + }); + + it("goToPage navigates to the correct page", () => { + const { result } = renderHook(() => usePagination(10)); + act(() => { + result.current.setTotal(100); + }); + act(() => { + result.current.goToPage(7); + }); + expect(result.current.currentPage).toBe(7); + expect(result.current.offset).toBe(60); + }); + + it("clamps current page when total shrinks", () => { + const { result } = renderHook(() => usePagination(10)); + act(() => { + result.current.setTotal(100); + }); + act(() => { + result.current.goToPage(10); + }); + expect(result.current.currentPage).toBe(10); + + act(() => { + result.current.setTotal(30); + }); + // totalPages = 3, so page should clamp to 3 + expect(result.current.currentPage).toBe(3); + }); + + it("reset goes back to page 1", () => { + const { result } = renderHook(() => usePagination(10)); + act(() => { + result.current.setTotal(50); + }); + act(() => { + result.current.goToPage(4); + }); + expect(result.current.currentPage).toBe(4); + + act(() => { + result.current.reset(); + }); + expect(result.current.currentPage).toBe(1); + expect(result.current.offset).toBe(0); + }); +}); diff --git a/frontend/tests/hooks/useSSE.test.ts b/frontend/tests/hooks/useSSE.test.ts new file mode 100644 index 0000000..ffe78b6 --- /dev/null +++ b/frontend/tests/hooks/useSSE.test.ts @@ -0,0 +1,177 @@ +/** + * Tests for the useSSE hook. + */ + +import { renderHook, act, cleanup } from "@testing-library/react"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +import { useSSE } from "../../src/hooks/useSSE.ts"; + +// Mock EventSource +class MockEventSource { + url: string; + onmessage: ((event: MessageEvent) => void) | null = null; + onerror: ((event: Event) => void) | null = null; + readyState = 0; + closed = false; + + constructor(url: string) { + this.url = url; + MockEventSource.instances.push(this); + } + + close() { + this.closed = true; + this.readyState = 2; + } + + // Simulate a message from the server + simulateMessage(data: unknown) { + if (this.onmessage) { + this.onmessage(new MessageEvent("message", { data: JSON.stringify(data) })); + } + } + + // Simulate an error + simulateError() { + if (this.onerror) { + this.onerror(new Event("error")); + } + } + + static instances: MockEventSource[] = []; + static clear() { + MockEventSource.instances = []; + } +} + +// Install mock +beforeEach(() => { + MockEventSource.clear(); + vi.stubGlobal("EventSource", MockEventSource); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("useSSE", () => { + it("does not create an EventSource when url is null", () => { + const onMessage = vi.fn(); + renderHook(() => useSSE({ url: null, onMessage })); + + expect(MockEventSource.instances).toHaveLength(0); + }); + + it("creates an EventSource when url is provided", () => { + const onMessage = vi.fn(); + renderHook(() => + useSSE({ url: "/api/upload/progress/test-id", onMessage }), + ); + + expect(MockEventSource.instances).toHaveLength(1); + expect(MockEventSource.instances[0]!.url).toBe( + "/api/upload/progress/test-id", + ); + }); + + it("calls onMessage with parsed JSON data", () => { + const onMessage = vi.fn(); + renderHook(() => + useSSE({ url: "/api/upload/progress/test-id", onMessage }), + ); + + const es = MockEventSource.instances[0]!; + act(() => { + es.simulateMessage({ type: "scan_started", folders_total: 5 }); + }); + + expect(onMessage).toHaveBeenCalledTimes(1); + expect(onMessage).toHaveBeenCalledWith({ + type: "scan_started", + folders_total: 5, + }); + }); + + it("ignores non-JSON messages without throwing", () => { + const onMessage = vi.fn(); + renderHook(() => + useSSE({ url: "/api/upload/progress/test-id", onMessage }), + ); + + const es = MockEventSource.instances[0]!; + // Send raw non-JSON string + act(() => { + if (es.onmessage) { + es.onmessage(new MessageEvent("message", { data: "not-json" })); + } + }); + + expect(onMessage).not.toHaveBeenCalled(); + }); + + it("calls onError and closes on error", () => { + const onMessage = vi.fn(); + const onError = vi.fn(); + renderHook(() => + useSSE({ url: "/api/upload/progress/test-id", onMessage, onError }), + ); + + const es = MockEventSource.instances[0]!; + act(() => { + es.simulateError(); + }); + + expect(onError).toHaveBeenCalledTimes(1); + expect(es.closed).toBe(true); + }); + + it("closes the EventSource on unmount", () => { + const onMessage = vi.fn(); + const { unmount } = renderHook(() => + useSSE({ url: "/api/upload/progress/test-id", onMessage }), + ); + + const es = MockEventSource.instances[0]!; + expect(es.closed).toBe(false); + + unmount(); + + expect(es.closed).toBe(true); + }); + + it("closes old EventSource and opens new one when url changes", () => { + const onMessage = vi.fn(); + const { rerender } = renderHook( + ({ url }: { url: string | null }) => useSSE({ url, onMessage }), + { initialProps: { url: "/api/upload/progress/id-1" } }, + ); + + expect(MockEventSource.instances).toHaveLength(1); + const first = MockEventSource.instances[0]!; + + rerender({ url: "/api/upload/progress/id-2" }); + + expect(first.closed).toBe(true); + expect(MockEventSource.instances).toHaveLength(2); + expect(MockEventSource.instances[1]!.url).toBe( + "/api/upload/progress/id-2", + ); + }); + + it("closes EventSource when url changes to null", () => { + const onMessage = vi.fn(); + const { rerender } = renderHook( + ({ url }: { url: string | null }) => useSSE({ url, onMessage }), + { initialProps: { url: "/api/upload/progress/id-1" as string | null } }, + ); + + const es = MockEventSource.instances[0]!; + expect(es.closed).toBe(false); + + rerender({ url: null }); + + expect(es.closed).toBe(true); + }); +}); diff --git a/frontend/tests/setup.ts b/frontend/tests/setup.ts new file mode 100644 index 0000000..f149f27 --- /dev/null +++ b/frontend/tests/setup.ts @@ -0,0 +1 @@ +import "@testing-library/jest-dom/vitest"; diff --git a/frontend/tests/utils/formatters.test.ts b/frontend/tests/utils/formatters.test.ts new file mode 100644 index 0000000..5d4b4c8 --- /dev/null +++ b/frontend/tests/utils/formatters.test.ts @@ -0,0 +1,110 @@ +import { describe, it, expect } from "vitest"; +import { formatBytes } from "../../src/utils/format/bytes.ts"; +import { formatDate, formatDateTime } from "../../src/utils/format/date.ts"; +import { formatDuration, formatEta } from "../../src/utils/format/time.ts"; + +describe("formatBytes", () => { + it('returns "0 B" for zero bytes', () => { + expect(formatBytes(0)).toBe("0 B"); + }); + + it("formats bytes correctly", () => { + expect(formatBytes(500)).toBe("500 B"); + }); + + it("formats kilobytes", () => { + expect(formatBytes(1024)).toBe("1.00 KB"); + expect(formatBytes(1536)).toBe("1.50 KB"); + }); + + it("formats megabytes", () => { + expect(formatBytes(1048576)).toBe("1.00 MB"); + expect(formatBytes(1572864)).toBe("1.50 MB"); + }); + + it("formats gigabytes", () => { + expect(formatBytes(1073741824)).toBe("1.00 GB"); + }); + + it("formats terabytes", () => { + expect(formatBytes(1099511627776)).toBe("1.00 TB"); + }); +}); + +describe("formatEta", () => { + it('returns "--" for null/undefined', () => { + expect(formatEta(null)).toBe("--"); + expect(formatEta(undefined)).toBe("--"); + }); + + it('returns "--" for negative values', () => { + expect(formatEta(-5)).toBe("--"); + }); + + it("formats seconds", () => { + expect(formatEta(30)).toBe("30s"); + expect(formatEta(1)).toBe("1s"); + }); + + it("formats minutes and seconds", () => { + expect(formatEta(90)).toBe("1m 30s"); + expect(formatEta(125)).toBe("2m 5s"); + }); + + it("formats hours and minutes", () => { + expect(formatEta(3661)).toBe("1h 1m"); + expect(formatEta(7200)).toBe("2h 0m"); + }); +}); + +describe("formatDuration", () => { + it("formats sub-second durations as milliseconds", () => { + expect(formatDuration(0.5)).toBe("500ms"); + expect(formatDuration(0.001)).toBe("1ms"); + }); + + it("formats seconds with one decimal", () => { + expect(formatDuration(5.3)).toBe("5.3s"); + expect(formatDuration(30.0)).toBe("30.0s"); + }); + + it("formats minutes and seconds", () => { + expect(formatDuration(90)).toBe("1m 30s"); + expect(formatDuration(125)).toBe("2m 5s"); + }); +}); + +describe("formatDate", () => { + it("formats a Unix epoch into a locale date string (date only)", () => { + // 2024-01-15 10:30:00 UTC = 1705311000 + const result = formatDate(1705311000); + // Should contain date components (locale-dependent formatting) + expect(result).toContain("Jan"); + expect(result).toContain("15"); + expect(result).toContain("2024"); + // Should NOT contain time + expect(result).not.toContain(":"); + }); +}); + +describe("formatDateTime", () => { + it('returns "-" for null/undefined', () => { + expect(formatDateTime(null)).toBe("-"); + expect(formatDateTime(undefined)).toBe("-"); + }); + + it('returns "-" for zero', () => { + expect(formatDateTime(0)).toBe("-"); + }); + + it("formats a Unix epoch into a locale datetime string", () => { + // 2024-01-15 10:30:00 UTC = 1705311000 + const result = formatDateTime(1705311000); + // Should contain date components (locale-dependent formatting) + expect(result).toContain("Jan"); + expect(result).toContain("15"); + expect(result).toContain("2024"); + // Should also contain time + expect(result).toContain(":"); + }); +}); diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..a9b5a59 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,28 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "types": ["vite/client"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..8a67f62 --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..fdadc3d --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,19 @@ +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + server: { + port: 3000, + proxy: { + "/api": { + target: "http://localhost:5000", + changeOrigin: true, + }, + }, + }, + build: { + outDir: "dist", + }, +}); diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..738362e --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,12 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: "jsdom", + setupFiles: ["./tests/setup.ts"], + css: true, + }, +}); diff --git a/jsconfig.json b/jsconfig.json deleted file mode 100644 index 187ef93..0000000 --- a/jsconfig.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "compilerOptions": { - "checkJs": true, - "noEmit": true, - "strict": false, - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "lib": ["ES2022", "DOM", "DOM.Iterable"], - "skipLibCheck": true - }, - "include": ["app/static/js/**/*.js"], - "exclude": ["node_modules", "tests"] -} diff --git a/modaq-upload.desktop.template b/modaq-upload.desktop.template index 6294a0d..5cf5435 100644 --- a/modaq-upload.desktop.template +++ b/modaq-upload.desktop.template @@ -4,7 +4,7 @@ Type=Application Name=MODAQ Upload Comment=MODAQ File Uploader Exec={{PROJECT_DIR}}/venv/bin/python {{PROJECT_DIR}}/launch.py -Icon={{PROJECT_DIR}}/app/static/images/modaq-logo.png +Icon={{PROJECT_DIR}}/frontend/public/images/modaq-logo.png Terminal=true Categories=Science;Utility; StartupNotify=true diff --git a/package.json b/package.json deleted file mode 100644 index 611fc07..0000000 --- a/package.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "private": true, - "name": "modaq-upload", - "description": "Frontend js for modaq_upload flask app", - "scripts": { - "lint": "biome check app/static/js/", - "lint:fix": "biome check --write app/static/js/", - "typecheck": "tsc -p jsconfig.json", - "test": "vitest run", - "test:watch": "vitest", - "test:coverage": "vitest run --coverage", - "check": "biome check app/static/js/ && tsc -p jsconfig.json && vitest run" - }, - "devDependencies": { - "@biomejs/biome": "^1.9.0", - "@vitest/coverage-v8": "^2.1.0", - "jsdom": "^25.0.0", - "typescript": "^5.6.0", - "vitest": "^2.1.0" - } -} diff --git a/pyproject.toml b/pyproject.toml index 6670e02..fca511f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "modaq-upload" -version = "0.2.2" +version = "1.0.0" description = "Python/Flask web application for uploading MODAQ data to Amazon AWS S3 buckets" requires-python = ">=3.11" authors = [ diff --git a/requirements-dev.txt b/requirements-dev.txt index 1b738ee..8a9e9e5 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -2,6 +2,7 @@ pytest>=8.0.0 pytest-cov>=4.1.0 moto[s3]>=5.0.0 mypy>=1.8.0 +types-psutil>=5.9.0 boto3-stubs[s3]>=1.34.0 ruff>=0.2.0 pre-commit>=3.6.0 diff --git a/requirements.txt b/requirements.txt index 8f31002..3db7f7f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ python-dotenv>=1.0.0 gunicorn>=21.0.0 modaq_toolkit[mcap] @ git+https://github.com/MODAQ2/MODAQ_toolkit.git mcap-ros2-support +psutil>=5.9.0 diff --git a/settings.default.json b/settings.default.json index 7b271cb..a633a92 100644 --- a/settings.default.json +++ b/settings.default.json @@ -3,5 +3,15 @@ "aws_region": "us-west-2", "s3_bucket": "", "default_upload_folder": "", - "log_directory": "logs" + "log_directory": "logs", + "batch_processing": { + "enabled": true, + "batch_size": 100, + "auto_tune_workers": true, + "max_workers": 4, + "target_cpu_percent": 70.0, + "skip_mcap_validation": false, + "use_database_for_large_jobs": true, + "large_job_threshold": 1000 + } } diff --git a/setup-mount-permissions.sh b/setup-mount-permissions.sh new file mode 100755 index 0000000..84b1c73 --- /dev/null +++ b/setup-mount-permissions.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# setup-mount-permissions.sh +# +# Configures udisks2 to mount exFAT drives with read/write permissions +# for the current user, so the MODAQ Uploader can delete local files. +# +# Usage: sudo ./setup-mount-permissions.sh [username] + +set -euo pipefail + +USER="${1:-${SUDO_USER:-}}" + +if [ -z "$USER" ]; then + echo "Error: Could not determine target user." + echo "Usage: sudo $0 [username]" + exit 1 +fi + +UID_NUM=$(id -u "$USER") +GID_NUM=$(id -g "$USER") + +CONF="/etc/udisks2/mount_options.conf" + +mkdir -p /etc/udisks2 + +cat > "$CONF" << EOF +# MODAQ Uploader — mount exFAT drives with rw for $USER +[defaults] +exfat_defaults=uid=$UID_NUM,gid=$GID_NUM,dmask=0022,fmask=0133 +EOF + +echo "Written: $CONF" +echo "exFAT drives will now mount with rw for $USER (uid=$UID_NUM)." +echo "" +echo "Unplug and re-plug the drive to apply." diff --git a/tests/conftest.py b/tests/conftest.py index 0bf19e8..e3896aa 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,6 +31,17 @@ def app() -> Generator[Flask, None, None]: ) temp_settings = f.name + # Ensure frontend/dist/index.html exists so SPA routes can serve it + from app.routes.main import FRONTEND_DIST + + dist_created = False + index_path = os.path.join(FRONTEND_DIST, "index.html") + if not os.path.exists(index_path): + os.makedirs(FRONTEND_DIST, exist_ok=True) + dist_created = True + with open(index_path, "w") as f: + f.write('
') + # Create the app (settings will be loaded from default) _ = SETTINGS_FILE # Reference to avoid unused import warning @@ -41,6 +52,8 @@ def app() -> Generator[Flask, None, None]: # Cleanup os.unlink(temp_settings) + if dist_created: + os.unlink(index_path) @pytest.fixture diff --git a/tests/js/file-browser.test.js b/tests/js/file-browser.test.js deleted file mode 100644 index c9b9ebb..0000000 --- a/tests/js/file-browser.test.js +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import state from '../../app/static/js/modules/state.js'; - -describe('file-browser', () => { - beforeEach(() => { - state.currentPrefix = ''; - - document.body.innerHTML = ` -
- -
-
-
Loading...
-
- - -
- - - - -
- `; - }); - - it('state.currentPrefix defaults to empty string', () => { - expect(state.currentPrefix).toBe(''); - }); - - it('currentPrefix can be updated', () => { - state.currentPrefix = 'year=2024/'; - expect(state.currentPrefix).toBe('year=2024/'); - state.currentPrefix = ''; - }); -}); diff --git a/tests/js/logs.test.js b/tests/js/logs.test.js deleted file mode 100644 index c69cb18..0000000 --- a/tests/js/logs.test.js +++ /dev/null @@ -1,44 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import state from '../../app/static/js/modules/state.js'; - -describe('logs state', () => { - beforeEach(() => { - state.logFilters = { date: null, level: null, category: null, search: '' }; - state.logPagination = { offset: 0, limit: 100 }; - }); - - it('logFilters defaults are correct', () => { - expect(state.logFilters.date).toBeNull(); - expect(state.logFilters.level).toBeNull(); - expect(state.logFilters.category).toBeNull(); - expect(state.logFilters.search).toBe(''); - }); - - it('logPagination defaults are correct', () => { - expect(state.logPagination.offset).toBe(0); - expect(state.logPagination.limit).toBe(100); - }); - - it('logFilters can be updated', () => { - state.logFilters.date = '2026-02-07'; - state.logFilters.level = 'ERROR'; - state.logFilters.category = 'upload'; - state.logFilters.search = 'test'; - - expect(state.logFilters.date).toBe('2026-02-07'); - expect(state.logFilters.level).toBe('ERROR'); - expect(state.logFilters.category).toBe('upload'); - expect(state.logFilters.search).toBe('test'); - }); - - it('logPagination offset can be advanced', () => { - state.logPagination.offset = 100; - expect(state.logPagination.offset).toBe(100); - }); - - it('logFilters can be reset', () => { - state.logFilters.level = 'ERROR'; - state.logFilters = { date: null, level: null, category: null, search: '' }; - expect(state.logFilters.level).toBeNull(); - }); -}); diff --git a/tests/js/settings.test.js b/tests/js/settings.test.js deleted file mode 100644 index 534e0ff..0000000 --- a/tests/js/settings.test.js +++ /dev/null @@ -1,18 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import state from '../../app/static/js/modules/state.js'; - -describe('settings', () => { - beforeEach(() => { - state.currentAwsProfile = undefined; - }); - - it('state.currentAwsProfile defaults to undefined', () => { - expect(state.currentAwsProfile).toBeUndefined(); - }); - - it('state.currentAwsProfile can be set', () => { - state.currentAwsProfile = 'my-profile'; - expect(state.currentAwsProfile).toBe('my-profile'); - state.currentAwsProfile = undefined; - }); -}); diff --git a/tests/js/state.test.js b/tests/js/state.test.js deleted file mode 100644 index 3a04e2a..0000000 --- a/tests/js/state.test.js +++ /dev/null @@ -1,39 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import state from '../../app/static/js/modules/state.js'; - -describe('state', () => { - it('has correct default values', () => { - expect(state.currentJobId).toBeNull(); - expect(state.eventSource).toBeNull(); - expect(state.selectedFolderPath).toBeNull(); - expect(state.currentStep).toBe(1); - expect(state.currentPrefix).toBe(''); - expect(state.appVersionData).toBeNull(); - expect(state.currentAwsProfile).toBeUndefined(); - expect(state.scanFilePaths).toEqual([]); - expect(state.scanFileStatuses).toEqual([]); - expect(state.scanTotalSize).toBe(0); - expect(state.scanFolderPath).toBeNull(); - expect(state.reviewSortConfig).toEqual({ column: 'filename', ascending: true }); - }); - - it('is a shared mutable reference', () => { - const originalStep = state.currentStep; - state.currentStep = 3; - expect(state.currentStep).toBe(3); - state.currentStep = originalStep; - }); - - it('allows setting and clearing job id', () => { - state.currentJobId = 'test-job-123'; - expect(state.currentJobId).toBe('test-job-123'); - state.currentJobId = null; - expect(state.currentJobId).toBeNull(); - }); - - it('allows managing scan file paths', () => { - state.scanFilePaths = ['/path/to/file1.mcap', '/path/to/file2.mcap']; - expect(state.scanFilePaths).toHaveLength(2); - state.scanFilePaths = []; - }); -}); diff --git a/tests/js/stepper.test.js b/tests/js/stepper.test.js deleted file mode 100644 index 5e1416d..0000000 --- a/tests/js/stepper.test.js +++ /dev/null @@ -1,101 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { setUploadStep, showUploadSteps, hideUploadSteps, goToStep } from '../../app/static/js/modules/stepper.js'; -import state from '../../app/static/js/modules/state.js'; - -describe('stepper', () => { - beforeEach(() => { - state.currentStep = 1; - - document.body.innerHTML = ` -
-
-
-
-
-
-
-
-
-
- `; - }); - - describe('setUploadStep', () => { - it('sets the active step', () => { - setUploadStep(3); - - const step3 = document.querySelector('[data-step="3"]'); - expect(step3.classList.contains('active')).toBe(true); - expect(step3.classList.contains('completed')).toBe(false); - }); - - it('marks previous steps as completed', () => { - setUploadStep(3); - - const step1 = document.querySelector('[data-step="1"]'); - const step2 = document.querySelector('[data-step="2"]'); - expect(step1.classList.contains('completed')).toBe(true); - expect(step2.classList.contains('completed')).toBe(true); - }); - - it('leaves future steps unmarked', () => { - setUploadStep(3); - - const step4 = document.querySelector('[data-step="4"]'); - expect(step4.classList.contains('active')).toBe(false); - expect(step4.classList.contains('completed')).toBe(false); - }); - - it('updates the step description', () => { - setUploadStep(1); - const desc = document.getElementById('step-description'); - expect(desc.textContent).toBe('Select files or a folder to upload'); - }); - - it('updates state.currentStep', () => { - setUploadStep(4); - expect(state.currentStep).toBe(4); - }); - - it('colors connectors for completed steps', () => { - setUploadStep(3); - const connectors = document.querySelectorAll('.step-connector'); - expect(connectors[0].style.backgroundColor).toBe('rgb(93, 151, 50)'); - expect(connectors[1].style.backgroundColor).toBe('rgb(93, 151, 50)'); - expect(connectors[2].style.backgroundColor).toBe('rgb(209, 213, 219)'); - }); - }); - - describe('showUploadSteps', () => { - it('delegates to setUploadStep', () => { - showUploadSteps(2); - expect(state.currentStep).toBe(2); - const step2 = document.querySelector('[data-step="2"]'); - expect(step2.classList.contains('active')).toBe(true); - }); - }); - - describe('hideUploadSteps', () => { - it('resets to step 1', () => { - setUploadStep(4); - hideUploadSteps(); - expect(state.currentStep).toBe(1); - const step1 = document.querySelector('[data-step="1"]'); - expect(step1.classList.contains('active')).toBe(true); - }); - }); - - describe('goToStep', () => { - it('does nothing when navigating forward', async () => { - state.currentStep = 2; - await goToStep(3); - expect(state.currentStep).toBe(2); - }); - - it('does nothing when navigating to current step', async () => { - state.currentStep = 2; - await goToStep(2); - expect(state.currentStep).toBe(2); - }); - }); -}); diff --git a/tests/js/upload-control.test.js b/tests/js/upload-control.test.js deleted file mode 100644 index 401e366..0000000 --- a/tests/js/upload-control.test.js +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { resetUpload } from '../../app/static/js/modules/upload-control.js'; -import state from '../../app/static/js/modules/state.js'; - -describe('upload-control', () => { - beforeEach(() => { - state.currentJobId = 'test-job'; - state.eventSource = null; - state.selectedFolderPath = '/some/path'; - state.scanFilePaths = ['/file1.mcap']; - state.scanFileStatuses = [{ path: '/file1.mcap', filename: 'file1.mcap', size: 100, already_uploaded: false }]; - state.scanTotalSize = 100; - state.scanFolderPath = '/some/path'; - state.currentStep = 3; - - document.body.innerHTML = ` -
-
-
-
-
-
-
-
-
-
- -
-
-
-
- `; - }); - - describe('resetUpload', () => { - it('clears the job ID', () => { - resetUpload(); - expect(state.currentJobId).toBeNull(); - }); - - it('clears selected folder path', () => { - resetUpload(); - expect(state.selectedFolderPath).toBeNull(); - }); - - it('clears scan file paths', () => { - resetUpload(); - expect(state.scanFilePaths).toEqual([]); - }); - - it('clears scan file statuses and total size', () => { - resetUpload(); - expect(state.scanFileStatuses).toEqual([]); - expect(state.scanTotalSize).toBe(0); - expect(state.scanFolderPath).toBeNull(); - }); - - it('shows folder browser panel and hides other sections', () => { - resetUpload(); - - expect(document.getElementById('folder-browser-panel').classList.contains('hidden')).toBe(false); - expect(document.getElementById('upload-section').classList.contains('hidden')).toBe(true); - expect(document.getElementById('completion-section').classList.contains('hidden')).toBe(true); - expect(document.getElementById('scan-results-section').classList.contains('hidden')).toBe(true); - expect(document.getElementById('confirm-upload-modal').classList.contains('hidden')).toBe(true); - }); - - it('resets stepper to step 1', () => { - resetUpload(); - expect(state.currentStep).toBe(1); - }); - - it('closes eventSource if open', () => { - let closeCalled = false; - state.eventSource = { close: () => { closeCalled = true; } }; - resetUpload(); - expect(closeCalled).toBe(true); - expect(state.eventSource).toBeNull(); - }); - }); -}); diff --git a/tests/js/upload-exec.test.js b/tests/js/upload-exec.test.js deleted file mode 100644 index 4e34634..0000000 --- a/tests/js/upload-exec.test.js +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { updateProgressUI, showCompletionSummary } from '../../app/static/js/modules/upload-exec.js'; -import state from '../../app/static/js/modules/state.js'; - -describe('upload-exec', () => { - beforeEach(() => { - state.currentStep = 3; - - document.body.innerHTML = ` -
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- -
-
-
-
-
-
-
-
- `; - }); - - describe('updateProgressUI', () => { - it('updates progress elements', () => { - updateProgressUI({ - progress_percent: 50.5, - files_completed: 3, - total_files: 6, - uploaded_bytes_formatted: '15 MB', - total_bytes_formatted: '30 MB', - eta_seconds: 120, - files: [ - { filename: 'test.mcap', status: 'completed', file_size_formatted: '5 MB', progress_percent: 100 }, - { filename: 'test2.mcap', status: 'uploading', file_size_formatted: '5 MB', progress_percent: 50 }, - ], - }); - - expect(document.getElementById('progress-percent').textContent).toBe('50.5'); - expect(document.getElementById('files-completed').textContent).toBe('3'); - expect(document.getElementById('files-total').textContent).toBe('6'); - expect(document.getElementById('bytes-uploaded').textContent).toBe('15 MB'); - expect(document.getElementById('bytes-total').textContent).toBe('30 MB'); - expect(document.getElementById('eta').textContent).toBe('2m 0s'); - }); - }); - - describe('showCompletionSummary', () => { - it('updates completion counts', () => { - showCompletionSummary({ - files: [ - { status: 'completed', filename: 'a.mcap', file_size_formatted: '5 MB' }, - { status: 'completed', filename: 'b.mcap', file_size_formatted: '3 MB' }, - { status: 'skipped', filename: 'c.mcap', file_size_formatted: '2 MB' }, - { status: 'failed', filename: 'd.mcap', file_size_formatted: '1 MB' }, - ], - successfully_uploaded_bytes_formatted: '8 MB', - total_upload_duration_formatted: '1m 30s', - average_upload_speed_mbps: 42.5, - }); - - expect(document.getElementById('completed-count').textContent).toBe('2'); - expect(document.getElementById('skipped-count').textContent).toBe('1'); - expect(document.getElementById('failed-count').textContent).toBe('1'); - expect(document.getElementById('total-uploaded-size').textContent).toBe('8 MB'); - expect(document.getElementById('avg-upload-speed').textContent).toBe('42.5 Mbps'); - }); - - it('sets step to 4 (complete)', () => { - showCompletionSummary({ - files: [{ status: 'completed', filename: 'a.mcap', file_size_formatted: '5 MB' }], - }); - - expect(state.currentStep).toBe(4); - }); - - it('shows completion section and hides upload section', () => { - showCompletionSummary({ - files: [{ status: 'completed', filename: 'a.mcap', file_size_formatted: '5 MB' }], - }); - - expect(document.getElementById('upload-section').classList.contains('hidden')).toBe(true); - expect(document.getElementById('completion-section').classList.contains('hidden')).toBe(false); - }); - }); -}); diff --git a/tests/js/utils.test.js b/tests/js/utils.test.js deleted file mode 100644 index 45070b7..0000000 --- a/tests/js/utils.test.js +++ /dev/null @@ -1,109 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { showNotification } from '../../app/static/js/modules/notify.js'; -import { formatBytes, formatEta, formatDuration } from '../../app/static/js/modules/formatters.js'; - -describe('formatBytes', () => { - it('returns "0 B" for zero bytes', () => { - expect(formatBytes(0)).toBe('0 B'); - }); - - it('formats bytes correctly', () => { - expect(formatBytes(500)).toBe('500 B'); - }); - - it('formats kilobytes', () => { - expect(formatBytes(1024)).toBe('1 KB'); - expect(formatBytes(1536)).toBe('1.5 KB'); - }); - - it('formats megabytes', () => { - expect(formatBytes(1048576)).toBe('1 MB'); - expect(formatBytes(1572864)).toBe('1.5 MB'); - }); - - it('formats gigabytes', () => { - expect(formatBytes(1073741824)).toBe('1 GB'); - }); - - it('formats terabytes', () => { - expect(formatBytes(1099511627776)).toBe('1 TB'); - }); -}); - -describe('formatEta', () => { - it('returns "Calculating..." for null/undefined', () => { - expect(formatEta(null)).toBe('Calculating...'); - expect(formatEta(undefined)).toBe('Calculating...'); - }); - - it('returns "Calculating..." for negative values', () => { - expect(formatEta(-5)).toBe('Calculating...'); - }); - - it('formats seconds', () => { - expect(formatEta(30)).toBe('30s'); - expect(formatEta(1)).toBe('1s'); - }); - - it('formats minutes and seconds', () => { - expect(formatEta(90)).toBe('1m 30s'); - expect(formatEta(125)).toBe('2m 5s'); - }); - - it('formats hours and minutes', () => { - expect(formatEta(3661)).toBe('1h 1m'); - expect(formatEta(7200)).toBe('2h 0m'); - }); -}); - -describe('formatDuration', () => { - it('formats sub-second durations as milliseconds', () => { - expect(formatDuration(0.5)).toBe('500ms'); - expect(formatDuration(0.001)).toBe('1ms'); - }); - - it('formats seconds with one decimal', () => { - expect(formatDuration(5.3)).toBe('5.3s'); - expect(formatDuration(30.0)).toBe('30.0s'); - }); - - it('formats minutes and seconds', () => { - expect(formatDuration(90)).toBe('1m 30s'); - expect(formatDuration(125)).toBe('2m 5s'); - }); -}); - -describe('showNotification', () => { - beforeEach(() => { - document.body.innerHTML = ''; - }); - - afterEach(() => { - document.body.innerHTML = ''; - }); - - it('creates a notification element in the DOM', () => { - showNotification('Test message', 'info'); - const notification = document.querySelector('.fixed.top-4.right-4'); - expect(notification).not.toBeNull(); - expect(notification.textContent).toBe('Test message'); - }); - - it('applies error styling for error type', () => { - showNotification('Error!', 'error'); - const notification = document.querySelector('.fixed.top-4.right-4'); - expect(notification.classList.contains('bg-red-500')).toBe(true); - }); - - it('applies success styling for success type', () => { - showNotification('Success!', 'success'); - const notification = document.querySelector('.fixed.top-4.right-4'); - expect(notification.classList.contains('bg-green-500')).toBe(true); - }); - - it('applies info styling by default', () => { - showNotification('Info'); - const notification = document.querySelector('.fixed.top-4.right-4'); - expect(notification.classList.contains('bg-nlr-blue')).toBe(true); - }); -}); diff --git a/tests/test_log_service.py b/tests/test_log_service.py index 9f5c264..132f856 100644 --- a/tests/test_log_service.py +++ b/tests/test_log_service.py @@ -113,9 +113,7 @@ def test_error_convenience(self, log_service: LogService, _mock_settings: Any) - entry = json.loads(log_file.read_text().strip()) assert entry["level"] == "ERROR" - def test_multiple_entries_appended( - self, log_service: LogService, _mock_settings: Any - ) -> None: + def test_multiple_entries_appended(self, log_service: LogService, _mock_settings: Any) -> None: """Test that multiple log calls append to the same file.""" with _mock_settings: log_service.info("app", "event1", "First") @@ -127,9 +125,7 @@ def test_multiple_entries_appended( lines = [line for line in log_file.read_text().strip().split("\n") if line] assert len(lines) == 3 - def test_no_metadata_omits_field( - self, log_service: LogService, _mock_settings: Any - ) -> None: + def test_no_metadata_omits_field(self, log_service: LogService, _mock_settings: Any) -> None: """Test that metadata field is omitted when not provided.""" with _mock_settings: log_service.info("app", "test", "No metadata") @@ -206,9 +202,7 @@ def test_read_entries_date_filter_hive( assert result["total"] == 1 assert result["entries"][0]["event"] == "event1" - def test_read_entries_invalid_date( - self, log_service: LogService, _mock_settings: Any - ) -> None: + def test_read_entries_invalid_date(self, log_service: LogService, _mock_settings: Any) -> None: """Test that an invalid date returns empty results.""" with _mock_settings: result = log_service.read_log_entries(date="not-a-date") @@ -284,9 +278,7 @@ def test_empty_stats(self, log_service: LogService, _mock_settings: Any) -> None assert stats["total_entries"] == 0 assert stats["file_count"] == 0 - def test_stats_includes_csv_count( - self, log_service: LogService, _mock_settings: Any - ) -> None: + def test_stats_includes_csv_count(self, log_service: LogService, _mock_settings: Any) -> None: """Test that stats include csv_count.""" with _mock_settings: log_service.info("app", "test", "Entry") @@ -386,9 +378,7 @@ def test_extract_date_from_non_hive_path(self) -> None: result = LogService._extract_date_from_hive_path(path) assert result is None - def test_log_writes_to_hive_path( - self, log_service: LogService, _mock_settings: Any - ) -> None: + def test_log_writes_to_hive_path(self, log_service: LogService, _mock_settings: Any) -> None: """Test that log() writes to a hive-partitioned events.jsonl.""" log_dir: Path = log_service._test_settings_mock.log_directory # type: ignore[attr-defined] @@ -471,9 +461,7 @@ def _make_mock_job(self) -> MagicMock: mock_job.files = [mock_file] return mock_job - def test_save_job_csv_creates_file( - self, log_service: LogService, _mock_settings: Any - ) -> None: + def test_save_job_csv_creates_file(self, log_service: LogService, _mock_settings: Any) -> None: """Test that save_job_csv creates a CSV at the correct hive path.""" log_dir: Path = log_service._test_settings_mock.log_directory # type: ignore[attr-defined] job_id = "a1b2c3d4-5678-9abc-def0-1234567890ab" @@ -489,9 +477,7 @@ def test_save_job_csv_creates_file( assert result_path.name.endswith(".csv") assert result_path.exists() - def test_save_job_csv_columns( - self, log_service: LogService, _mock_settings: Any - ) -> None: + def test_save_job_csv_columns(self, log_service: LogService, _mock_settings: Any) -> None: """Test that the CSV has all 14 expected columns.""" job_id = "test-job-id-1234" completed_at = datetime(2026, 2, 8, 12, 0, 0, tzinfo=UTC) @@ -522,9 +508,7 @@ def test_save_job_csv_columns( ] assert header == expected_columns - def test_save_job_csv_data_row( - self, log_service: LogService, _mock_settings: Any - ) -> None: + def test_save_job_csv_data_row(self, log_service: LogService, _mock_settings: Any) -> None: """Test that the CSV has a data row for each file.""" job_id = "test-job-id" completed_at = datetime(2026, 2, 8, 12, 0, 0, tzinfo=UTC) diff --git a/tests/test_routes.py b/tests/test_routes.py index cd4cb36..da87d12 100644 --- a/tests/test_routes.py +++ b/tests/test_routes.py @@ -10,22 +10,22 @@ class TestMainRoutes: """Tests for main page routes.""" def test_index_page(self, client: FlaskClient) -> None: - """Test that index page loads successfully.""" + """Test that index page serves the React SPA shell.""" response = client.get("/") assert response.status_code == 200 - assert b"Upload MCAP Files" in response.data + assert b'
' in response.data def test_files_page(self, client: FlaskClient) -> None: - """Test that files page loads successfully.""" + """Test that files page serves the React SPA shell.""" response = client.get("/files") assert response.status_code == 200 - assert b"S3 File Browser" in response.data + assert b'
' in response.data def test_settings_page(self, client: FlaskClient) -> None: - """Test that settings page loads successfully.""" + """Test that settings page serves the React SPA shell.""" response = client.get("/settings") assert response.status_code == 200 - assert b"Settings" in response.data + assert b'
' in response.data class TestSettingsAPI: @@ -138,7 +138,6 @@ def test_analyze_with_json_paths(self, client: FlaskClient) -> None: data = json.loads(response.data) assert "job_id" in data - assert "files" in data assert data["status"] == "analyzing" def test_get_status_not_found(self, client: FlaskClient) -> None: @@ -200,49 +199,6 @@ def test_list_files_no_bucket(self, client: FlaskClient) -> None: response = client.get("/api/files/list") assert response.status_code == 400 - def test_search_no_query(self, client: FlaskClient) -> None: - """Test search without query parameter.""" - # Ensure bucket is set for this test - client.put( - "/api/settings", - data=json.dumps({"s3_bucket": "test-bucket"}), - content_type="application/json", - ) - - response = client.get("/api/files/search") - assert response.status_code == 400 - - @patch("app.routes.files.s3_service") - def test_search_success(self, mock_s3: MagicMock, client: FlaskClient) -> None: - """Test successful file search.""" - # Ensure bucket is set - client.put( - "/api/settings", - data=json.dumps({"s3_bucket": "test-bucket"}), - content_type="application/json", - ) - - mock_s3.create_s3_client.return_value = MagicMock() - mock_s3.list_bucket_objects.return_value = { - "success": True, - "bucket": "test-bucket", - "prefix": "", - "folders": [], - "files": [ - {"name": "test_file.mcap", "key": "path/test_file.mcap", "size": 1000}, - {"name": "other.mcap", "key": "path/other.mcap", "size": 2000}, - ], - "error": None, - } - - response = client.get("/api/files/search?query=test") - assert response.status_code == 200 - - data = json.loads(response.data) - assert data["success"] is True - assert len(data["files"]) == 1 - assert data["files"][0]["name"] == "test_file.mcap" - def test_get_file_info_no_key(self, client: FlaskClient) -> None: """Test getting file info without key parameter.""" response = client.get("/api/files/info") @@ -253,10 +209,10 @@ class TestLogsAPI: """Tests for logs API endpoints.""" def test_logs_page(self, client: FlaskClient) -> None: - """Test that logs page loads successfully.""" + """Test that logs page serves the React SPA shell.""" response = client.get("/logs") assert response.status_code == 200 - assert b"Application Logs" in response.data + assert b'
' in response.data def test_get_entries(self, client: FlaskClient) -> None: """Test getting log entries.""" diff --git a/tests/test_sse_infrastructure.py b/tests/test_sse_infrastructure.py new file mode 100644 index 0000000..e608457 --- /dev/null +++ b/tests/test_sse_infrastructure.py @@ -0,0 +1,158 @@ +"""Tests for SSE infrastructure: event signaling, queue management, and resource cleanup.""" + +import threading +import time +from collections.abc import Generator +from typing import Any + +import pytest + +from app.routes.upload import ( + SSE_QUEUE_TTL_SECONDS, + _cleanup_old_sse_queues, + _sse_events, + _sse_queues, + _sse_timestamps, + send_sse_event, +) + + +@pytest.fixture +def clear_sse_state() -> Generator[None, None, None]: + """Clear SSE module state before each test.""" + _sse_queues.clear() + _sse_events.clear() + _sse_timestamps.clear() + yield + _sse_queues.clear() + _sse_events.clear() + _sse_timestamps.clear() + + +def test_send_sse_event_creates_timestamp(clear_sse_state: Any) -> None: + """Test that sending an event updates the timestamp.""" + from collections import deque + + job_id = "test-job-123" + + # Create a queue manually + queue = deque() + _sse_queues[job_id] = [queue] + + # Send event + send_sse_event(job_id, {"type": "test", "data": "hello"}) + + # Verify timestamp was created + assert job_id in _sse_timestamps + assert time.time() - _sse_timestamps[job_id] < 1 # Within 1 second + + +def test_send_sse_event_signals_waiting_threads(clear_sse_state: Any) -> None: + """Test that sending an event signals the threading.Event.""" + from collections import deque + + job_id = "test-job-456" + + # Create queue and event + queue = deque() + event = threading.Event() + _sse_queues[job_id] = [queue] + _sse_events[job_id] = event + + # Event should not be set initially + assert not event.is_set() + + # Send event + send_sse_event(job_id, {"type": "test"}) + + # Event should now be set + assert event.is_set() + assert len(queue) == 1 + + +def test_cleanup_removes_old_queues(clear_sse_state: Any) -> None: + """Test that cleanup removes expired queues.""" + from collections import deque + + # Create some queues with old timestamps + old_time = time.time() - SSE_QUEUE_TTL_SECONDS - 100 + recent_time = time.time() + + _sse_queues["old-job-1"] = [deque()] + _sse_timestamps["old-job-1"] = old_time + _sse_events["old-job-1"] = threading.Event() + + _sse_queues["old-job-2"] = [deque()] + _sse_timestamps["old-job-2"] = old_time + + _sse_queues["recent-job"] = [deque()] + _sse_timestamps["recent-job"] = recent_time + + # Run cleanup + removed = _cleanup_old_sse_queues() + + # Should remove 2 old jobs, keep recent one + assert removed == 2 + assert "old-job-1" not in _sse_queues + assert "old-job-1" not in _sse_events + assert "old-job-1" not in _sse_timestamps + assert "old-job-2" not in _sse_queues + assert "recent-job" in _sse_queues + + +def test_cleanup_with_no_expired_queues(clear_sse_state: Any) -> None: + """Test that cleanup does nothing when all queues are recent.""" + from collections import deque + + recent_time = time.time() + + _sse_queues["job-1"] = [deque()] + _sse_timestamps["job-1"] = recent_time + + _sse_queues["job-2"] = [deque()] + _sse_timestamps["job-2"] = recent_time + + # Run cleanup + removed = _cleanup_old_sse_queues() + + # Should remove nothing + assert removed == 0 + assert len(_sse_queues) == 2 + + +def test_event_driven_signaling(clear_sse_state: Any) -> None: + """Test that Event.wait() is more efficient than polling.""" + from collections import deque + + job_id = "test-job-signal" + queue = deque() + event = threading.Event() + + _sse_queues[job_id] = [queue] + _sse_events[job_id] = event + + # Simulate waiting thread + wait_result: list[float] = [] + + def waiter() -> None: + # This should block until event is set + start = time.time() + event.wait(timeout=2.0) + elapsed = time.time() - start + wait_result.append(elapsed) + + thread = threading.Thread(target=waiter) + thread.start() + + # Small delay to ensure thread is waiting + time.sleep(0.1) + + # Send event to wake thread + send_sse_event(job_id, {"type": "wake"}) + + # Wait for thread to finish + thread.join(timeout=3.0) + + # Thread should have woken up quickly (< 0.5s, not 2s timeout) + assert len(wait_result) == 1 + assert wait_result[0] < 0.5 # Should be nearly instant diff --git a/vitest.config.js b/vitest.config.js deleted file mode 100644 index 53550f8..0000000 --- a/vitest.config.js +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from 'vitest/config'; - -export default defineConfig({ - test: { - environment: 'jsdom', - include: ['tests/js/**/*.test.js'], - coverage: { - provider: 'v8', - include: ['app/static/js/**/*.js'], - reportsDirectory: 'htmlcov-js', - }, - }, -});