feat: Add Linux deployment scripts and server logs view
- Add deployment/linux/ with deploy.sh for deploying from Claude-Agent LXC to Windows server - Add ServerLogsView.vue for viewing server logs from frontend - Add shared/routes/system.py for system health endpoints - Update CLAUDE.md with quick deploy instructions - Improve Windows deployment scripts (ROA2WEB-Console.ps1) - Fix OCR service validation and worker pool improvements - Update environment config examples - Various script permission and startup fixes 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
12
CLAUDE.md
12
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'])
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
4
backend/run-with-restart.sh
Normal file → Executable file
4
backend/run-with-restart.sh
Normal file → Executable file
@@ -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
|
||||
|
||||
215
deployment/linux/README.md
Normal file
215
deployment/linux/README.md
Normal file
@@ -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)
|
||||
381
deployment/linux/deploy.sh
Executable file
381
deployment/linux/deploy.sh
Executable file
@@ -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 "$@"
|
||||
@@ -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)
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
0
setup_production.sh
Normal file → Executable file
0
setup_production.sh
Normal file → Executable file
155
shared/routes/system.py
Normal file
155
shared/routes/system.py
Normal file
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<ul class="menu-list">
|
||||
<li class="menu-item">
|
||||
<router-link
|
||||
to="/dashboard"
|
||||
to="/reports/dashboard"
|
||||
class="menu-link"
|
||||
:class="{ active: $route.name === 'Dashboard' }"
|
||||
@click="closeMenu"
|
||||
@@ -26,7 +26,7 @@
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<router-link
|
||||
to="/invoices"
|
||||
to="/reports/invoices"
|
||||
class="menu-link"
|
||||
:class="{ active: $route.name === 'Invoices' }"
|
||||
@click="closeMenu"
|
||||
@@ -37,9 +37,9 @@
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<router-link
|
||||
to="/bank-cash-register"
|
||||
to="/reports/bank-cash"
|
||||
class="menu-link"
|
||||
:class="{ active: $route.name === 'BankCashRegister' }"
|
||||
:class="{ active: $route.name === 'BankCash' }"
|
||||
@click="closeMenu"
|
||||
>
|
||||
<i class="menu-icon pi pi-money-bill"></i>
|
||||
@@ -48,7 +48,7 @@
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<router-link
|
||||
to="/trial-balance"
|
||||
to="/reports/trial-balance"
|
||||
class="menu-link"
|
||||
:class="{ active: $route.name === 'TrialBalance' }"
|
||||
@click="closeMenu"
|
||||
@@ -66,7 +66,7 @@
|
||||
<ul class="menu-list">
|
||||
<li class="menu-item">
|
||||
<router-link
|
||||
to="/cache-stats"
|
||||
to="/reports/cache-stats"
|
||||
class="menu-link"
|
||||
:class="{ active: $route.name === 'CacheStats' }"
|
||||
@click="closeMenu"
|
||||
@@ -75,6 +75,17 @@
|
||||
<span>Statistici cache</span>
|
||||
</router-link>
|
||||
</li>
|
||||
<li class="menu-item">
|
||||
<router-link
|
||||
to="/reports/server-logs"
|
||||
class="menu-link"
|
||||
:class="{ active: $route.name === 'ServerLogs' }"
|
||||
@click="closeMenu"
|
||||
>
|
||||
<i class="menu-icon pi pi-file-edit"></i>
|
||||
<span>Server Logs</span>
|
||||
</router-link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -87,7 +98,7 @@
|
||||
<ul class="menu-list">
|
||||
<li class="menu-item">
|
||||
<router-link
|
||||
to="/telegram"
|
||||
to="/reports/telegram"
|
||||
class="menu-link"
|
||||
:class="{ active: $route.name === 'Telegram' }"
|
||||
@click="closeMenu"
|
||||
@@ -111,7 +122,7 @@
|
||||
<script>
|
||||
import { computed } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
import { useAuthStore } from "@reports/stores/sharedStores";
|
||||
|
||||
export default {
|
||||
name: "HamburgerMenu",
|
||||
@@ -123,6 +134,8 @@ export default {
|
||||
},
|
||||
emits: ["close"],
|
||||
setup(props, { emit }) {
|
||||
console.log('[HamburgerMenu] Component loaded - Server Logs should be visible in Sistem section');
|
||||
|
||||
const router = useRouter();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
|
||||
389
src/modules/reports/views/ServerLogsView.vue
Normal file
389
src/modules/reports/views/ServerLogsView.vue
Normal file
@@ -0,0 +1,389 @@
|
||||
<template>
|
||||
<div class="server-logs-view">
|
||||
<div class="stats-header">
|
||||
<h1>
|
||||
<i class="pi pi-file-edit"></i>
|
||||
Server Logs
|
||||
</h1>
|
||||
<div class="actions">
|
||||
<Dropdown
|
||||
v-model="selectedFile"
|
||||
:options="logFiles"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
placeholder="Select log file"
|
||||
class="log-file-select"
|
||||
/>
|
||||
<InputText
|
||||
v-model="filterText"
|
||||
placeholder="Filter (e.g., ocr, error)"
|
||||
class="filter-input"
|
||||
@keyup.enter="loadLogs"
|
||||
/>
|
||||
<Dropdown
|
||||
v-model="linesCount"
|
||||
:options="linesOptions"
|
||||
optionLabel="label"
|
||||
optionValue="value"
|
||||
class="lines-select"
|
||||
/>
|
||||
<Button
|
||||
label="Refresh"
|
||||
icon="pi pi-refresh"
|
||||
@click="loadLogs"
|
||||
:loading="loading"
|
||||
/>
|
||||
<Button
|
||||
label="Auto"
|
||||
:icon="autoRefresh ? 'pi pi-pause' : 'pi pi-play'"
|
||||
:severity="autoRefresh ? 'warning' : 'secondary'"
|
||||
@click="toggleAutoRefresh"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Message v-if="error" severity="error" :closable="true" @close="error = null">
|
||||
{{ error }}
|
||||
</Message>
|
||||
|
||||
<Card class="logs-card">
|
||||
<template #title>
|
||||
<div class="logs-title">
|
||||
<span>{{ selectedFile === 'backend-stderr' ? 'Errors & Warnings' : 'Info Logs' }}</span>
|
||||
<Tag :value="`${logs.length} lines`" severity="info" />
|
||||
<Tag v-if="debugInfo.file_size_kb !== null" :value="`${debugInfo.file_size_kb} KB`" severity="secondary" class="ml-2" />
|
||||
<span v-if="autoRefresh" class="auto-refresh-indicator">
|
||||
<i class="pi pi-spin pi-sync"></i> Auto-refresh: {{ autoRefreshInterval }}s
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="debugInfo.logs_path" class="logs-path">
|
||||
<small><i class="pi pi-folder"></i> {{ debugInfo.logs_path }}</small>
|
||||
</div>
|
||||
</template>
|
||||
<template #content>
|
||||
<div v-if="loading && logs.length === 0" class="loading-container">
|
||||
<ProgressSpinner />
|
||||
<p>Loading logs...</p>
|
||||
</div>
|
||||
<div v-else-if="logs.length === 0" class="empty-logs">
|
||||
<i class="pi pi-inbox"></i>
|
||||
<p>No log entries found</p>
|
||||
</div>
|
||||
<div v-else class="logs-container" ref="logsContainer">
|
||||
<pre class="logs-content"><code v-for="(line, index) in logs" :key="index" :class="getLineClass(line)">{{ line }}</code></pre>
|
||||
</div>
|
||||
</template>
|
||||
</Card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||
import Button from 'primevue/button'
|
||||
import Card from 'primevue/card'
|
||||
import Dropdown from 'primevue/dropdown'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import Tag from 'primevue/tag'
|
||||
import axios from 'axios'
|
||||
|
||||
// System API - endpoint separat de reports
|
||||
const systemApi = axios.create({
|
||||
baseURL: import.meta.env.BASE_URL + 'api/system',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
|
||||
// Add auth token
|
||||
systemApi.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('access_token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// State
|
||||
const logs = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
const selectedFile = ref('backend-stderr')
|
||||
const filterText = ref('')
|
||||
const linesCount = ref(100)
|
||||
const autoRefresh = ref(false)
|
||||
const autoRefreshInterval = ref(5)
|
||||
const logsContainer = ref(null)
|
||||
const debugInfo = ref({
|
||||
logs_path: null,
|
||||
file_exists: true,
|
||||
file_size_kb: null
|
||||
})
|
||||
|
||||
let refreshTimer = null
|
||||
|
||||
// Options
|
||||
const logFiles = [
|
||||
{ label: 'Errors (stderr)', value: 'backend-stderr' },
|
||||
{ label: 'Info (stdout)', value: 'backend-stdout' }
|
||||
]
|
||||
|
||||
const linesOptions = [
|
||||
{ label: '50 lines', value: 50 },
|
||||
{ label: '100 lines', value: 100 },
|
||||
{ label: '200 lines', value: 200 },
|
||||
{ label: '500 lines', value: 500 }
|
||||
]
|
||||
|
||||
// Methods
|
||||
const loadLogs = async () => {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const params = {
|
||||
file: selectedFile.value,
|
||||
lines: linesCount.value
|
||||
}
|
||||
if (filterText.value) {
|
||||
params.filter = filterText.value
|
||||
}
|
||||
|
||||
console.log('[ServerLogs] Fetching logs from:', systemApi.defaults.baseURL + '/logs', params)
|
||||
const response = await systemApi.get('/logs', { params })
|
||||
console.log('[ServerLogs] Response:', response.data)
|
||||
|
||||
logs.value = response.data.lines || []
|
||||
debugInfo.value = {
|
||||
logs_path: response.data.logs_path || null,
|
||||
file_exists: response.data.file_exists ?? true,
|
||||
file_size_kb: response.data.file_size_kb ?? null
|
||||
}
|
||||
|
||||
// Scroll to bottom
|
||||
await nextTick()
|
||||
if (logsContainer.value) {
|
||||
logsContainer.value.scrollTop = logsContainer.value.scrollHeight
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[ServerLogs] Failed to load logs:', err)
|
||||
console.error('[ServerLogs] Error details:', err.response?.data, err.message)
|
||||
error.value = err.response?.data?.detail || err.message || 'Failed to load logs - check console for details'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getLineClass = (line) => {
|
||||
const lineLower = line.toLowerCase()
|
||||
if (lineLower.includes('error') || lineLower.includes('exception') || lineLower.includes('failed')) {
|
||||
return 'log-error'
|
||||
}
|
||||
if (lineLower.includes('warning') || lineLower.includes('warn')) {
|
||||
return 'log-warning'
|
||||
}
|
||||
if (lineLower.includes('success') || lineLower.includes('completed')) {
|
||||
return 'log-success'
|
||||
}
|
||||
return 'log-info'
|
||||
}
|
||||
|
||||
const toggleAutoRefresh = () => {
|
||||
autoRefresh.value = !autoRefresh.value
|
||||
if (autoRefresh.value) {
|
||||
startAutoRefresh()
|
||||
} else {
|
||||
stopAutoRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
const startAutoRefresh = () => {
|
||||
stopAutoRefresh()
|
||||
refreshTimer = setInterval(() => {
|
||||
loadLogs()
|
||||
}, autoRefreshInterval.value * 1000)
|
||||
}
|
||||
|
||||
const stopAutoRefresh = () => {
|
||||
if (refreshTimer) {
|
||||
clearInterval(refreshTimer)
|
||||
refreshTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watch(selectedFile, () => {
|
||||
loadLogs()
|
||||
})
|
||||
|
||||
watch(linesCount, () => {
|
||||
loadLogs()
|
||||
})
|
||||
|
||||
// Lifecycle
|
||||
onMounted(() => {
|
||||
loadLogs()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopAutoRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.server-logs-view {
|
||||
padding: var(--space-lg);
|
||||
max-width: 1400px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.stats-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-lg);
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.stats-header h1 {
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
font-size: 1.5rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.log-file-select {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.lines-select {
|
||||
min-width: 120px;
|
||||
}
|
||||
|
||||
.logs-card {
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
.logs-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.logs-path {
|
||||
margin-top: var(--space-xs);
|
||||
color: var(--color-text-secondary, #6b7280);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.logs-path i {
|
||||
margin-right: var(--space-xs);
|
||||
}
|
||||
|
||||
.ml-2 {
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.auto-refresh-indicator {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-warning);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--space-xl);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.empty-logs {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: var(--space-xl);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.empty-logs i {
|
||||
font-size: 3rem;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.logs-container {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
background: var(--color-bg-secondary, #1e1e1e);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.logs-content {
|
||||
margin: 0;
|
||||
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.logs-content code {
|
||||
display: block;
|
||||
padding: 2px var(--space-xs);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.log-error {
|
||||
color: #f87171;
|
||||
background: rgba(248, 113, 113, 0.1);
|
||||
}
|
||||
|
||||
.log-warning {
|
||||
color: #fbbf24;
|
||||
background: rgba(251, 191, 36, 0.1);
|
||||
}
|
||||
|
||||
.log-success {
|
||||
color: #34d399;
|
||||
background: rgba(52, 211, 153, 0.1);
|
||||
}
|
||||
|
||||
.log-info {
|
||||
color: #e5e7eb;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.stats-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.actions {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.log-file-select,
|
||||
.filter-input,
|
||||
.lines-select {
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -79,7 +79,22 @@ import { useToast } from "primevue/usetoast";
|
||||
import Button from "primevue/button";
|
||||
import Toast from "primevue/toast";
|
||||
import QRCodeVue from "qrcode.vue";
|
||||
import api from "@reports/services/api";
|
||||
import axios from "axios";
|
||||
|
||||
// Telegram API uses /api/telegram (separate from reports)
|
||||
const telegramApi = axios.create({
|
||||
baseURL: import.meta.env.BASE_URL + 'api/telegram',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
// Add auth token
|
||||
telegramApi.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
const toast = useToast();
|
||||
|
||||
@@ -107,7 +122,7 @@ const generateCode = async () => {
|
||||
showQR.value = false;
|
||||
|
||||
try {
|
||||
const response = await api.post("/telegram/auth/generate-code");
|
||||
const response = await telegramApi.post("/auth/generate-code");
|
||||
linkingCode.value = response.data.linking_code;
|
||||
timeRemaining.value = response.data.expires_in_minutes * 60;
|
||||
|
||||
|
||||
@@ -47,6 +47,12 @@ const routes = [
|
||||
name: 'CacheStats',
|
||||
component: () => import('@reports/views/CacheStatsView.vue'),
|
||||
meta: { requiresAuth: true, title: 'Statistici Cache - ROA2WEB' }
|
||||
},
|
||||
{
|
||||
path: 'server-logs',
|
||||
name: 'ServerLogs',
|
||||
component: () => import('@reports/views/ServerLogsView.vue'),
|
||||
meta: { requiresAuth: true, title: 'Server Logs - ROA2WEB' }
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
0
ssh-tunnel-prod.sh
Normal file → Executable file
0
ssh-tunnel-prod.sh
Normal file → Executable file
0
ssh-tunnel-test.sh
Normal file → Executable file
0
ssh-tunnel-test.sh
Normal file → Executable file
0
start-backend.sh
Normal file → Executable file
0
start-backend.sh
Normal file → Executable file
0
start-frontend.sh
Normal file → Executable file
0
start-frontend.sh
Normal file → Executable file
13
start-prod.sh
Normal file → Executable file
13
start-prod.sh
Normal file → Executable file
@@ -103,6 +103,19 @@ fi
|
||||
|
||||
sleep 2
|
||||
|
||||
# Step 1.5: Check and install poppler-utils (required for PDF OCR)
|
||||
if ! command -v pdftoppm &> /dev/null; then
|
||||
print_warning "poppler-utils not found - required for PDF OCR processing"
|
||||
print_message "Installing poppler-utils..."
|
||||
if sudo apt-get update -qq && sudo apt-get install -y -qq poppler-utils; then
|
||||
print_success "poppler-utils installed"
|
||||
else
|
||||
print_warning "Could not install poppler-utils - PDF OCR may not work"
|
||||
fi
|
||||
else
|
||||
print_success "poppler-utils found ($(pdftoppm -v 2>&1 | head -1))"
|
||||
fi
|
||||
|
||||
# Step 2: Start Unified Backend (8000)
|
||||
print_message "2. Starting Unified Backend on port 8000..."
|
||||
|
||||
|
||||
13
start-test.sh
Normal file → Executable file
13
start-test.sh
Normal file → Executable file
@@ -103,6 +103,19 @@ fi
|
||||
|
||||
sleep 2
|
||||
|
||||
# Step 1.5: Check and install poppler-utils (required for PDF OCR)
|
||||
if ! command -v pdftoppm &> /dev/null; then
|
||||
print_warning "poppler-utils not found - required for PDF OCR processing"
|
||||
print_message "Installing poppler-utils..."
|
||||
if sudo apt-get update -qq && sudo apt-get install -y -qq poppler-utils; then
|
||||
print_success "poppler-utils installed"
|
||||
else
|
||||
print_warning "Could not install poppler-utils - PDF OCR may not work"
|
||||
fi
|
||||
else
|
||||
print_success "poppler-utils found ($(pdftoppm -v 2>&1 | head -1))"
|
||||
fi
|
||||
|
||||
# Step 2: Start Unified Backend (8000)
|
||||
print_message "2. Starting Unified Backend on port 8000..."
|
||||
|
||||
|
||||
0
test-unified-backend.sh
Normal file → Executable file
0
test-unified-backend.sh
Normal file → Executable file
@@ -29,7 +29,7 @@ export default defineConfig({
|
||||
port: 3000,
|
||||
host: true,
|
||||
// Allow Tailscale VPN access for mobile debugging
|
||||
allowedHosts: ['.ts.net'],
|
||||
allowedHosts: ['.ts.net', 'claude-agent'],
|
||||
// Disable file watching to prevent WSL2 deadlock
|
||||
watch: null,
|
||||
// Disable HMR completely
|
||||
|
||||
Reference in New Issue
Block a user