diff --git a/CLAUDE.md b/CLAUDE.md index 713cee3..97fbdc7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -397,6 +397,18 @@ const response = await api.get('/endpoint'); - `tests/MANUAL_TESTING_CHECKLIST.md` - Manual testing procedures ### Deployment + +**🚀 Quick Deploy from Claude-Agent LXC:** +```bash +# Din orice director ROA2WEB (main, worktree, branch): +./deployment/linux/deploy.sh # Full deploy (frontend + backend) +./deployment/linux/deploy.sh frontend # Frontend only +./deployment/linux/deploy.sh backend # Backend only +./deployment/linux/deploy.sh test # Test SSH connection +``` +→ Server auto-deploys within 5 minutes (scheduled task) + +- **`deployment/linux/README.md`** - ⚠️ **DEPLOY FROM LXC** - Linux to Windows deployment guide - `deployment/windows/README.md` - Windows deployment quick start - `deployment/windows/docs/WINDOWS_DEPLOYMENT.md` - Complete Windows guide - **`deployment/windows/docs/TWO-TIER-IIS-DEPLOYMENT.md`** - ⚠️ **PRODUCTION ARCHITECTURE** - 2-tier IIS setup with public gateway diff --git a/backend/.env.dev.example b/backend/.env.dev.example index c4daa47..694c840 100644 --- a/backend/.env.dev.example +++ b/backend/.env.dev.example @@ -94,11 +94,11 @@ CACHE_BENCHMARK_ON_STARTUP=False ORACLE_ENV=dev # SQLite Database (development) -SQLITE_DATABASE_PATH=data/receipts/receipts_dev.db +DATA_ENTRY_SQLITE_DATABASE_PATH=data/receipts/receipts_dev.db +DATA_ENTRY_UPLOAD_PATH=data/receipts/uploads # File uploads -UPLOAD_PATH=data/receipts/uploads -MAX_UPLOAD_SIZE_MB=10 +DATA_ENTRY_MAX_UPLOAD_SIZE_MB=10 # Test company (for development testing) TEST_COMPANY_ID=110 diff --git a/backend/.env.prod.example b/backend/.env.prod.example index 8aa28c6..4ba93e5 100644 --- a/backend/.env.prod.example +++ b/backend/.env.prod.example @@ -90,11 +90,11 @@ CACHE_BENCHMARK_ON_STARTUP=False ORACLE_ENV=prod # SQLite Database (production) -SQLITE_DATABASE_PATH=data/receipts/receipts_prod.db +DATA_ENTRY_SQLITE_DATABASE_PATH=data/receipts/receipts_prod.db +DATA_ENTRY_UPLOAD_PATH=data/receipts/uploads # File uploads -UPLOAD_PATH=data/receipts/uploads -MAX_UPLOAD_SIZE_MB=10 +DATA_ENTRY_MAX_UPLOAD_SIZE_MB=10 # ============================================================================ # OCR ENGINE CONFIGURATION diff --git a/backend/.env.test.example b/backend/.env.test.example index bb6a846..bba8d66 100644 --- a/backend/.env.test.example +++ b/backend/.env.test.example @@ -95,11 +95,11 @@ CACHE_BENCHMARK_ON_STARTUP=False ORACLE_ENV=test # SQLite Database (test) -SQLITE_DATABASE_PATH=data/receipts/receipts_test.db +DATA_ENTRY_SQLITE_DATABASE_PATH=data/receipts/receipts_test.db +DATA_ENTRY_UPLOAD_PATH=data/receipts/uploads # File uploads -UPLOAD_PATH=data/receipts/uploads -MAX_UPLOAD_SIZE_MB=10 +DATA_ENTRY_MAX_UPLOAD_SIZE_MB=10 # Test company (for testing) TEST_COMPANY_ID=110 diff --git a/backend/main.py b/backend/main.py index 4386975..f578af9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -32,6 +32,7 @@ from shared.auth.middleware import AuthenticationMiddleware from shared.auth.routes import create_auth_router from shared.routes.companies import create_companies_router from shared.routes.calendar import create_calendar_router +from shared.routes.system import create_system_router # Import module router factories from backend.modules.reports.routers import create_reports_router @@ -364,7 +365,8 @@ app.add_middleware( "/api/telegram/auth/verify-email", "/api/telegram/auth/login-with-email", "/api/telegram/auth/refresh-token", - "/api/telegram/health" + "/api/telegram/health", + "/api/telegram/internal/save-code" ] ) @@ -388,6 +390,9 @@ app.include_router(companies_router, prefix="/api/companies") calendar_router = create_calendar_router(oracle_pool, tags=["calendar"]) app.include_router(calendar_router, prefix="/api/calendar") +system_router = create_system_router() +app.include_router(system_router, prefix="/api/system", tags=["system"]) + # ============================================================================ # ROOT & HEALTH ENDPOINTS diff --git a/backend/modules/data_entry/db/crud/attachment.py b/backend/modules/data_entry/db/crud/attachment.py index 82d57e9..df9ec86 100644 --- a/backend/modules/data_entry/db/crud/attachment.py +++ b/backend/modules/data_entry/db/crud/attachment.py @@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession from fastapi import UploadFile from backend.modules.data_entry.db.models.receipt import ReceiptAttachment -from backend.modules.data_entry.config import settings +from backend.config import settings class AttachmentCRUD: @@ -29,7 +29,7 @@ class AttachmentCRUD: """Get full path for storing file, organized by year/month.""" now = datetime.utcnow() relative_path = Path(str(now.year)) / f"{now.month:02d}" - full_path = settings.upload_path_resolved / relative_path + full_path = settings.data_entry_upload_path_resolved / relative_path # Ensure directory exists full_path.mkdir(parents=True, exist_ok=True) @@ -50,19 +50,19 @@ class AttachmentCRUD: relative_path = AttachmentCRUD._get_upload_path(stored_filename) # Full path for saving - full_path = settings.upload_path_resolved / relative_path + full_path = settings.data_entry_upload_path_resolved / relative_path # Read file content content = await file.read() file_size = len(content) # Validate file size - if file_size > settings.max_upload_size_bytes: - raise ValueError(f"File too large. Maximum size is {settings.max_upload_size_mb}MB") + if file_size > settings.data_entry_max_upload_size_bytes: + raise ValueError(f"File too large. Maximum size is {settings.data_entry_max_upload_size_mb}MB") # Validate MIME type mime_type = file.content_type or "application/octet-stream" - if mime_type not in settings.allowed_mime_types: + if mime_type not in settings.data_entry_allowed_mime_types: raise ValueError(f"File type not allowed: {mime_type}") # Save file @@ -111,7 +111,7 @@ class AttachmentCRUD: @staticmethod def get_file_path(attachment: ReceiptAttachment) -> Path: """Get full file path for an attachment.""" - return settings.upload_path_resolved / attachment.file_path + return settings.data_entry_upload_path_resolved / attachment.file_path @staticmethod async def delete(session: AsyncSession, attachment: ReceiptAttachment) -> bool: diff --git a/backend/modules/data_entry/db/crud/receipt.py b/backend/modules/data_entry/db/crud/receipt.py index 054a8a4..4dab43d 100644 --- a/backend/modules/data_entry/db/crud/receipt.py +++ b/backend/modules/data_entry/db/crud/receipt.py @@ -193,6 +193,14 @@ class ReceiptCRUD: """Update receipt fields.""" update_data = data.model_dump(exclude_unset=True) + # Recalculate tva_total from tva_breakdown if breakdown is being updated + if 'tva_breakdown' in update_data and update_data['tva_breakdown']: + tva_total = sum( + float(entry.get('amount', 0) if isinstance(entry, dict) else getattr(entry, 'amount', 0)) + for entry in update_data['tva_breakdown'] + ) + update_data['tva_total'] = round(tva_total, 2) + # Serialize tva_breakdown and payment_methods to JSON string if present if 'tva_breakdown' in update_data: update_data['tva_breakdown'] = _serialize_tva_breakdown(update_data['tva_breakdown']) diff --git a/backend/modules/data_entry/db/database.py b/backend/modules/data_entry/db/database.py index 292416e..4e2aac6 100644 --- a/backend/modules/data_entry/db/database.py +++ b/backend/modules/data_entry/db/database.py @@ -6,13 +6,13 @@ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession from sqlalchemy.orm import sessionmaker from sqlmodel import SQLModel -from backend.modules.data_entry.config import settings +from backend.config import settings # Create async engine # Note: echo=False to disable SQL query logging (too verbose) engine = create_async_engine( - settings.database_url, + settings.data_entry_database_url, echo=False, future=True, ) @@ -28,7 +28,7 @@ async_session_maker = sessionmaker( async def init_db() -> None: """Initialize database - create tables if they don't exist.""" # Ensure data directory exists - db_path = Path(settings.sqlite_database_path) + db_path = Path(settings.data_entry_sqlite_database_path) db_path.parent.mkdir(parents=True, exist_ok=True) async with engine.begin() as conn: diff --git a/backend/modules/data_entry/routers/ocr.py b/backend/modules/data_entry/routers/ocr.py index 475116e..c1846f1 100644 --- a/backend/modules/data_entry/routers/ocr.py +++ b/backend/modules/data_entry/routers/ocr.py @@ -18,7 +18,7 @@ from decimal import Decimal from pathlib import Path from typing import Optional -from fastapi import APIRouter, HTTPException, UploadFile, File, Depends, Query +from fastapi import APIRouter, HTTPException, UploadFile, File, Depends, Query, Response from sqlalchemy.ext.asyncio import AsyncSession from backend.modules.data_entry.db.database import get_session @@ -190,7 +190,10 @@ async def get_job_status( @router.get("/jobs/{job_id}/wait", response_model=OCRJobResponse) async def wait_for_job_status( job_id: str, + response: Response, timeout: int = Query(default=30, ge=1, le=60, description="Max wait time in seconds"), + wait_for_terminal: bool = Query(default=False, description="If true, only return on completed/failed"), + _t: int = Query(default=None, description="Cache-busting timestamp (ignored)"), session: AsyncSession = Depends(get_session), current_user: CurrentUser = Depends(get_current_user) ): @@ -198,7 +201,8 @@ async def wait_for_job_status( Long-poll for OCR job status change. Waits until: - - Job status changes to completed/failed + - Job status changes (default behavior - returns on any status change) + - Job reaches terminal state (if wait_for_terminal=true) - Timeout expires (returns current status) Recommended client timeout: timeout + 5 seconds @@ -206,36 +210,53 @@ async def wait_for_job_status( Args: job_id: Job UUID from POST /extract response timeout: Max wait time in seconds (1-60, default 30) + wait_for_terminal: If true, wait until completed/failed only Returns: OCRJobResponse with status, queue_position, and result (if completed) """ + # Prevent caching - critical for long-polling + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "0" import asyncio import time - end_time = time.time() + timeout + start_time = time.time() + end_time = start_time + timeout last_status = None + iteration = 0 + + print(f"[OCR Wait] Starting long-poll for job {job_id}, timeout={timeout}s, wait_for_terminal={wait_for_terminal}", flush=True) while time.time() < end_time: + iteration += 1 job = await job_queue.get_job(job_id) if not job: + print(f"[OCR Wait] Job {job_id} not found after {iteration} iterations", flush=True) raise HTTPException(status_code=404, detail="Job not found") - # Return immediately if job completed or failed + # Return immediately if job completed or failed (terminal states) if job.status in [JobStatus.completed, JobStatus.failed]: + elapsed = time.time() - start_time + print(f"[OCR Wait] Job {job_id} {job.status.value} after {elapsed:.1f}s ({iteration} iterations)", flush=True) return await get_job_status(job_id, session, current_user) - # Return if status changed from last check - if last_status is not None and job.status != last_status: + # Return on status change (unless wait_for_terminal is set) + if not wait_for_terminal and last_status is not None and job.status != last_status: + elapsed = time.time() - start_time + print(f"[OCR Wait] Job {job_id} status changed {last_status.value}->{job.status.value} after {elapsed:.1f}s", flush=True) return await get_job_status(job_id, session, current_user) last_status = job.status - # Wait 1 second before next internal check - await asyncio.sleep(1) + # Wait 500ms before next internal check (faster polling for better responsiveness) + await asyncio.sleep(0.5) # Timeout - return current status + elapsed = time.time() - start_time + print(f"[OCR Wait] Job {job_id} timeout after {elapsed:.1f}s ({iteration} iterations), status={last_status.value if last_status else 'unknown'}", flush=True) return await get_job_status(job_id, session, current_user) @@ -417,7 +438,7 @@ async def _apply_fuzzy_cui_matching( if match: corrected_cui, supplier_name = match if corrected_cui != extraction_data.cui: - print(f"[Fuzzy Match] Corrected: {extraction_data.cui} → {corrected_cui} ({supplier_name})", flush=True) + print(f"[Fuzzy Match] Corrected: {extraction_data.cui} -> {corrected_cui} ({supplier_name})", flush=True) extraction_data.cui = corrected_cui # Also set partner_name if not already set if not extraction_data.partner_name: diff --git a/backend/modules/data_entry/routers/receipts.py b/backend/modules/data_entry/routers/receipts.py index 492165d..c850696 100644 --- a/backend/modules/data_entry/routers/receipts.py +++ b/backend/modules/data_entry/routers/receipts.py @@ -3,7 +3,7 @@ from typing import List, Optional, Annotated from pathlib import Path -from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Header +from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Query, Header, Response from fastapi.responses import FileResponse from sqlalchemy.ext.asyncio import AsyncSession @@ -120,6 +120,7 @@ async def create_receipt( @router.get("/", response_model=ReceiptListResponse) async def list_receipts( + response: Response, status: Optional[ReceiptStatus] = None, direction: Optional[ReceiptDirection] = None, company_id: Optional[int] = None, @@ -133,6 +134,10 @@ async def list_receipts( selected_company: SelectedCompany = None, ): """Get paginated list of receipts with filters.""" + # Disable browser caching to always get fresh data + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + response.headers["Pragma"] = "no-cache" + from datetime import date as date_type filters = ReceiptFilter( @@ -152,11 +157,16 @@ async def list_receipts( @router.get("/pending", response_model=List[ReceiptResponse]) async def list_pending_receipts( + response: Response, company_id: Optional[int] = None, session: AsyncSession = Depends(get_session), selected_company: SelectedCompany = None, ): """Get all receipts pending review (for accountant view).""" + # Disable browser caching to always get fresh data + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + response.headers["Pragma"] = "no-cache" + receipts = await ReceiptCRUD.get_pending_review( session, company_id or selected_company ) @@ -165,6 +175,7 @@ async def list_pending_receipts( @router.get("/stats") async def get_receipt_stats( + response: Response, company_id: Optional[int] = None, my_receipts: bool = False, session: AsyncSession = Depends(get_session), @@ -172,6 +183,10 @@ async def get_receipt_stats( current_user: CurrentUser = Depends(get_current_user), ): """Get receipt statistics.""" + # Disable browser caching to always get fresh data + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + response.headers["Pragma"] = "no-cache" + return await ReceiptCRUD.get_stats( session, company_id or selected_company, @@ -182,9 +197,14 @@ async def get_receipt_stats( @router.get("/{receipt_id}", response_model=ReceiptResponse) async def get_receipt( receipt_id: int, + response: Response, session: AsyncSession = Depends(get_session), ): """Get receipt details with attachments and accounting entries.""" + # Disable browser caching to always get fresh data + response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" + response.headers["Pragma"] = "no-cache" + receipt = await ReceiptService.get_receipt(session, receipt_id) if not receipt: diff --git a/backend/modules/data_entry/services/ocr/ocr_worker_pool.py b/backend/modules/data_entry/services/ocr/ocr_worker_pool.py index fd86bf2..679e838 100644 --- a/backend/modules/data_entry/services/ocr/ocr_worker_pool.py +++ b/backend/modules/data_entry/services/ocr/ocr_worker_pool.py @@ -75,9 +75,10 @@ class OCRWorkerPool: self._sync_lock = mp.Lock() # Register cleanup handlers + # NOTE: Only use atexit, NOT signal handlers! + # Signal handlers interfere with FastAPI's shutdown handling. + # FastAPI's shutdown event calls stop_job_worker() which calls shutdown(). atexit.register(self._cleanup_on_exit) - signal.signal(signal.SIGTERM, self._signal_handler) - signal.signal(signal.SIGINT, self._signal_handler) self._initialized = True logger.info("[OCRWorkerPool] Singleton instance created") diff --git a/backend/modules/data_entry/services/ocr/validation.py b/backend/modules/data_entry/services/ocr/validation.py index 27233e5..1bc6f9b 100644 --- a/backend/modules/data_entry/services/ocr/validation.py +++ b/backend/modules/data_entry/services/ocr/validation.py @@ -1004,7 +1004,7 @@ class OCRValidationEngine: for replacement in candidates + all_digits: candidate = cui_digits[:pos] + replacement + cui_digits[pos+1:] if CUIChecksumRule.validate_checksum(candidate): - print(f"[CUI Repair] Fixed {cui_digits} → {candidate} (position {pos}: {original_digit}→{replacement})", flush=True) + print(f"[CUI Repair] Fixed {cui_digits} -> {candidate} (position {pos}: {original_digit}->{replacement})", flush=True) return candidate # No single-digit fix found @@ -1164,7 +1164,7 @@ class OCRValidationEngine: if CUIChecksumRule.validate_checksum(cui_digits): match = await lookup_cui_in_db(cui_digits) if match: - print(f"[Fuzzy CUI] Exact match found: {cui} → {match[0]} ({match[1]})", flush=True) + print(f"[Fuzzy CUI] Exact match found: {cui} -> {match[0]} ({match[1]})", flush=True) return match # Valid checksum but not in DB - return as-is (it might be a new supplier) return None @@ -1214,7 +1214,7 @@ class OCRValidationEngine: # Check if this corrected CUI exists in database match = await lookup_cui_in_db(candidate) if match: - print(f"[Fuzzy CUI] DB match: {cui} → {match[0]} ({match[1]}) [pos {pos}: {original_digit}→{replacement}]", flush=True) + print(f"[Fuzzy CUI] DB match: {cui} -> {match[0]} ({match[1]}) [pos {pos}: {original_digit}->{replacement}]", flush=True) return match # No match found in database @@ -1269,7 +1269,7 @@ class OCRValidationEngine: if not name_words: return None - print(f"[Fuzzy Name] Searching for vendor: '{vendor_name}' → keywords: {name_words}", flush=True) + print(f"[Fuzzy Name] Searching for vendor: '{vendor_name}' -> keywords: {name_words}", flush=True) # Build search pattern - use first significant word primary_word = name_words[0] diff --git a/backend/modules/data_entry/services/ocr_extractor.py b/backend/modules/data_entry/services/ocr_extractor.py index 867d762..f2ac6a3 100644 --- a/backend/modules/data_entry/services/ocr_extractor.py +++ b/backend/modules/data_entry/services/ocr_extractor.py @@ -1475,7 +1475,7 @@ class ReceiptExtractor: # e.g., from 14.921492, extract 14.92 try: corrected_amount = Decimal(f"{int_part}.{dec_part[:2]}") - print(f"[TVA Validation] Corrected concatenation error: {amount} → {corrected_amount}", flush=True) + print(f"[TVA Validation] Corrected concatenation error: {amount} -> {corrected_amount}", flush=True) entry['amount'] = corrected_amount except InvalidOperation: pass diff --git a/backend/modules/data_entry/services/ocr_service.py b/backend/modules/data_entry/services/ocr_service.py index d18d38e..893e483 100644 --- a/backend/modules/data_entry/services/ocr_service.py +++ b/backend/modules/data_entry/services/ocr_service.py @@ -195,7 +195,7 @@ class OCRService: del images return True, "OCR complete (fast mode)", extraction else: - print("[OCR] → Step 1 incomplete, continuing to Step 2...", flush=True) + print("[OCR] -> Step 1 incomplete, continuing to Step 2...", flush=True) except Exception as e: print(f"[OCR] PaddleOCR light failed: {e}", flush=True) extraction = ExtractionResult() @@ -251,7 +251,7 @@ class OCRService: del images return True, "OCR complete (paddle dual)", extraction else: - print("[OCR] → Step 2 incomplete, continuing to Step 3 (Tesseract)...", flush=True) + print("[OCR] -> Step 2 incomplete, continuing to Step 3 (Tesseract)...", flush=True) except Exception as e: print(f"[OCR] PaddleOCR medium failed: {e}", flush=True) # Cleanup on error diff --git a/backend/modules/reports/routers/cache.py b/backend/modules/reports/routers/cache.py index f33f395..195de57 100644 --- a/backend/modules/reports/routers/cache.py +++ b/backend/modules/reports/routers/cache.py @@ -14,7 +14,7 @@ from shared.auth.dependencies import get_current_user from shared.auth.models import CurrentUser from ..cache import get_cache, get_event_monitor, toggle_event_monitor -router = APIRouter(prefix="/cache", tags=["cache"]) +router = APIRouter(tags=["cache"]) # Pydantic Models diff --git a/backend/modules/telegram/routers/auth_codes.py b/backend/modules/telegram/routers/auth_codes.py index b60ab20..50d2387 100644 --- a/backend/modules/telegram/routers/auth_codes.py +++ b/backend/modules/telegram/routers/auth_codes.py @@ -19,7 +19,7 @@ from shared.auth.jwt_handler import jwt_handler from shared.database.oracle_pool import oracle_pool # Telegram bot internal API URL (running on same server) -TELEGRAM_BOT_INTERNAL_API = os.getenv("TELEGRAM_BOT_INTERNAL_API", "http://localhost:8002") +TELEGRAM_BOT_INTERNAL_API = os.getenv("TELEGRAM_BOT_INTERNAL_API", "http://localhost:8000/api/telegram") router = APIRouter(redirect_slashes=False) diff --git a/backend/run-with-restart.sh b/backend/run-with-restart.sh old mode 100644 new mode 100755 index c20de3e..62582fb --- a/backend/run-with-restart.sh +++ b/backend/run-with-restart.sh @@ -2,6 +2,10 @@ # Wrapper script that auto-restarts uvicorn on crash # Usage: ./run-with-restart.sh [port] [log_file] +# Get script directory and activate venv +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/venv/bin/activate" + PORT=${1:-8000} LOG_FILE=${2:-/tmp/unified_backend.log} MAX_RESTARTS=10 diff --git a/deployment/linux/README.md b/deployment/linux/README.md new file mode 100644 index 0000000..cbedf13 --- /dev/null +++ b/deployment/linux/README.md @@ -0,0 +1,215 @@ +# ROA2WEB Linux Deployment + +Deploy ROA2WEB to Windows IIS production server from Linux/LXC (claude-agent). + +## Quick Deploy (TLDR) + +```bash +# Din orice director ROA2WEB (main, worktree, sau branch): +# Exemple locații posibile: +# /workspace/roa2web # clone principal +# /workspace/.worktrees/roa2web/fix/fix-bon # worktree pentru fix +# /workspace/.worktrees/roa2web/feature/xxx # worktree pentru feature + +# Deploy complet (frontend + backend) +./deployment/linux/deploy.sh + +# Doar frontend +./deployment/linux/deploy.sh frontend + +# Doar backend +./deployment/linux/deploy.sh backend + +# Test conexiune SSH +./deployment/linux/deploy.sh test +``` + +**După deploy**: Serverul face auto-deploy în maxim 5 minute (scheduled task). + +## Current Setup (claude-agent LXC) + +SSH-ul este **deja configurat** pe acest LXC: + +```bash +# Verificare conexiune (ar trebui să funcționeze direct) +ssh roa2web-prod "echo OK" +``` + +**Configurare existentă** (`~/.ssh/config`): +``` +Host roa2web-prod + HostName 10.0.20.36 + Port 22122 + User romfast + IdentityFile ~/.ssh/roa2web_deploy +``` + +## Prerequisites + +1. **Node.js 16+** - pentru build frontend +2. **SSH access** - deja configurat pe claude-agent LXC + +## First-Time Setup (doar dacă SSH nu funcționează) + +### 1. Generate SSH Key + +```bash +ssh-keygen -t ed25519 -f ~/.ssh/roa2web_deploy -C "roa2web-deploy-lxc" +``` + +### 2. Configure SSH Host + +Add to `~/.ssh/config`: + +``` +Host roa2web-prod + HostName 10.0.20.36 + Port 22122 + User romfast + IdentityFile ~/.ssh/roa2web_deploy + IdentitiesOnly yes + StrictHostKeyChecking accept-new +``` + +### 3. Add Public Key to Server + +Copy the content of `~/.ssh/roa2web_deploy.pub`: + +```bash +cat ~/.ssh/roa2web_deploy.pub +``` + +On Windows server (via RDP or SSH): +1. Connect: `ssh romfast@10.0.20.36 -p 22122` +2. Add key to `C:\Users\romfast\.ssh\authorized_keys` + +### 4. Test Connection + +```bash +ssh roa2web-prod "echo OK" +``` + +## Usage + +```bash +cd /workspace/.worktrees/roa2web/fix/fix-bon + +# Full deployment (frontend + backend) +./deployment/linux/deploy.sh + +# Frontend only +./deployment/linux/deploy.sh frontend + +# Backend only +./deployment/linux/deploy.sh backend + +# Test SSH connection +./deployment/linux/deploy.sh test +``` + +## How It Works + +``` +[LXC] npm run build → dist/ + ↓ + Package: deploy-package-TIMESTAMP/ + ├── frontend/ + ├── backend/ + ├── shared/ + └── scripts/ + ↓ + SCP → C:\Temp\deploy-TIMESTAMP\ + ↓ +[Server] Check-And-Deploy.ps1 (scheduled task, every 5 min) + ↓ + Auto-deploy to C:\inetpub\wwwroot\roa2web\ +``` + +## Server Configuration + +| Setting | Value | +|---------|-------| +| Host | 10.0.20.36 | +| SSH Port | 22122 | +| User | romfast | +| Remote Path | C:\Temp | +| Install Path | C:\inetpub\wwwroot\roa2web | +| Logs Path | C:\inetpub\wwwroot\roa2web\logs | +| **Scripts Path** | **C:\TEMP\ROA2WEB-Scripts** | + +### Permanent Scripts Location + +Scripturile de deploy (`Check-And-Deploy.ps1`, `ROA2WEB-Console.ps1`) rulează din: +``` +C:\TEMP\ROA2WEB-Scripts\ +``` + +**IMPORTANT**: Când modifici scripturile, trebuie să le copiezi și aici: +```bash +# După deploy.sh, copiază scripturile actualizate în locația permanentă: +ssh roa2web-prod "powershell -Command \"Copy-Item -Path 'C:\\Temp\\deploy-*\\scripts\\*.ps1' -Destination 'C:\\TEMP\\ROA2WEB-Scripts\\' -Force\"" +``` + +### What Deploy Preserves + +Deploy-ul păstrează automat (NU le șterge): +- **`.env`** - Configurația mediului (credențiale, setări) +- **`data/`** - Directorul cu baze de date SQLite: + - `data/receipts/receipts_prod.db` - Bonuri fiscale + - `data/telegram/telegram_prod.db` - Sesiuni Telegram + - `data/cache/` - Cache SQLite + - `data/receipts/uploads/` - Fișiere uploadate + +## Production Logs + +```bash +# View backend stderr (errors) +ssh roa2web-prod "powershell -Command \"Get-Content 'C:\\inetpub\\wwwroot\\roa2web\\logs\\backend-stderr.log' -Tail 100\"" + +# View backend stdout (info logs) +ssh roa2web-prod "powershell -Command \"Get-Content 'C:\\inetpub\\wwwroot\\roa2web\\logs\\backend-stdout.log' -Tail 100\"" + +# Filter OCR errors +ssh roa2web-prod "powershell -Command \"Get-Content 'C:\\inetpub\\wwwroot\\roa2web\\logs\\backend-stderr.log' -Tail 200\"" | grep -i ocr +``` + +## Troubleshooting + +### SSH Connection Failed + +```bash +# Check SSH config +cat ~/.ssh/config + +# Test with verbose output +ssh -v roa2web-prod "echo test" + +# Check if key exists +ls -la ~/.ssh/roa2web_deploy* +``` + +### Build Failed + +```bash +# Check Node.js version +node --version + +# Reinstall dependencies +rm -rf node_modules +npm install +``` + +### Transfer Failed + +```bash +# Test SCP manually +scp test.txt roa2web-prod:C:/Temp/ + +# Check Windows firewall (port 22122) +``` + +## Related Documentation + +- [Windows Deployment](../windows/docs/WINDOWS_DEPLOYMENT.md) +- [Two-Tier IIS Architecture](../windows/docs/TWO-TIER-IIS-DEPLOYMENT.md) +- [ROA2WEB Console](../windows/scripts/ROA2WEB-Console.ps1) diff --git a/deployment/linux/deploy.sh b/deployment/linux/deploy.sh new file mode 100755 index 0000000..ea2b073 --- /dev/null +++ b/deployment/linux/deploy.sh @@ -0,0 +1,381 @@ +#!/bin/bash +# +# ROA2WEB Linux Deployment Script +# Builds and deploys to Windows IIS server via SSH/SCP +# +# Usage: +# ./deploy.sh # Full deploy (frontend + backend) +# ./deploy.sh frontend # Frontend only +# ./deploy.sh backend # Backend only +# ./deploy.sh test # Test SSH connection only +# + +set -e + +# ============================================================================= +# CONFIGURATION +# ============================================================================= + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" + +# SSH Configuration (matches deploy-config.json) +SSH_HOST="roa2web-prod" # Uses ~/.ssh/config +REMOTE_PATH="C:/Temp" + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' # No Color + +# ============================================================================= +# HELPER FUNCTIONS +# ============================================================================= + +log_step() { + echo -e "\n${CYAN}[*] $1${NC}" >&2 +} + +log_success() { + echo -e "${GREEN} [OK] $1${NC}" >&2 +} + +log_error() { + echo -e "${RED} [ERROR] $1${NC}" >&2 +} + +log_warning() { + echo -e "${YELLOW} [WARN] $1${NC}" >&2 +} + +log_info() { + echo -e " [INFO] $1" >&2 +} + +# ============================================================================= +# SSH CONNECTION TEST +# ============================================================================= + +test_ssh_connection() { + log_step "Testing SSH connection to $SSH_HOST..." + + if ssh -o ConnectTimeout=10 "$SSH_HOST" "echo 'Connection successful'" 2>/dev/null; then + log_success "SSH connection working" + return 0 + else + log_error "SSH connection failed" + echo "" + echo "Please ensure:" + echo " 1. SSH key is configured in ~/.ssh/config" + echo " 2. Public key is added to server's authorized_keys" + echo " 3. Server is reachable on port 22122" + echo "" + echo "SSH config should contain:" + echo " Host roa2web-prod" + echo " HostName 10.0.20.36" + echo " Port 22122" + echo " User Administrator" + echo " IdentityFile ~/.ssh/roa2web_deploy" + return 1 + fi +} + +# ============================================================================= +# BUILD FRONTEND +# ============================================================================= + +build_frontend() { + log_step "Building frontend..." + + cd "$PROJECT_ROOT" + + # Check Node.js + if ! command -v node &> /dev/null; then + log_error "Node.js not found. Please install Node.js 16+" + exit 1 + fi + + NODE_VERSION=$(node --version) + log_info "Node.js: $NODE_VERSION" + + # Check if node_modules exists + if [ ! -d "node_modules" ]; then + log_step "Installing npm dependencies..." + npm install + fi + + # Build production + log_step "Running production build..." + NODE_ENV=production npm run build + + if [ ! -d "dist" ]; then + log_error "Build failed: dist/ directory not created" + exit 1 + fi + + # Count files + FILE_COUNT=$(find dist -type f | wc -l) + TOTAL_SIZE=$(du -sh dist | cut -f1) + log_success "Build completed: $FILE_COUNT files ($TOTAL_SIZE)" + + # Verify web.config + if [ -f "dist/web.config" ]; then + log_success "web.config present in build" + else + log_warning "web.config NOT found in build" + fi +} + +# ============================================================================= +# CREATE DEPLOYMENT PACKAGE +# ============================================================================= + +create_package() { + local COMPONENT=$1 + local TIMESTAMP=$(date +%Y%m%d-%H%M%S) + local PACKAGE_DIR="$PROJECT_ROOT/deploy-package-$TIMESTAMP" + + log_step "Creating deployment package..." + log_info "Package: $PACKAGE_DIR" + + mkdir -p "$PACKAGE_DIR" + + # Frontend + if [ "$COMPONENT" = "all" ] || [ "$COMPONENT" = "frontend" ]; then + log_step "Packaging frontend..." + mkdir -p "$PACKAGE_DIR/frontend" + cp -r "$PROJECT_ROOT/dist/"* "$PACKAGE_DIR/frontend/" + log_success "Frontend packaged" + fi + + # Backend + if [ "$COMPONENT" = "all" ] || [ "$COMPONENT" = "backend" ]; then + log_step "Packaging backend..." + mkdir -p "$PACKAGE_DIR/backend" + + # Copy backend files (excluding venv, __pycache__, logs, .env) + rsync -av --progress \ + --exclude 'venv' \ + --exclude '__pycache__' \ + --exclude '*.pyc' \ + --exclude '*.pyo' \ + --exclude '.pytest_cache' \ + --exclude 'logs' \ + --exclude '.env' \ + --exclude '.env.local' \ + --exclude '*.log' \ + "$PROJECT_ROOT/backend/" "$PACKAGE_DIR/backend/" + + # Copy .env.example if exists + if [ -f "$PROJECT_ROOT/backend/.env.example" ]; then + cp "$PROJECT_ROOT/backend/.env.example" "$PACKAGE_DIR/backend/" + fi + + log_success "Backend packaged" + fi + + # Shared modules + if [ "$COMPONENT" = "all" ] || [ "$COMPONENT" = "backend" ]; then + log_step "Packaging shared modules..." + mkdir -p "$PACKAGE_DIR/shared" + + rsync -av --progress \ + --exclude '__pycache__' \ + --exclude '*.pyc' \ + --exclude 'tests' \ + "$PROJECT_ROOT/shared/" "$PACKAGE_DIR/shared/" + + log_success "Shared modules packaged" + fi + + # Config templates + log_step "Packaging config templates..." + if [ -d "$PROJECT_ROOT/deployment/config" ]; then + mkdir -p "$PACKAGE_DIR/config" + cp -r "$PROJECT_ROOT/deployment/config/"* "$PACKAGE_DIR/config/" 2>/dev/null || true + fi + + # Deployment scripts + log_step "Packaging deployment scripts..." + mkdir -p "$PACKAGE_DIR/scripts" + + SCRIPTS=( + "ROA2WEB-Console.ps1" + "Install-ROA2WEB.ps1" + "Check-And-Deploy.ps1" + ) + + for script in "${SCRIPTS[@]}"; do + if [ -f "$PROJECT_ROOT/deployment/windows/scripts/$script" ]; then + cp "$PROJECT_ROOT/deployment/windows/scripts/$script" "$PACKAGE_DIR/scripts/" + fi + done + + log_success "Deployment scripts packaged" + + # Create README + cat > "$PACKAGE_DIR/README.txt" << EOF +================================================================================ + ROA2WEB DEPLOYMENT PACKAGE + Generated: $(date '+%Y-%m-%d %H:%M:%S') + From: Linux/LXC deployment script +================================================================================ + +CONTENTS: +--------- + backend/ Unified FastAPI backend + frontend/ Vue.js SPA (production build) + shared/ Shared Python modules + config/ Configuration templates + scripts/ PowerShell deployment scripts + +DEPLOYMENT: +----------- +Server will auto-deploy within 5 minutes (Check-And-Deploy.ps1 scheduled task) + +Or manually: + cd scripts + .\ROA2WEB-Console.ps1 -NonInteractive -Action DeployAll + +================================================================================ +EOF + + # Calculate package size + PACKAGE_SIZE=$(du -sh "$PACKAGE_DIR" | cut -f1) + FILE_COUNT=$(find "$PACKAGE_DIR" -type f | wc -l) + + log_success "Package created: $FILE_COUNT files ($PACKAGE_SIZE)" + + # Return path via global variable (avoid stdout pollution from rsync) + CREATED_PACKAGE_DIR="$PACKAGE_DIR" +} + +# ============================================================================= +# TRANSFER TO SERVER +# ============================================================================= + +transfer_to_server() { + local PACKAGE_DIR=$1 + local TIMESTAMP=$(date +%Y%m%d-%H%M%S) + local REMOTE_DEPLOY_DIR="deploy-$TIMESTAMP" + local REMOTE_FULL_PATH="C:\\Temp\\$REMOTE_DEPLOY_DIR" + + log_step "Transferring to server..." + log_info "Remote path: C:\\Temp\\$REMOTE_DEPLOY_DIR" + + # Create remote directory + log_step "Creating remote directory..." + ssh "$SSH_HOST" "New-Item -ItemType Directory -Path 'C:\\Temp\\$REMOTE_DEPLOY_DIR' -Force" + log_success "Remote directory created" + + # Transfer files one directory at a time (Bitvise SCP compatibility) + log_step "Uploading files via SCP..." + + for dir in "$PACKAGE_DIR"/*/; do + if [ -d "$dir" ]; then + local dirname=$(basename "$dir") + log_info "Uploading $dirname/..." + scp -r "$dir" "roa2web-prod:C:\\Temp\\$REMOTE_DEPLOY_DIR\\$dirname" + fi + done + + # Upload root files (README, etc.) + for file in "$PACKAGE_DIR"/*; do + if [ -f "$file" ]; then + local filename=$(basename "$file") + scp "$file" "roa2web-prod:C:\\Temp\\$REMOTE_DEPLOY_DIR\\$filename" + fi + done + + log_success "Transfer completed" + + # Verify transfer + log_step "Verifying transfer..." + REMOTE_FILE_COUNT=$(ssh "$SSH_HOST" "(Get-ChildItem -Path 'C:\\Temp\\$REMOTE_DEPLOY_DIR' -Recurse -File).Count") + log_success "Remote files: $REMOTE_FILE_COUNT" + + echo "" + echo "==========================================" + echo -e "${GREEN} DEPLOYMENT PACKAGE UPLOADED${NC}" + echo "==========================================" + echo "" + echo " Remote path: C:\\Temp\\$REMOTE_DEPLOY_DIR" + echo "" + echo " Server will auto-deploy within 5 minutes." + echo " Or manually deploy:" + echo " ssh $SSH_HOST" + echo " cd C:\\Temp\\$REMOTE_DEPLOY_DIR\\scripts" + echo " .\\ROA2WEB-Console.ps1" + echo "" +} + +# ============================================================================= +# CLEANUP +# ============================================================================= + +cleanup_local_package() { + local PACKAGE_DIR=$1 + + log_step "Cleaning up local package..." + rm -rf "$PACKAGE_DIR" + log_success "Local package removed" +} + +# ============================================================================= +# MAIN +# ============================================================================= + +main() { + local COMPONENT=${1:-all} + + echo "" + echo "========================================" + echo " ROA2WEB Linux Deployment Script" + echo " Component: $COMPONENT" + echo "========================================" + echo "" + + # Handle test command + if [ "$COMPONENT" = "test" ]; then + test_ssh_connection + exit $? + fi + + # Validate component + case $COMPONENT in + all|frontend|backend) + ;; + *) + echo "Usage: $0 [all|frontend|backend|test]" + exit 1 + ;; + esac + + # Test SSH connection first + if ! test_ssh_connection; then + exit 1 + fi + + # Build frontend if needed + if [ "$COMPONENT" = "all" ] || [ "$COMPONENT" = "frontend" ]; then + build_frontend + fi + + # Create package (sets CREATED_PACKAGE_DIR global variable) + create_package "$COMPONENT" + + # Transfer to server + transfer_to_server "$CREATED_PACKAGE_DIR" + + # Cleanup + cleanup_local_package "$CREATED_PACKAGE_DIR" + + echo "" + echo -e "${GREEN}Deployment completed successfully!${NC}" + echo "" +} + +# Run main with all arguments +main "$@" diff --git a/deployment/windows/scripts/Check-And-Deploy.ps1 b/deployment/windows/scripts/Check-And-Deploy.ps1 index dfa03f9..5fdb5e3 100644 --- a/deployment/windows/scripts/Check-And-Deploy.ps1 +++ b/deployment/windows/scripts/Check-And-Deploy.ps1 @@ -301,6 +301,10 @@ function Invoke-Deployment { # Capture exit code IMMEDIATELY (before any other command that might reset it) $exitCode = $LASTEXITCODE + # Run OCR dependency check with auto-install + Write-Log -Message "Checking and installing OCR dependencies..." -Level "INFO" + & $consoleScript -NonInteractive -Action InstallOCR 2>&1 | ForEach-Object { Write-Log -Message $_ -Level "INFO" } + Pop-Location # Check if exit code indicates success (0 = success) diff --git a/deployment/windows/scripts/Install-ROA2WEB.ps1 b/deployment/windows/scripts/Install-ROA2WEB.ps1 index 4d27067..8fa0d1a 100644 --- a/deployment/windows/scripts/Install-ROA2WEB.ps1 +++ b/deployment/windows/scripts/Install-ROA2WEB.ps1 @@ -72,6 +72,8 @@ $script:Config = @{ FrontendPath = Join-Path $InstallPath "frontend" LogsPath = Join-Path $InstallPath "logs" TempPath = Join-Path $InstallPath "temp" + # IMPORTANT: venv is OUTSIDE InstallPath to survive deployments! + VenvPath = "C:\inetpub\wwwroot\roa2web-venv" PythonVersion = $PythonVersion ServicePort = $ServicePort IISSiteName = $IISSiteName @@ -302,24 +304,46 @@ function New-DirectoryStructure { } function Install-PythonDependencies { - Write-Step "Installing Python dependencies..." + Write-Step "Setting up Python virtual environment..." $requirementsPath = Join-Path $Config.BackendPath "requirements.txt" + $venvPath = $Config.VenvPath + $venvPython = Join-Path $venvPath "Scripts\python.exe" + $venvPip = Join-Path $venvPath "Scripts\pip.exe" + # Create venv if it doesn't exist + if (-not (Test-Path $venvPython)) { + Write-Step "Creating virtual environment at $venvPath..." + try { + & python -m venv $venvPath + Write-Success "Virtual environment created" + } catch { + throw "Failed to create virtual environment: $_" + } + } else { + Write-Success "Virtual environment already exists" + } + + # Upgrade pip in venv + Write-Step "Upgrading pip in virtual environment..." + try { + & $venvPython -m pip install --upgrade pip + Write-Success "Pip upgraded" + } catch { + Write-Warning "Could not upgrade pip: $_" + } + + # Install dependencies if (-not (Test-Path $requirementsPath)) { Write-Warning "requirements.txt not found at $requirementsPath" Write-Warning "Please copy backend files first, then run this script again" return } + Write-Step "Installing Python dependencies in virtual environment..." try { - # Upgrade pip first - & python -m pip install --upgrade pip - - # Install dependencies - & python -m pip install -r $requirementsPath - - Write-Success "Python dependencies installed successfully" + & $venvPip install -r $requirementsPath + Write-Success "Python dependencies installed successfully in venv" } catch { throw "Failed to install Python dependencies: $_" } @@ -359,16 +383,21 @@ function New-WindowsService { Write-Success "Existing service removed" } - # Get Python path - $pythonPath = (Get-Command python).Source + # Get Python path from venv + $venvPython = Join-Path $Config.VenvPath "Scripts\python.exe" + + if (-not (Test-Path $venvPython)) { + throw "Virtual environment Python not found at $venvPython. Run Install-PythonDependencies first." + } + $uvicornModule = "uvicorn" $appModule = "main:app" # NSSM service creation try { - # Install service + # Install service using venv Python # NOTE: Using --workers 1 because Telegram bot requires single instance (polling conflict) - & nssm install $Config.ServiceName $pythonPath "-m" $uvicornModule $appModule "--host" "127.0.0.1" "--port" $Config.ServicePort.ToString() "--workers" "1" + & nssm install $Config.ServiceName $venvPython "-m" $uvicornModule $appModule "--host" "127.0.0.1" "--port" $Config.ServicePort.ToString() "--workers" "1" # Set service configuration & nssm set $Config.ServiceName DisplayName $Config.ServiceDisplayName @@ -530,6 +559,7 @@ function Show-Summary { Write-Host "`nInstallation Details:" -ForegroundColor Yellow Write-Host " Install Path: $($Config.InstallPath)" Write-Host " Backend Path: $($Config.BackendPath)" + Write-Host " Virtual Env: $($Config.VenvPath)" Write-Host " Frontend Path: $($Config.FrontendPath)" Write-Host " Service Name: $($Config.ServiceName)" Write-Host " Service Port: $($Config.ServicePort)" diff --git a/deployment/windows/scripts/ROA2WEB-Console.ps1 b/deployment/windows/scripts/ROA2WEB-Console.ps1 index ef01399..b931da2 100644 --- a/deployment/windows/scripts/ROA2WEB-Console.ps1 +++ b/deployment/windows/scripts/ROA2WEB-Console.ps1 @@ -54,7 +54,7 @@ param( [ValidateSet("DeployBackend", "DeployFrontend", "DeployAll", "StartService", "StopService", "RestartService", - "Status", "ViewLogs")] + "Status", "ViewLogs", "CheckOCR", "InstallOCR")] [string]$Action = "", [string]$PackagePath = "" @@ -84,6 +84,8 @@ $script:Config = @{ # Installation Paths InstallRoot = "C:\inetpub\wwwroot\roa2web" BackendPath = "C:\inetpub\wwwroot\roa2web\backend" + # IMPORTANT: venv is OUTSIDE roa2web to survive deployments! + VenvPath = "C:\inetpub\wwwroot\roa2web-venv" FrontendPath = "C:\inetpub\wwwroot\roa2web\frontend" SharedPath = "C:\inetpub\wwwroot\roa2web\shared" ConfigPath = "C:\inetpub\wwwroot\roa2web\config" @@ -132,6 +134,128 @@ function Write-Info { Write-Host " [*] $Message" -ForegroundColor Gray } +function Get-PythonPaths { + <# + .SYNOPSIS + Returns Python and pip paths, preferring venv if available + #> + $venvPython = Join-Path $Config.VenvPath "Scripts\python.exe" + $venvPip = Join-Path $Config.VenvPath "Scripts\pip.exe" + + if (Test-Path $venvPython) { + return @{ + Python = $venvPython + Pip = $venvPip + IsVenv = $true + } + } else { + # Fallback to global Python + $globalPython = (Get-Command python -ErrorAction SilentlyContinue).Source + $globalPip = (Get-Command pip -ErrorAction SilentlyContinue).Source + return @{ + Python = $globalPython + Pip = $globalPip + IsVenv = $false + } + } +} + +function Initialize-Venv { + <# + .SYNOPSIS + Creates virtual environment at external location if it doesn't exist. + External location (C:\inetpub\wwwroot\roa2web-venv) survives deployments. + #> + $venvPath = $Config.VenvPath + $venvPython = Join-Path $venvPath "Scripts\python.exe" + $venvPip = Join-Path $venvPath "Scripts\pip.exe" + + # If venv exists and is valid (pip works), we're good + if (Test-Path $venvPython) { + # Verify pip is functional (not broken by move) + try { + $pipTest = & $venvPip --version 2>&1 + if ($LASTEXITCODE -eq 0) { + Write-Success "Virtual environment already exists at $venvPath" + return $true + } else { + Write-Warning "Venv exists but pip is broken, recreating..." + Remove-Item -Path $venvPath -Recurse -Force -ErrorAction SilentlyContinue + } + } catch { + Write-Warning "Venv exists but pip test failed, recreating..." + Remove-Item -Path $venvPath -Recurse -Force -ErrorAction SilentlyContinue + } + } + + Write-Step "Creating virtual environment at external location..." + Write-Info "Path: $venvPath (survives deployments)" + try { + $globalPython = (Get-Command python -ErrorAction Stop).Source + & $globalPython -m venv $venvPath + if (Test-Path $venvPython) { + Write-Success "Virtual environment created" + + # Upgrade pip + Write-Step "Upgrading pip in venv..." + & $venvPython -m pip install --upgrade pip 2>&1 | Out-Null + Write-Success "Pip upgraded" + + return $true + } else { + Write-Error "Failed to create virtual environment" + return $false + } + } catch { + Write-Error "Failed to create venv: $_" + return $false + } +} + +function Update-ServiceToUseVenv { + <# + .SYNOPSIS + Updates NSSM service to use venv Python + #> + $venvPython = Join-Path $Config.VenvPath "Scripts\python.exe" + + if (-not (Test-Path $venvPython)) { + Write-Warning "Venv Python not found: $venvPython" + return $false + } + + # Check if nssm is available + $nssmPath = Get-Command nssm -ErrorAction SilentlyContinue + if (-not $nssmPath) { + Write-Warning "NSSM not found in PATH" + return $false + } + + try { + # Get current application path + $currentApp = & nssm get $Config.ServiceName Application 2>&1 + + if ($currentApp -eq $venvPython) { + Write-Success "Service already configured to use venv Python" + return $true + } + + Write-Step "Updating service to use venv Python..." + + # Stop service first + Stop-ROAService | Out-Null + + # Update service application + & nssm set $Config.ServiceName Application $venvPython + Write-Success "Service updated to use: $venvPython" + + return $true + } catch { + Write-Error "Failed to update service: $_" + return $false + } +} + function Wait-ForKeyPress { Write-Host "`nPress any key to continue..." -ForegroundColor Gray $null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") @@ -304,6 +428,294 @@ function Test-ServiceHealth { } } +# ============================================================================= +# OCR DEPENDENCY CHECK +# ============================================================================= + +function Test-OCRDependencies { + param( + [switch]$AutoInstall, + [switch]$Silent + ) + + if (-not $Silent) { + Write-Host "`n" + ("=" * 70) -ForegroundColor Cyan + Write-Host " OCR Dependencies Check$(if ($AutoInstall) { ' (Auto-Install Enabled)' })" -ForegroundColor Cyan + Write-Host ("=" * 70) -ForegroundColor Cyan + } + + $allOk = $true + + # Get Python paths (prefer venv) + $pyPaths = Get-PythonPaths + + # Check Python/venv + if (-not $Silent) { Write-Step "Checking Python installation..." } + + if ($pyPaths.IsVenv) { + if (-not $Silent) { + $venvPythonVersion = & $pyPaths.Python --version 2>&1 + Write-Success "Virtual environment: $($Config.VenvPath)" + Write-Success "Python (venv): $venvPythonVersion" + } + # Ensure service uses venv Python + if ($AutoInstall) { + # Install requirements.txt if exists + $requirementsFile = Join-Path $Config.BackendPath "requirements.txt" + if (Test-Path $requirementsFile) { + if (-not $Silent) { Write-Step "Installing base requirements in venv..." } + try { + & $pyPaths.Pip install -r $requirementsFile 2>&1 | Out-Null + if (-not $Silent) { Write-Success "Base requirements installed" } + } catch { + if (-not $Silent) { Write-Warning "Failed to install requirements: $_" } + } + } + + if (-not $Silent) { Write-Step "Ensuring service uses venv Python..." } + $serviceUpdated = Update-ServiceToUseVenv + if ($serviceUpdated -and -not $Silent) { + Write-Success "Service configured to use venv" + } + } + } else { + # No venv - create one if AutoInstall + if ($AutoInstall) { + if (-not $Silent) { Write-Warning "Virtual environment not found - creating..." } + $venvCreated = Initialize-Venv + if ($venvCreated) { + # Refresh paths + $pyPaths = Get-PythonPaths + + # Update service to use venv Python + if (-not $Silent) { Write-Step "Updating service to use virtual environment..." } + Update-ServiceToUseVenv | Out-Null + } else { + if (-not $Silent) { Write-Error "Could not create virtual environment" } + return $false + } + } else { + # Check global Python + if ($pyPaths.Python) { + $pythonVersion = cmd /c "python --version 2>&1" + if (-not $Silent) { + Write-Warning "Using global Python (venv recommended)" + Write-Success "Python: $pythonVersion" + } + } else { + if (-not $Silent) { Write-Error "Python not found" } + return $false + } + } + } + + # Determine pip executable to use + $pipExe = if ($pyPaths.Pip -and (Test-Path $pyPaths.Pip)) { "`"$($pyPaths.Pip)`"" } else { "pip" } + if (-not $Silent -and $pyPaths.IsVenv) { Write-Info "Using pip from venv: $pipExe" } + + # Check and optionally install Python packages + if (-not $Silent) { Write-Step "Checking Python OCR packages..." } + + $packages = @( + @{ Name = "torch"; Package = "torch"; Required = $true; Description = "PyTorch (for docTR)" }, + @{ Name = "torchvision"; Package = "torchvision"; Required = $true; Description = "TorchVision (for docTR)" }, + @{ Name = "python-doctr"; Package = "python-doctr"; Required = $true; Description = "docTR OCR engine" }, + @{ Name = "pytesseract"; Package = "pytesseract"; Required = $true; Description = "Tesseract Python wrapper" }, + @{ Name = "paddleocr"; Package = "paddleocr"; Required = $true; Description = "PaddleOCR engine" } + ) + + foreach ($pkg in $packages) { + # Use venv pip to check packages + $pipOutput = cmd /c "$pipExe show $($pkg.Package) 2>&1" + $isInstalled = $pipOutput -match "Version:" + + if ($isInstalled) { + $versionLine = $pipOutput | Where-Object { $_ -match "^Version:" } + $version = if ($versionLine) { ($versionLine -split ":")[1].Trim() } else { "unknown" } + if (-not $Silent) { Write-Success "$($pkg.Package): $version" } + } else { + if ($pkg.Required) { + if ($AutoInstall) { + if (-not $Silent) { Write-Warning "$($pkg.Package): NOT INSTALLED - Installing..." } + try { + # Use venv pip to install + $installCmd = "$pipExe install `"$($pkg.Name)`"" + if (-not $Silent) { Write-Info " Running: $installCmd" } + $installResult = Invoke-Expression "cmd /c $installCmd 2>&1" + # Verify installation + $verifyOutput = cmd /c "$pipExe show $($pkg.Package) 2>&1" + if ($verifyOutput -match "Version:") { + if (-not $Silent) { Write-Success "$($pkg.Package): Installed successfully" } + } else { + if (-not $Silent) { + Write-Error "$($pkg.Package): Installation FAILED" + # Show last few lines of pip output for debugging + $errorLines = ($installResult | Select-Object -Last 5) -join "`n" + if ($errorLines) { + Write-Host " Pip output:" -ForegroundColor Gray + Write-Host " $errorLines" -ForegroundColor Gray + } + Write-Info " Try manually: $pipExe install `"$($pkg.Name)`"" + } + $allOk = $false + } + } catch { + if (-not $Silent) { Write-Error "$($pkg.Package): Installation error - $_" } + $allOk = $false + } + } else { + if (-not $Silent) { + Write-Error "$($pkg.Package): NOT INSTALLED - $($pkg.Description)" + Write-Info " Install with: pip install $($pkg.Name)" + } + $allOk = $false + } + } else { + if (-not $Silent) { Write-Warning "$($pkg.Package): Not installed (optional)" } + } + } + } + + # Check external tools + if (-not $Silent) { Write-Step "Checking external OCR tools..." } + + # Check for Chocolatey (used for auto-install) + $chocoAvailable = $null -ne (Get-Command choco -ErrorAction SilentlyContinue) + + # Install Chocolatey if needed and AutoInstall is enabled + if ($AutoInstall -and -not $chocoAvailable) { + if (-not $Silent) { Write-Warning "Chocolatey: NOT FOUND - Installing..." } + try { + Set-ExecutionPolicy Bypass -Scope Process -Force + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 + Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) + # Refresh environment + $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") + $chocoAvailable = $null -ne (Get-Command choco -ErrorAction SilentlyContinue) + if ($chocoAvailable) { + if (-not $Silent) { Write-Success "Chocolatey: Installed successfully" } + } else { + if (-not $Silent) { Write-Warning "Chocolatey: Installed but not in PATH - restart PowerShell" } + } + } catch { + if (-not $Silent) { Write-Error "Chocolatey: Installation failed - $_" } + } + } + + # Tesseract + $tesseractPath = Get-Command tesseract -ErrorAction SilentlyContinue + if ($tesseractPath) { + $tessVersion = cmd /c "tesseract --version 2>&1" | Select-Object -First 1 + if (-not $Silent) { + Write-Success "Tesseract: $tessVersion" + Write-Info " Path: $($tesseractPath.Source)" + } + } else { + if ($AutoInstall) { + if ($chocoAvailable) { + if (-not $Silent) { Write-Warning "Tesseract: NOT FOUND - Installing via Chocolatey..." } + try { + $result = cmd /c "choco install tesseract -y 2>&1" + # Refresh PATH + $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") + $tesseractPath = Get-Command tesseract -ErrorAction SilentlyContinue + if ($tesseractPath) { + if (-not $Silent) { Write-Success "Tesseract: Installed successfully" } + } else { + if (-not $Silent) { Write-Error "Tesseract: Installation completed but not in PATH - restart PowerShell" } + $allOk = $false + } + } catch { + if (-not $Silent) { Write-Error "Tesseract: Chocolatey install failed - $_" } + $allOk = $false + } + } else { + if (-not $Silent) { + Write-Error "Tesseract: NOT FOUND - Chocolatey not available for auto-install" + Write-Info " Install Chocolatey first: https://chocolatey.org/install" + Write-Info " Then run: choco install tesseract -y" + } + $allOk = $false + } + } else { + if (-not $Silent) { + Write-Error "Tesseract: NOT FOUND in PATH" + Write-Info " Install with: choco install tesseract -y" + Write-Info " Or download from: https://github.com/UB-Mannheim/tesseract/wiki" + } + $allOk = $false + } + } + + # Poppler (for PDF support) + $popplerPath = Get-Command pdftoppm -ErrorAction SilentlyContinue + if ($popplerPath) { + if (-not $Silent) { + Write-Success "Poppler: Found (pdftoppm)" + Write-Info " Path: $($popplerPath.Source)" + } + } else { + if ($AutoInstall -and $chocoAvailable) { + if (-not $Silent) { Write-Warning "Poppler: NOT FOUND - Installing via Chocolatey..." } + try { + $result = cmd /c "choco install poppler -y 2>&1" + # Refresh PATH + $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path", "User") + $popplerPath = Get-Command pdftoppm -ErrorAction SilentlyContinue + if ($popplerPath) { + if (-not $Silent) { Write-Success "Poppler: Installed successfully" } + } else { + if (-not $Silent) { Write-Warning "Poppler: Installation completed but not in PATH - restart PowerShell" } + } + } catch { + if (-not $Silent) { Write-Warning "Poppler: Chocolatey install failed - $_" } + } + } else { + if (-not $Silent) { + Write-Warning "Poppler: NOT FOUND in PATH (required for PDF OCR)" + Write-Info " Install with: choco install poppler -y" + } + } + } + + # Check .env OCR settings + if (-not $Silent) { + Write-Step "Checking OCR configuration in .env..." + $envPath = Join-Path $Config.BackendPath ".env" + if (Test-Path $envPath) { + $envContent = Get-Content $envPath -Raw + + $ocrSettings = @( + "OCR_ENABLE_PADDLEOCR", + "OCR_ENABLE_TESSERACT", + "OCR_DEFAULT_ENGINE", + "OCR_WORKERS" + ) + + foreach ($setting in $ocrSettings) { + if ($envContent -match "$setting\s*=\s*(.+)") { + Write-Info " $setting = $($Matches[1].Trim())" + } else { + Write-Warning " ${setting}: NOT CONFIGURED" + } + } + } else { + Write-Warning ".env file not found at: $envPath" + } + + Write-Host "`n" + ("=" * 70) -ForegroundColor Cyan + if ($allOk) { + Write-Host " Result: All required OCR dependencies are installed" -ForegroundColor Green + } else { + Write-Host " Result: Some required dependencies are MISSING" -ForegroundColor Red + Write-Host " Run with -AutoInstall to install missing packages" -ForegroundColor Yellow + } + Write-Host ("=" * 70) -ForegroundColor Cyan + } + + return $allOk +} + # ============================================================================= # BACKUP FUNCTIONS # ============================================================================= @@ -400,6 +812,23 @@ function Deploy-Backend { Write-Info "Preserving .env file" } + # Preserve data directory (contains SQLite databases with production data!) + $dataDir = Join-Path $Config.BackendPath "data" + $dataTempPath = Join-Path $env:TEMP "roa2web-data-backup-$(Get-Date -Format 'yyyyMMddHHmmss')" + $dataBackup = $null + if (Test-Path $dataDir) { + Write-Info "Preserving data directory (SQLite databases, uploads, cache)" + Copy-Item -Path $dataDir -Destination $dataTempPath -Recurse -Force + $dataBackup = $dataTempPath + } + + # Delete old venv inside backend if it exists (it has hardcoded paths and can't be moved) + $oldVenvPath = Join-Path $Config.BackendPath "venv" + if (Test-Path $oldVenvPath) { + Write-Info "Removing old venv from backend directory (will use external venv)" + Remove-Item -Path $oldVenvPath -Recurse -Force -ErrorAction SilentlyContinue + } + Remove-Item -Path $Config.BackendPath -Recurse -Force Copy-Item -Path $sourceBe -Destination $Config.BackendPath -Recurse -Force @@ -408,6 +837,18 @@ function Deploy-Backend { Set-Content -Path $envFile -Value $envBackup -Force Write-Success ".env file restored" } + + # Restore data directory + if ($dataBackup -and (Test-Path $dataBackup)) { + # Remove the empty data dir from package and restore the preserved one + $newDataDir = Join-Path $Config.BackendPath "data" + if (Test-Path $newDataDir) { + Remove-Item -Path $newDataDir -Recurse -Force + } + Copy-Item -Path $dataBackup -Destination $newDataDir -Recurse -Force + Remove-Item -Path $dataBackup -Recurse -Force -ErrorAction SilentlyContinue + Write-Success "Data directory restored (SQLite databases preserved)" + } } else { Copy-Item -Path $sourceBe -Destination $Config.BackendPath -Recurse -Force } @@ -423,10 +864,81 @@ function Deploy-Backend { Write-Success "Shared modules deployed" } + # Setup virtual environment + Write-Step "Setting up Python virtual environment..." + $venvCreated = Initialize-Venv + if (-not $venvCreated) { + Write-Warning "Could not create/verify virtual environment" + } + + # Install requirements.txt + $requirementsFile = Join-Path $Config.BackendPath "requirements.txt" + if (Test-Path $requirementsFile) { + $pyPaths = Get-PythonPaths + if ($pyPaths.IsVenv) { + Write-Step "Installing Python dependencies in venv..." + $pipExe = $pyPaths.Pip + + # Verify pip is functional before installing + $pipVersion = & $pipExe --version 2>&1 + if ($LASTEXITCODE -ne 0) { + Write-Error "Pip is not functional: $pipVersion" + Write-Info "Try: Remove-Item -Recurse $($Config.VenvPath); then redeploy" + } else { + Write-Info "Using pip: $pipVersion" + + # Install requirements (ignore warnings, check exit code only) + $oldErrorAction = $ErrorActionPreference + $ErrorActionPreference = "Continue" + $pipOutput = & $pipExe install -r $requirementsFile 2>&1 + $pipExitCode = $LASTEXITCODE + $ErrorActionPreference = $oldErrorAction + + # Log warnings but don't fail on them + $pipOutput | ForEach-Object { + if ($_ -match "WARNING:") { + Write-Warning $_ + } + } + + if ($pipExitCode -eq 0) { + # Verify uvicorn installed (critical dependency) + $uvicornCheck = & $pipExe show uvicorn 2>&1 + if ($LASTEXITCODE -eq 0) { + Write-Success "Python dependencies installed" + } else { + Write-Error "Dependencies install failed - uvicorn not found" + Write-Info "Manual fix: $pipExe install -r $requirementsFile" + } + } else { + Write-Error "Pip install failed with exit code $pipExitCode" + Write-Info "Manual fix: $pipExe install -r $requirementsFile" + } + } + } else { + Write-Warning "No venv found - skipping requirements.txt installation" + } + } + + # Update service to use venv Python + $pyPaths = Get-PythonPaths + if ($pyPaths.IsVenv) { + Update-ServiceToUseVenv | Out-Null + } + # Start service Start-Sleep -Seconds 2 if (Start-ROAService) { Write-Success "Backend deployment completed successfully" + + # Check and auto-install OCR dependencies after deployment + Write-Step "Checking and installing OCR dependencies..." + $ocrOk = Test-OCRDependencies -AutoInstall + if (-not $ocrOk) { + Write-Warning "Some OCR dependencies could not be installed automatically" + Write-Info "Manual installation may be required for external tools (Tesseract, Poppler)" + } + return $true } else { Write-Warning "Backend deployed but service start failed" @@ -670,6 +1182,7 @@ function Show-MainMenu { Write-Host " === Monitoring ===" -ForegroundColor Cyan Write-Host " [7] View Status" -ForegroundColor White Write-Host " [8] View Logs" -ForegroundColor White + Write-Host " [9] Check/Install OCR Dependencies" -ForegroundColor White Write-Host "" Write-Host " [Q] Quit" -ForegroundColor Red Write-Host "" @@ -738,9 +1251,23 @@ function Show-MainMenu { Wait-ForKeyPress return "Continue" } + "9" { + $ocrOk = Test-OCRDependencies + if (-not $ocrOk) { + Write-Host "" + Write-Host " Install missing dependencies? (Y/N): " -ForegroundColor Yellow -NoNewline + $installChoice = Read-Host + if ($installChoice -eq "Y" -or $installChoice -eq "y") { + Write-Host "" + Test-OCRDependencies -AutoInstall | Out-Null + } + } + Wait-ForKeyPress + return "Continue" + } "Q" { return "Quit" } default { - Write-Host "Invalid choice. Please select 1-8 or Q." -ForegroundColor Red + Write-Host "Invalid choice. Please select 1-9 or Q." -ForegroundColor Red } } } while ($true) @@ -806,6 +1333,14 @@ function Main { Show-Logs exit 0 } + "CheckOCR" { + $success = Test-OCRDependencies + exit $(if ($success) { 0 } else { 1 }) + } + "InstallOCR" { + $success = Test-OCRDependencies -AutoInstall + exit $(if ($success) { 0 } else { 1 }) + } } } diff --git a/setup_production.sh b/setup_production.sh old mode 100644 new mode 100755 diff --git a/shared/routes/system.py b/shared/routes/system.py new file mode 100644 index 0000000..616ee8e --- /dev/null +++ b/shared/routes/system.py @@ -0,0 +1,155 @@ +""" +System routes for server monitoring and logs. +""" + +import os +from pathlib import Path +from typing import Optional +from collections import deque + +from fastapi import APIRouter, Depends, Query, HTTPException +from pydantic import BaseModel + +from shared.auth.dependencies import get_current_user, CurrentUser + + +class LogEntry(BaseModel): + """Single log entry.""" + line: str + level: Optional[str] = None + + +class LogsResponse(BaseModel): + """Response with log entries.""" + file: str + lines: list[str] + total_lines: int + showing: int + logs_path: Optional[str] = None + file_exists: bool = True + file_size_kb: Optional[float] = None + + +def create_system_router() -> APIRouter: + """ + Create system router for logs and monitoring. + """ + router = APIRouter() + + def get_logs_path() -> Path: + """Get logs directory path based on environment.""" + # Windows production: C:\inetpub\wwwroot\roa2web\logs + # Development: backend/logs or ./logs + if os.name == 'nt': # Windows + prod_path = Path(r"C:\inetpub\wwwroot\roa2web\logs") + if prod_path.exists(): + return prod_path + + # Development fallback + dev_paths = [ + Path(__file__).parent.parent.parent / "backend" / "logs", + Path(__file__).parent.parent.parent / "logs", + Path("./logs"), + ] + for path in dev_paths: + if path.exists(): + return path + + return Path("./logs") + + @router.get("/logs", response_model=LogsResponse) + async def get_logs( + file: str = Query(default="backend-stderr", description="Log file: backend-stderr or backend-stdout"), + lines: int = Query(default=100, ge=10, le=1000, description="Number of lines to return"), + filter: Optional[str] = Query(default=None, description="Filter text (case-insensitive)"), + current_user: CurrentUser = Depends(get_current_user) + ): + """ + Get server log entries. + + Args: + file: Log file name (backend-stderr or backend-stdout) + lines: Number of lines to return (10-1000) + filter: Optional filter text + + Returns: + LogsResponse with log lines + """ + # Validate file name to prevent path traversal + allowed_files = ["backend-stderr", "backend-stdout"] + if file not in allowed_files: + raise HTTPException(status_code=400, detail=f"Invalid file. Allowed: {allowed_files}") + + logs_path = get_logs_path() + log_file = logs_path / f"{file}.log" + logs_path_str = str(logs_path.resolve()) + + if not log_file.exists(): + return LogsResponse( + file=file, + lines=[f"Log file not found: {log_file}"], + total_lines=0, + showing=0, + logs_path=logs_path_str, + file_exists=False, + file_size_kb=0 + ) + + try: + # Get file size + file_size_kb = round(log_file.stat().st_size / 1024, 2) + + # Read file and get last N lines efficiently + with open(log_file, 'r', encoding='utf-8', errors='replace') as f: + # Use deque for efficient tail operation + all_lines = deque(f, maxlen=lines * 2 if filter else lines) + + # Apply filter if provided + if filter: + filter_lower = filter.lower() + filtered_lines = [line.rstrip() for line in all_lines if filter_lower in line.lower()] + result_lines = list(filtered_lines)[-lines:] + else: + result_lines = [line.rstrip() for line in all_lines][-lines:] + + return LogsResponse( + file=file, + lines=result_lines, + total_lines=len(result_lines), + showing=len(result_lines), + logs_path=logs_path_str, + file_exists=True, + file_size_kb=file_size_kb + ) + + except Exception as e: + raise HTTPException(status_code=500, detail=f"Error reading logs: {str(e)}") + + @router.get("/logs/available") + async def get_available_logs( + current_user: CurrentUser = Depends(get_current_user) + ): + """ + Get list of available log files. + """ + logs_path = get_logs_path() + + if not logs_path.exists(): + return {"logs_path": str(logs_path), "files": [], "exists": False} + + log_files = [] + for f in logs_path.glob("*.log"): + stat = f.stat() + log_files.append({ + "name": f.stem, + "size_kb": round(stat.st_size / 1024, 1), + "modified": stat.st_mtime + }) + + return { + "logs_path": str(logs_path), + "files": sorted(log_files, key=lambda x: x["name"]), + "exists": True + } + + return router diff --git a/src/modules/data-entry/components/ocr/OCRUploadZone.vue b/src/modules/data-entry/components/ocr/OCRUploadZone.vue index 1a37db7..b67352c 100644 --- a/src/modules/data-entry/components/ocr/OCRUploadZone.vue +++ b/src/modules/data-entry/components/ocr/OCRUploadZone.vue @@ -286,6 +286,7 @@ const processOCR = async () => { const pollJobStatus = async (id) => { const LONG_POLL_TIMEOUT = 30 // seconds const MAX_TOTAL_TIME = 120 // 2 minutes max + const MIN_POLL_INTERVAL = 500 // Minimum 500ms between polls (safety net, backend does long-poll) const startTime = Date.now() const poll = async () => { @@ -298,11 +299,15 @@ const pollJobStatus = async (id) => { return } + const pollStartTime = Date.now() + try { // Long-poll with 30s server timeout, 35s axios timeout + // Add cache-busting param to prevent browser/IIS caching const response = await api.get(`/ocr/jobs/${id}/wait`, { - params: { timeout: LONG_POLL_TIMEOUT }, - timeout: (LONG_POLL_TIMEOUT + 5) * 1000 + params: { timeout: LONG_POLL_TIMEOUT, _t: Date.now() }, + timeout: (LONG_POLL_TIMEOUT + 5) * 1000, + headers: { 'Cache-Control': 'no-cache' } }) const job = response.data @@ -310,7 +315,12 @@ const pollJobStatus = async (id) => { queuePosition.value = job.queue_position estimatedWait.value = job.estimated_wait_seconds - console.log('📊 OCR Long-Poll:', { status: job.status, position: job.queue_position }) + const pollDuration = Date.now() - pollStartTime + console.log('📊 OCR Long-Poll:', { + status: job.status, + position: job.queue_position, + pollDurationMs: pollDuration + }) if (job.status === 'completed') { processing.value = false @@ -336,6 +346,13 @@ const pollJobStatus = async (id) => { // Still pending/processing - long-poll again if (processing.value) { + // Prevent rapid polling: if response came back too fast, wait before next poll + const pollDuration = Date.now() - pollStartTime + if (pollDuration < MIN_POLL_INTERVAL) { + const waitTime = MIN_POLL_INTERVAL - pollDuration + console.log(`⏳ Waiting ${waitTime}ms before next poll (preventing rapid polling)`) + await new Promise(resolve => setTimeout(resolve, waitTime)) + } await poll() } @@ -349,8 +366,18 @@ const pollJobStatus = async (id) => { return } - // Real error + // Real error - wait before retry to prevent rapid error loops console.error('🔴 Poll Error:', err.message) + + // Check if we should retry or give up + const elapsed = (Date.now() - startTime) / 1000 + if (elapsed < MAX_TOTAL_TIME && processing.value) { + console.log('🔄 Retrying after error...') + await new Promise(resolve => setTimeout(resolve, MIN_POLL_INTERVAL)) + await poll() + return + } + processing.value = false error.value = 'Eroare la verificarea starii job-ului' emit('error', error.value) diff --git a/src/modules/data-entry/services/api.js b/src/modules/data-entry/services/api.js index 375945b..60c3699 100644 --- a/src/modules/data-entry/services/api.js +++ b/src/modules/data-entry/services/api.js @@ -1,15 +1,7 @@ import axios from 'axios' -// Detect if we're accessing from remote (not localhost) -const isRemoteAccess = !['localhost', '127.0.0.1'].includes(window.location.hostname) - -// For remote access, use direct backend URL (same host, port 8000) -// For local access, use proxy through Vite -const baseURL = isRemoteAccess - ? `http://${window.location.hostname}:8000/api/data-entry` - : import.meta.env.BASE_URL + 'api/data-entry' - -console.log('📡 API Config:', { isRemoteAccess, baseURL, hostname: window.location.hostname }) +// Use relative path - works with both Vite dev proxy and IIS production proxy +const baseURL = import.meta.env.BASE_URL + 'api/data-entry' const api = axios.create({ baseURL, diff --git a/src/modules/reports/components/layout/HamburgerMenu.vue b/src/modules/reports/components/layout/HamburgerMenu.vue index 4521be2..0f37a29 100644 --- a/src/modules/reports/components/layout/HamburgerMenu.vue +++ b/src/modules/reports/components/layout/HamburgerMenu.vue @@ -15,7 +15,7 @@
@@ -87,7 +98,7 @@