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
|
- `tests/MANUAL_TESTING_CHECKLIST.md` - Manual testing procedures
|
||||||
|
|
||||||
### Deployment
|
### 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/README.md` - Windows deployment quick start
|
||||||
- `deployment/windows/docs/WINDOWS_DEPLOYMENT.md` - Complete Windows guide
|
- `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
|
- **`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
|
ORACLE_ENV=dev
|
||||||
|
|
||||||
# SQLite Database (development)
|
# 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
|
# File uploads
|
||||||
UPLOAD_PATH=data/receipts/uploads
|
DATA_ENTRY_MAX_UPLOAD_SIZE_MB=10
|
||||||
MAX_UPLOAD_SIZE_MB=10
|
|
||||||
|
|
||||||
# Test company (for development testing)
|
# Test company (for development testing)
|
||||||
TEST_COMPANY_ID=110
|
TEST_COMPANY_ID=110
|
||||||
|
|||||||
@@ -90,11 +90,11 @@ CACHE_BENCHMARK_ON_STARTUP=False
|
|||||||
ORACLE_ENV=prod
|
ORACLE_ENV=prod
|
||||||
|
|
||||||
# SQLite Database (production)
|
# 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
|
# File uploads
|
||||||
UPLOAD_PATH=data/receipts/uploads
|
DATA_ENTRY_MAX_UPLOAD_SIZE_MB=10
|
||||||
MAX_UPLOAD_SIZE_MB=10
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# OCR ENGINE CONFIGURATION
|
# OCR ENGINE CONFIGURATION
|
||||||
|
|||||||
@@ -95,11 +95,11 @@ CACHE_BENCHMARK_ON_STARTUP=False
|
|||||||
ORACLE_ENV=test
|
ORACLE_ENV=test
|
||||||
|
|
||||||
# SQLite Database (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
|
# File uploads
|
||||||
UPLOAD_PATH=data/receipts/uploads
|
DATA_ENTRY_MAX_UPLOAD_SIZE_MB=10
|
||||||
MAX_UPLOAD_SIZE_MB=10
|
|
||||||
|
|
||||||
# Test company (for testing)
|
# Test company (for testing)
|
||||||
TEST_COMPANY_ID=110
|
TEST_COMPANY_ID=110
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ from shared.auth.middleware import AuthenticationMiddleware
|
|||||||
from shared.auth.routes import create_auth_router
|
from shared.auth.routes import create_auth_router
|
||||||
from shared.routes.companies import create_companies_router
|
from shared.routes.companies import create_companies_router
|
||||||
from shared.routes.calendar import create_calendar_router
|
from shared.routes.calendar import create_calendar_router
|
||||||
|
from shared.routes.system import create_system_router
|
||||||
|
|
||||||
# Import module router factories
|
# Import module router factories
|
||||||
from backend.modules.reports.routers import create_reports_router
|
from backend.modules.reports.routers import create_reports_router
|
||||||
@@ -364,7 +365,8 @@ app.add_middleware(
|
|||||||
"/api/telegram/auth/verify-email",
|
"/api/telegram/auth/verify-email",
|
||||||
"/api/telegram/auth/login-with-email",
|
"/api/telegram/auth/login-with-email",
|
||||||
"/api/telegram/auth/refresh-token",
|
"/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"])
|
calendar_router = create_calendar_router(oracle_pool, tags=["calendar"])
|
||||||
app.include_router(calendar_router, prefix="/api/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
|
# ROOT & HEALTH ENDPOINTS
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
from fastapi import UploadFile
|
from fastapi import UploadFile
|
||||||
|
|
||||||
from backend.modules.data_entry.db.models.receipt import ReceiptAttachment
|
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:
|
class AttachmentCRUD:
|
||||||
@@ -29,7 +29,7 @@ class AttachmentCRUD:
|
|||||||
"""Get full path for storing file, organized by year/month."""
|
"""Get full path for storing file, organized by year/month."""
|
||||||
now = datetime.utcnow()
|
now = datetime.utcnow()
|
||||||
relative_path = Path(str(now.year)) / f"{now.month:02d}"
|
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
|
# Ensure directory exists
|
||||||
full_path.mkdir(parents=True, exist_ok=True)
|
full_path.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -50,19 +50,19 @@ class AttachmentCRUD:
|
|||||||
relative_path = AttachmentCRUD._get_upload_path(stored_filename)
|
relative_path = AttachmentCRUD._get_upload_path(stored_filename)
|
||||||
|
|
||||||
# Full path for saving
|
# 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
|
# Read file content
|
||||||
content = await file.read()
|
content = await file.read()
|
||||||
file_size = len(content)
|
file_size = len(content)
|
||||||
|
|
||||||
# Validate file size
|
# Validate file size
|
||||||
if file_size > settings.max_upload_size_bytes:
|
if file_size > settings.data_entry_max_upload_size_bytes:
|
||||||
raise ValueError(f"File too large. Maximum size is {settings.max_upload_size_mb}MB")
|
raise ValueError(f"File too large. Maximum size is {settings.data_entry_max_upload_size_mb}MB")
|
||||||
|
|
||||||
# Validate MIME type
|
# Validate MIME type
|
||||||
mime_type = file.content_type or "application/octet-stream"
|
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}")
|
raise ValueError(f"File type not allowed: {mime_type}")
|
||||||
|
|
||||||
# Save file
|
# Save file
|
||||||
@@ -111,7 +111,7 @@ class AttachmentCRUD:
|
|||||||
@staticmethod
|
@staticmethod
|
||||||
def get_file_path(attachment: ReceiptAttachment) -> Path:
|
def get_file_path(attachment: ReceiptAttachment) -> Path:
|
||||||
"""Get full file path for an attachment."""
|
"""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
|
@staticmethod
|
||||||
async def delete(session: AsyncSession, attachment: ReceiptAttachment) -> bool:
|
async def delete(session: AsyncSession, attachment: ReceiptAttachment) -> bool:
|
||||||
|
|||||||
@@ -193,6 +193,14 @@ class ReceiptCRUD:
|
|||||||
"""Update receipt fields."""
|
"""Update receipt fields."""
|
||||||
update_data = data.model_dump(exclude_unset=True)
|
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
|
# Serialize tva_breakdown and payment_methods to JSON string if present
|
||||||
if 'tva_breakdown' in update_data:
|
if 'tva_breakdown' in update_data:
|
||||||
update_data['tva_breakdown'] = _serialize_tva_breakdown(update_data['tva_breakdown'])
|
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 sqlalchemy.orm import sessionmaker
|
||||||
from sqlmodel import SQLModel
|
from sqlmodel import SQLModel
|
||||||
|
|
||||||
from backend.modules.data_entry.config import settings
|
from backend.config import settings
|
||||||
|
|
||||||
|
|
||||||
# Create async engine
|
# Create async engine
|
||||||
# Note: echo=False to disable SQL query logging (too verbose)
|
# Note: echo=False to disable SQL query logging (too verbose)
|
||||||
engine = create_async_engine(
|
engine = create_async_engine(
|
||||||
settings.database_url,
|
settings.data_entry_database_url,
|
||||||
echo=False,
|
echo=False,
|
||||||
future=True,
|
future=True,
|
||||||
)
|
)
|
||||||
@@ -28,7 +28,7 @@ async_session_maker = sessionmaker(
|
|||||||
async def init_db() -> None:
|
async def init_db() -> None:
|
||||||
"""Initialize database - create tables if they don't exist."""
|
"""Initialize database - create tables if they don't exist."""
|
||||||
# Ensure data directory exists
|
# 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)
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
async with engine.begin() as conn:
|
async with engine.begin() as conn:
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from decimal import Decimal
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Optional
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from backend.modules.data_entry.db.database import get_session
|
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)
|
@router.get("/jobs/{job_id}/wait", response_model=OCRJobResponse)
|
||||||
async def wait_for_job_status(
|
async def wait_for_job_status(
|
||||||
job_id: str,
|
job_id: str,
|
||||||
|
response: Response,
|
||||||
timeout: int = Query(default=30, ge=1, le=60, description="Max wait time in seconds"),
|
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),
|
session: AsyncSession = Depends(get_session),
|
||||||
current_user: CurrentUser = Depends(get_current_user)
|
current_user: CurrentUser = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
@@ -198,7 +201,8 @@ async def wait_for_job_status(
|
|||||||
Long-poll for OCR job status change.
|
Long-poll for OCR job status change.
|
||||||
|
|
||||||
Waits until:
|
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)
|
- Timeout expires (returns current status)
|
||||||
|
|
||||||
Recommended client timeout: timeout + 5 seconds
|
Recommended client timeout: timeout + 5 seconds
|
||||||
@@ -206,36 +210,53 @@ async def wait_for_job_status(
|
|||||||
Args:
|
Args:
|
||||||
job_id: Job UUID from POST /extract response
|
job_id: Job UUID from POST /extract response
|
||||||
timeout: Max wait time in seconds (1-60, default 30)
|
timeout: Max wait time in seconds (1-60, default 30)
|
||||||
|
wait_for_terminal: If true, wait until completed/failed only
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
OCRJobResponse with status, queue_position, and result (if completed)
|
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 asyncio
|
||||||
import time
|
import time
|
||||||
|
|
||||||
end_time = time.time() + timeout
|
start_time = time.time()
|
||||||
|
end_time = start_time + timeout
|
||||||
last_status = None
|
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:
|
while time.time() < end_time:
|
||||||
|
iteration += 1
|
||||||
job = await job_queue.get_job(job_id)
|
job = await job_queue.get_job(job_id)
|
||||||
|
|
||||||
if not job:
|
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")
|
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]:
|
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 await get_job_status(job_id, session, current_user)
|
||||||
|
|
||||||
# Return if status changed from last check
|
# Return on status change (unless wait_for_terminal is set)
|
||||||
if last_status is not None and job.status != last_status:
|
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)
|
return await get_job_status(job_id, session, current_user)
|
||||||
|
|
||||||
last_status = job.status
|
last_status = job.status
|
||||||
|
|
||||||
# Wait 1 second before next internal check
|
# Wait 500ms before next internal check (faster polling for better responsiveness)
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(0.5)
|
||||||
|
|
||||||
# Timeout - return current status
|
# 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)
|
return await get_job_status(job_id, session, current_user)
|
||||||
|
|
||||||
|
|
||||||
@@ -417,7 +438,7 @@ async def _apply_fuzzy_cui_matching(
|
|||||||
if match:
|
if match:
|
||||||
corrected_cui, supplier_name = match
|
corrected_cui, supplier_name = match
|
||||||
if corrected_cui != extraction_data.cui:
|
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
|
extraction_data.cui = corrected_cui
|
||||||
# Also set partner_name if not already set
|
# Also set partner_name if not already set
|
||||||
if not extraction_data.partner_name:
|
if not extraction_data.partner_name:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
from typing import List, Optional, Annotated
|
from typing import List, Optional, Annotated
|
||||||
from pathlib import Path
|
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 fastapi.responses import FileResponse
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -120,6 +120,7 @@ async def create_receipt(
|
|||||||
|
|
||||||
@router.get("/", response_model=ReceiptListResponse)
|
@router.get("/", response_model=ReceiptListResponse)
|
||||||
async def list_receipts(
|
async def list_receipts(
|
||||||
|
response: Response,
|
||||||
status: Optional[ReceiptStatus] = None,
|
status: Optional[ReceiptStatus] = None,
|
||||||
direction: Optional[ReceiptDirection] = None,
|
direction: Optional[ReceiptDirection] = None,
|
||||||
company_id: Optional[int] = None,
|
company_id: Optional[int] = None,
|
||||||
@@ -133,6 +134,10 @@ async def list_receipts(
|
|||||||
selected_company: SelectedCompany = None,
|
selected_company: SelectedCompany = None,
|
||||||
):
|
):
|
||||||
"""Get paginated list of receipts with filters."""
|
"""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
|
from datetime import date as date_type
|
||||||
|
|
||||||
filters = ReceiptFilter(
|
filters = ReceiptFilter(
|
||||||
@@ -152,11 +157,16 @@ async def list_receipts(
|
|||||||
|
|
||||||
@router.get("/pending", response_model=List[ReceiptResponse])
|
@router.get("/pending", response_model=List[ReceiptResponse])
|
||||||
async def list_pending_receipts(
|
async def list_pending_receipts(
|
||||||
|
response: Response,
|
||||||
company_id: Optional[int] = None,
|
company_id: Optional[int] = None,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
selected_company: SelectedCompany = None,
|
selected_company: SelectedCompany = None,
|
||||||
):
|
):
|
||||||
"""Get all receipts pending review (for accountant view)."""
|
"""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(
|
receipts = await ReceiptCRUD.get_pending_review(
|
||||||
session, company_id or selected_company
|
session, company_id or selected_company
|
||||||
)
|
)
|
||||||
@@ -165,6 +175,7 @@ async def list_pending_receipts(
|
|||||||
|
|
||||||
@router.get("/stats")
|
@router.get("/stats")
|
||||||
async def get_receipt_stats(
|
async def get_receipt_stats(
|
||||||
|
response: Response,
|
||||||
company_id: Optional[int] = None,
|
company_id: Optional[int] = None,
|
||||||
my_receipts: bool = False,
|
my_receipts: bool = False,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
@@ -172,6 +183,10 @@ async def get_receipt_stats(
|
|||||||
current_user: CurrentUser = Depends(get_current_user),
|
current_user: CurrentUser = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Get receipt statistics."""
|
"""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(
|
return await ReceiptCRUD.get_stats(
|
||||||
session,
|
session,
|
||||||
company_id or selected_company,
|
company_id or selected_company,
|
||||||
@@ -182,9 +197,14 @@ async def get_receipt_stats(
|
|||||||
@router.get("/{receipt_id}", response_model=ReceiptResponse)
|
@router.get("/{receipt_id}", response_model=ReceiptResponse)
|
||||||
async def get_receipt(
|
async def get_receipt(
|
||||||
receipt_id: int,
|
receipt_id: int,
|
||||||
|
response: Response,
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
"""Get receipt details with attachments and accounting entries."""
|
"""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)
|
receipt = await ReceiptService.get_receipt(session, receipt_id)
|
||||||
|
|
||||||
if not receipt:
|
if not receipt:
|
||||||
|
|||||||
@@ -75,9 +75,10 @@ class OCRWorkerPool:
|
|||||||
self._sync_lock = mp.Lock()
|
self._sync_lock = mp.Lock()
|
||||||
|
|
||||||
# Register cleanup handlers
|
# 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)
|
atexit.register(self._cleanup_on_exit)
|
||||||
signal.signal(signal.SIGTERM, self._signal_handler)
|
|
||||||
signal.signal(signal.SIGINT, self._signal_handler)
|
|
||||||
|
|
||||||
self._initialized = True
|
self._initialized = True
|
||||||
logger.info("[OCRWorkerPool] Singleton instance created")
|
logger.info("[OCRWorkerPool] Singleton instance created")
|
||||||
|
|||||||
@@ -1004,7 +1004,7 @@ class OCRValidationEngine:
|
|||||||
for replacement in candidates + all_digits:
|
for replacement in candidates + all_digits:
|
||||||
candidate = cui_digits[:pos] + replacement + cui_digits[pos+1:]
|
candidate = cui_digits[:pos] + replacement + cui_digits[pos+1:]
|
||||||
if CUIChecksumRule.validate_checksum(candidate):
|
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
|
return candidate
|
||||||
|
|
||||||
# No single-digit fix found
|
# No single-digit fix found
|
||||||
@@ -1164,7 +1164,7 @@ class OCRValidationEngine:
|
|||||||
if CUIChecksumRule.validate_checksum(cui_digits):
|
if CUIChecksumRule.validate_checksum(cui_digits):
|
||||||
match = await lookup_cui_in_db(cui_digits)
|
match = await lookup_cui_in_db(cui_digits)
|
||||||
if match:
|
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
|
return match
|
||||||
# Valid checksum but not in DB - return as-is (it might be a new supplier)
|
# Valid checksum but not in DB - return as-is (it might be a new supplier)
|
||||||
return None
|
return None
|
||||||
@@ -1214,7 +1214,7 @@ class OCRValidationEngine:
|
|||||||
# Check if this corrected CUI exists in database
|
# Check if this corrected CUI exists in database
|
||||||
match = await lookup_cui_in_db(candidate)
|
match = await lookup_cui_in_db(candidate)
|
||||||
if match:
|
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
|
return match
|
||||||
|
|
||||||
# No match found in database
|
# No match found in database
|
||||||
@@ -1269,7 +1269,7 @@ class OCRValidationEngine:
|
|||||||
if not name_words:
|
if not name_words:
|
||||||
return None
|
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
|
# Build search pattern - use first significant word
|
||||||
primary_word = name_words[0]
|
primary_word = name_words[0]
|
||||||
|
|||||||
@@ -1475,7 +1475,7 @@ class ReceiptExtractor:
|
|||||||
# e.g., from 14.921492, extract 14.92
|
# e.g., from 14.921492, extract 14.92
|
||||||
try:
|
try:
|
||||||
corrected_amount = Decimal(f"{int_part}.{dec_part[:2]}")
|
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
|
entry['amount'] = corrected_amount
|
||||||
except InvalidOperation:
|
except InvalidOperation:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -195,7 +195,7 @@ class OCRService:
|
|||||||
del images
|
del images
|
||||||
return True, "OCR complete (fast mode)", extraction
|
return True, "OCR complete (fast mode)", extraction
|
||||||
else:
|
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:
|
except Exception as e:
|
||||||
print(f"[OCR] PaddleOCR light failed: {e}", flush=True)
|
print(f"[OCR] PaddleOCR light failed: {e}", flush=True)
|
||||||
extraction = ExtractionResult()
|
extraction = ExtractionResult()
|
||||||
@@ -251,7 +251,7 @@ class OCRService:
|
|||||||
del images
|
del images
|
||||||
return True, "OCR complete (paddle dual)", extraction
|
return True, "OCR complete (paddle dual)", extraction
|
||||||
else:
|
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:
|
except Exception as e:
|
||||||
print(f"[OCR] PaddleOCR medium failed: {e}", flush=True)
|
print(f"[OCR] PaddleOCR medium failed: {e}", flush=True)
|
||||||
# Cleanup on error
|
# Cleanup on error
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from shared.auth.dependencies import get_current_user
|
|||||||
from shared.auth.models import CurrentUser
|
from shared.auth.models import CurrentUser
|
||||||
from ..cache import get_cache, get_event_monitor, toggle_event_monitor
|
from ..cache import get_cache, get_event_monitor, toggle_event_monitor
|
||||||
|
|
||||||
router = APIRouter(prefix="/cache", tags=["cache"])
|
router = APIRouter(tags=["cache"])
|
||||||
|
|
||||||
|
|
||||||
# Pydantic Models
|
# Pydantic Models
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ from shared.auth.jwt_handler import jwt_handler
|
|||||||
from shared.database.oracle_pool import oracle_pool
|
from shared.database.oracle_pool import oracle_pool
|
||||||
|
|
||||||
# Telegram bot internal API URL (running on same server)
|
# 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)
|
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
|
# Wrapper script that auto-restarts uvicorn on crash
|
||||||
# Usage: ./run-with-restart.sh [port] [log_file]
|
# 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}
|
PORT=${1:-8000}
|
||||||
LOG_FILE=${2:-/tmp/unified_backend.log}
|
LOG_FILE=${2:-/tmp/unified_backend.log}
|
||||||
MAX_RESTARTS=10
|
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)
|
# Capture exit code IMMEDIATELY (before any other command that might reset it)
|
||||||
$exitCode = $LASTEXITCODE
|
$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
|
Pop-Location
|
||||||
|
|
||||||
# Check if exit code indicates success (0 = success)
|
# Check if exit code indicates success (0 = success)
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ $script:Config = @{
|
|||||||
FrontendPath = Join-Path $InstallPath "frontend"
|
FrontendPath = Join-Path $InstallPath "frontend"
|
||||||
LogsPath = Join-Path $InstallPath "logs"
|
LogsPath = Join-Path $InstallPath "logs"
|
||||||
TempPath = Join-Path $InstallPath "temp"
|
TempPath = Join-Path $InstallPath "temp"
|
||||||
|
# IMPORTANT: venv is OUTSIDE InstallPath to survive deployments!
|
||||||
|
VenvPath = "C:\inetpub\wwwroot\roa2web-venv"
|
||||||
PythonVersion = $PythonVersion
|
PythonVersion = $PythonVersion
|
||||||
ServicePort = $ServicePort
|
ServicePort = $ServicePort
|
||||||
IISSiteName = $IISSiteName
|
IISSiteName = $IISSiteName
|
||||||
@@ -302,24 +304,46 @@ function New-DirectoryStructure {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Install-PythonDependencies {
|
function Install-PythonDependencies {
|
||||||
Write-Step "Installing Python dependencies..."
|
Write-Step "Setting up Python virtual environment..."
|
||||||
|
|
||||||
$requirementsPath = Join-Path $Config.BackendPath "requirements.txt"
|
$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)) {
|
if (-not (Test-Path $requirementsPath)) {
|
||||||
Write-Warning "requirements.txt not found at $requirementsPath"
|
Write-Warning "requirements.txt not found at $requirementsPath"
|
||||||
Write-Warning "Please copy backend files first, then run this script again"
|
Write-Warning "Please copy backend files first, then run this script again"
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Write-Step "Installing Python dependencies in virtual environment..."
|
||||||
try {
|
try {
|
||||||
# Upgrade pip first
|
& $venvPip install -r $requirementsPath
|
||||||
& python -m pip install --upgrade pip
|
Write-Success "Python dependencies installed successfully in venv"
|
||||||
|
|
||||||
# Install dependencies
|
|
||||||
& python -m pip install -r $requirementsPath
|
|
||||||
|
|
||||||
Write-Success "Python dependencies installed successfully"
|
|
||||||
} catch {
|
} catch {
|
||||||
throw "Failed to install Python dependencies: $_"
|
throw "Failed to install Python dependencies: $_"
|
||||||
}
|
}
|
||||||
@@ -359,16 +383,21 @@ function New-WindowsService {
|
|||||||
Write-Success "Existing service removed"
|
Write-Success "Existing service removed"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Get Python path
|
# Get Python path from venv
|
||||||
$pythonPath = (Get-Command python).Source
|
$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"
|
$uvicornModule = "uvicorn"
|
||||||
$appModule = "main:app"
|
$appModule = "main:app"
|
||||||
|
|
||||||
# NSSM service creation
|
# NSSM service creation
|
||||||
try {
|
try {
|
||||||
# Install service
|
# Install service using venv Python
|
||||||
# NOTE: Using --workers 1 because Telegram bot requires single instance (polling conflict)
|
# 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
|
# Set service configuration
|
||||||
& nssm set $Config.ServiceName DisplayName $Config.ServiceDisplayName
|
& nssm set $Config.ServiceName DisplayName $Config.ServiceDisplayName
|
||||||
@@ -530,6 +559,7 @@ function Show-Summary {
|
|||||||
Write-Host "`nInstallation Details:" -ForegroundColor Yellow
|
Write-Host "`nInstallation Details:" -ForegroundColor Yellow
|
||||||
Write-Host " Install Path: $($Config.InstallPath)"
|
Write-Host " Install Path: $($Config.InstallPath)"
|
||||||
Write-Host " Backend Path: $($Config.BackendPath)"
|
Write-Host " Backend Path: $($Config.BackendPath)"
|
||||||
|
Write-Host " Virtual Env: $($Config.VenvPath)"
|
||||||
Write-Host " Frontend Path: $($Config.FrontendPath)"
|
Write-Host " Frontend Path: $($Config.FrontendPath)"
|
||||||
Write-Host " Service Name: $($Config.ServiceName)"
|
Write-Host " Service Name: $($Config.ServiceName)"
|
||||||
Write-Host " Service Port: $($Config.ServicePort)"
|
Write-Host " Service Port: $($Config.ServicePort)"
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ param(
|
|||||||
|
|
||||||
[ValidateSet("DeployBackend", "DeployFrontend", "DeployAll",
|
[ValidateSet("DeployBackend", "DeployFrontend", "DeployAll",
|
||||||
"StartService", "StopService", "RestartService",
|
"StartService", "StopService", "RestartService",
|
||||||
"Status", "ViewLogs")]
|
"Status", "ViewLogs", "CheckOCR", "InstallOCR")]
|
||||||
[string]$Action = "",
|
[string]$Action = "",
|
||||||
|
|
||||||
[string]$PackagePath = ""
|
[string]$PackagePath = ""
|
||||||
@@ -84,6 +84,8 @@ $script:Config = @{
|
|||||||
# Installation Paths
|
# Installation Paths
|
||||||
InstallRoot = "C:\inetpub\wwwroot\roa2web"
|
InstallRoot = "C:\inetpub\wwwroot\roa2web"
|
||||||
BackendPath = "C:\inetpub\wwwroot\roa2web\backend"
|
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"
|
FrontendPath = "C:\inetpub\wwwroot\roa2web\frontend"
|
||||||
SharedPath = "C:\inetpub\wwwroot\roa2web\shared"
|
SharedPath = "C:\inetpub\wwwroot\roa2web\shared"
|
||||||
ConfigPath = "C:\inetpub\wwwroot\roa2web\config"
|
ConfigPath = "C:\inetpub\wwwroot\roa2web\config"
|
||||||
@@ -132,6 +134,128 @@ function Write-Info {
|
|||||||
Write-Host " [*] $Message" -ForegroundColor Gray
|
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 {
|
function Wait-ForKeyPress {
|
||||||
Write-Host "`nPress any key to continue..." -ForegroundColor Gray
|
Write-Host "`nPress any key to continue..." -ForegroundColor Gray
|
||||||
$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown")
|
$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
|
# BACKUP FUNCTIONS
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
@@ -400,6 +812,23 @@ function Deploy-Backend {
|
|||||||
Write-Info "Preserving .env file"
|
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
|
Remove-Item -Path $Config.BackendPath -Recurse -Force
|
||||||
Copy-Item -Path $sourceBe -Destination $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
|
Set-Content -Path $envFile -Value $envBackup -Force
|
||||||
Write-Success ".env file restored"
|
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 {
|
} else {
|
||||||
Copy-Item -Path $sourceBe -Destination $Config.BackendPath -Recurse -Force
|
Copy-Item -Path $sourceBe -Destination $Config.BackendPath -Recurse -Force
|
||||||
}
|
}
|
||||||
@@ -423,10 +864,81 @@ function Deploy-Backend {
|
|||||||
Write-Success "Shared modules deployed"
|
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 service
|
||||||
Start-Sleep -Seconds 2
|
Start-Sleep -Seconds 2
|
||||||
if (Start-ROAService) {
|
if (Start-ROAService) {
|
||||||
Write-Success "Backend deployment completed successfully"
|
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
|
return $true
|
||||||
} else {
|
} else {
|
||||||
Write-Warning "Backend deployed but service start failed"
|
Write-Warning "Backend deployed but service start failed"
|
||||||
@@ -670,6 +1182,7 @@ function Show-MainMenu {
|
|||||||
Write-Host " === Monitoring ===" -ForegroundColor Cyan
|
Write-Host " === Monitoring ===" -ForegroundColor Cyan
|
||||||
Write-Host " [7] View Status" -ForegroundColor White
|
Write-Host " [7] View Status" -ForegroundColor White
|
||||||
Write-Host " [8] View Logs" -ForegroundColor White
|
Write-Host " [8] View Logs" -ForegroundColor White
|
||||||
|
Write-Host " [9] Check/Install OCR Dependencies" -ForegroundColor White
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
Write-Host " [Q] Quit" -ForegroundColor Red
|
Write-Host " [Q] Quit" -ForegroundColor Red
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
@@ -738,9 +1251,23 @@ function Show-MainMenu {
|
|||||||
Wait-ForKeyPress
|
Wait-ForKeyPress
|
||||||
return "Continue"
|
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" }
|
"Q" { return "Quit" }
|
||||||
default {
|
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)
|
} while ($true)
|
||||||
@@ -806,6 +1333,14 @@ function Main {
|
|||||||
Show-Logs
|
Show-Logs
|
||||||
exit 0
|
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 pollJobStatus = async (id) => {
|
||||||
const LONG_POLL_TIMEOUT = 30 // seconds
|
const LONG_POLL_TIMEOUT = 30 // seconds
|
||||||
const MAX_TOTAL_TIME = 120 // 2 minutes max
|
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 startTime = Date.now()
|
||||||
|
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
@@ -298,11 +299,15 @@ const pollJobStatus = async (id) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pollStartTime = Date.now()
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Long-poll with 30s server timeout, 35s axios timeout
|
// 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`, {
|
const response = await api.get(`/ocr/jobs/${id}/wait`, {
|
||||||
params: { timeout: LONG_POLL_TIMEOUT },
|
params: { timeout: LONG_POLL_TIMEOUT, _t: Date.now() },
|
||||||
timeout: (LONG_POLL_TIMEOUT + 5) * 1000
|
timeout: (LONG_POLL_TIMEOUT + 5) * 1000,
|
||||||
|
headers: { 'Cache-Control': 'no-cache' }
|
||||||
})
|
})
|
||||||
|
|
||||||
const job = response.data
|
const job = response.data
|
||||||
@@ -310,7 +315,12 @@ const pollJobStatus = async (id) => {
|
|||||||
queuePosition.value = job.queue_position
|
queuePosition.value = job.queue_position
|
||||||
estimatedWait.value = job.estimated_wait_seconds
|
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') {
|
if (job.status === 'completed') {
|
||||||
processing.value = false
|
processing.value = false
|
||||||
@@ -336,6 +346,13 @@ const pollJobStatus = async (id) => {
|
|||||||
|
|
||||||
// Still pending/processing - long-poll again
|
// Still pending/processing - long-poll again
|
||||||
if (processing.value) {
|
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()
|
await poll()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -349,8 +366,18 @@ const pollJobStatus = async (id) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Real error
|
// Real error - wait before retry to prevent rapid error loops
|
||||||
console.error('🔴 Poll Error:', err.message)
|
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
|
processing.value = false
|
||||||
error.value = 'Eroare la verificarea starii job-ului'
|
error.value = 'Eroare la verificarea starii job-ului'
|
||||||
emit('error', error.value)
|
emit('error', error.value)
|
||||||
|
|||||||
@@ -1,15 +1,7 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
|
|
||||||
// Detect if we're accessing from remote (not localhost)
|
// Use relative path - works with both Vite dev proxy and IIS production proxy
|
||||||
const isRemoteAccess = !['localhost', '127.0.0.1'].includes(window.location.hostname)
|
const baseURL = import.meta.env.BASE_URL + 'api/data-entry'
|
||||||
|
|
||||||
// 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 })
|
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL,
|
baseURL,
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<ul class="menu-list">
|
<ul class="menu-list">
|
||||||
<li class="menu-item">
|
<li class="menu-item">
|
||||||
<router-link
|
<router-link
|
||||||
to="/dashboard"
|
to="/reports/dashboard"
|
||||||
class="menu-link"
|
class="menu-link"
|
||||||
:class="{ active: $route.name === 'Dashboard' }"
|
:class="{ active: $route.name === 'Dashboard' }"
|
||||||
@click="closeMenu"
|
@click="closeMenu"
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li class="menu-item">
|
<li class="menu-item">
|
||||||
<router-link
|
<router-link
|
||||||
to="/invoices"
|
to="/reports/invoices"
|
||||||
class="menu-link"
|
class="menu-link"
|
||||||
:class="{ active: $route.name === 'Invoices' }"
|
:class="{ active: $route.name === 'Invoices' }"
|
||||||
@click="closeMenu"
|
@click="closeMenu"
|
||||||
@@ -37,9 +37,9 @@
|
|||||||
</li>
|
</li>
|
||||||
<li class="menu-item">
|
<li class="menu-item">
|
||||||
<router-link
|
<router-link
|
||||||
to="/bank-cash-register"
|
to="/reports/bank-cash"
|
||||||
class="menu-link"
|
class="menu-link"
|
||||||
:class="{ active: $route.name === 'BankCashRegister' }"
|
:class="{ active: $route.name === 'BankCash' }"
|
||||||
@click="closeMenu"
|
@click="closeMenu"
|
||||||
>
|
>
|
||||||
<i class="menu-icon pi pi-money-bill"></i>
|
<i class="menu-icon pi pi-money-bill"></i>
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li class="menu-item">
|
<li class="menu-item">
|
||||||
<router-link
|
<router-link
|
||||||
to="/trial-balance"
|
to="/reports/trial-balance"
|
||||||
class="menu-link"
|
class="menu-link"
|
||||||
:class="{ active: $route.name === 'TrialBalance' }"
|
:class="{ active: $route.name === 'TrialBalance' }"
|
||||||
@click="closeMenu"
|
@click="closeMenu"
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
<ul class="menu-list">
|
<ul class="menu-list">
|
||||||
<li class="menu-item">
|
<li class="menu-item">
|
||||||
<router-link
|
<router-link
|
||||||
to="/cache-stats"
|
to="/reports/cache-stats"
|
||||||
class="menu-link"
|
class="menu-link"
|
||||||
:class="{ active: $route.name === 'CacheStats' }"
|
:class="{ active: $route.name === 'CacheStats' }"
|
||||||
@click="closeMenu"
|
@click="closeMenu"
|
||||||
@@ -75,6 +75,17 @@
|
|||||||
<span>Statistici cache</span>
|
<span>Statistici cache</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -87,7 +98,7 @@
|
|||||||
<ul class="menu-list">
|
<ul class="menu-list">
|
||||||
<li class="menu-item">
|
<li class="menu-item">
|
||||||
<router-link
|
<router-link
|
||||||
to="/telegram"
|
to="/reports/telegram"
|
||||||
class="menu-link"
|
class="menu-link"
|
||||||
:class="{ active: $route.name === 'Telegram' }"
|
:class="{ active: $route.name === 'Telegram' }"
|
||||||
@click="closeMenu"
|
@click="closeMenu"
|
||||||
@@ -111,7 +122,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { useRouter } from "vue-router";
|
import { useRouter } from "vue-router";
|
||||||
import { useAuthStore } from "../../stores/auth";
|
import { useAuthStore } from "@reports/stores/sharedStores";
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "HamburgerMenu",
|
name: "HamburgerMenu",
|
||||||
@@ -123,6 +134,8 @@ export default {
|
|||||||
},
|
},
|
||||||
emits: ["close"],
|
emits: ["close"],
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
|
console.log('[HamburgerMenu] Component loaded - Server Logs should be visible in Sistem section');
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const authStore = useAuthStore();
|
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 Button from "primevue/button";
|
||||||
import Toast from "primevue/toast";
|
import Toast from "primevue/toast";
|
||||||
import QRCodeVue from "qrcode.vue";
|
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();
|
const toast = useToast();
|
||||||
|
|
||||||
@@ -107,7 +122,7 @@ const generateCode = async () => {
|
|||||||
showQR.value = false;
|
showQR.value = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await api.post("/telegram/auth/generate-code");
|
const response = await telegramApi.post("/auth/generate-code");
|
||||||
linkingCode.value = response.data.linking_code;
|
linkingCode.value = response.data.linking_code;
|
||||||
timeRemaining.value = response.data.expires_in_minutes * 60;
|
timeRemaining.value = response.data.expires_in_minutes * 60;
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ const routes = [
|
|||||||
name: 'CacheStats',
|
name: 'CacheStats',
|
||||||
component: () => import('@reports/views/CacheStatsView.vue'),
|
component: () => import('@reports/views/CacheStatsView.vue'),
|
||||||
meta: { requiresAuth: true, title: 'Statistici Cache - ROA2WEB' }
|
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
|
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)
|
# Step 2: Start Unified Backend (8000)
|
||||||
print_message "2. Starting Unified Backend on port 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
|
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)
|
# Step 2: Start Unified Backend (8000)
|
||||||
print_message "2. Starting Unified Backend on port 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,
|
port: 3000,
|
||||||
host: true,
|
host: true,
|
||||||
// Allow Tailscale VPN access for mobile debugging
|
// Allow Tailscale VPN access for mobile debugging
|
||||||
allowedHosts: ['.ts.net'],
|
allowedHosts: ['.ts.net', 'claude-agent'],
|
||||||
// Disable file watching to prevent WSL2 deadlock
|
// Disable file watching to prevent WSL2 deadlock
|
||||||
watch: null,
|
watch: null,
|
||||||
// Disable HMR completely
|
// Disable HMR completely
|
||||||
|
|||||||
Reference in New Issue
Block a user