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:
Claude Agent
2026-01-04 00:26:36 +00:00
parent 495790411f
commit 02a8c8682c
39 changed files with 1939 additions and 80 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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:

View File

@@ -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'])

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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")

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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
View 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
View 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 "$@"

View File

@@ -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)

View File

@@ -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)"

View File

@@ -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
View File

155
shared/routes/system.py Normal file
View 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

View File

@@ -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)

View File

@@ -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,

View File

@@ -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();

View 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>

View File

@@ -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;

View File

@@ -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
View File

0
ssh-tunnel-test.sh Normal file → Executable file
View File

0
start-backend.sh Normal file → Executable file
View File

0
start-frontend.sh Normal file → Executable file
View File

13
start-prod.sh Normal file → Executable file
View 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
View 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
status.sh Normal file → Executable file
View File

0
test-unified-backend.sh Normal file → Executable file
View File

View 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