feat: add logging, marketplace, and admin enhancements
Database & Migrations: - Add application_logs table migration for hybrid cloud logging - Add companies table migration and restructure vendor relationships Logging System: - Implement hybrid logging system (database + file) - Add log_service for centralized log management - Create admin logs page with filtering and viewing capabilities - Add init_log_settings.py script for log configuration - Enhance core logging with database integration Marketplace Integration: - Add marketplace admin page with product management - Create marketplace vendor page with product listings - Implement marketplace.js for both admin and vendor interfaces - Add marketplace integration documentation Admin Enhancements: - Add imports management page and functionality - Create settings page for admin configuration - Add vendor themes management page - Enhance vendor detail and edit pages - Improve code quality dashboard and violation details - Add logs viewing and management - Update icons guide and shared icon system Architecture & Documentation: - Document frontend structure and component architecture - Document models structure and relationships - Add vendor-in-token architecture documentation - Add vendor RBAC (role-based access control) documentation - Document marketplace integration patterns - Update architecture patterns documentation Infrastructure: - Add platform static files structure (css, img, js) - Move architecture_scan.py to proper models location - Update model imports and registrations - Enhance exception handling - Update dependency injection patterns UI/UX: - Improve vendor edit interface - Update admin user interface - Enhance page templates documentation - Add vendor marketplace interface
This commit is contained in:
@@ -271,17 +271,18 @@ def get_current_vendor_api(
|
||||
Get current vendor user from Authorization header ONLY.
|
||||
|
||||
Used for vendor API endpoints that should not accept cookies.
|
||||
Validates that user still has access to the vendor specified in the token.
|
||||
|
||||
Args:
|
||||
credentials: Bearer token from Authorization header
|
||||
db: Database session
|
||||
|
||||
Returns:
|
||||
User: Authenticated vendor user
|
||||
User: Authenticated vendor user (with token_vendor_id, token_vendor_code, token_vendor_role)
|
||||
|
||||
Raises:
|
||||
InvalidTokenException: If no token or invalid token
|
||||
InsufficientPermissionsException: If user is not vendor or is admin
|
||||
InsufficientPermissionsException: If user is not vendor or lost access to vendor
|
||||
"""
|
||||
if not credentials:
|
||||
raise InvalidTokenException("Authorization header required for API calls")
|
||||
@@ -297,6 +298,24 @@ def get_current_vendor_api(
|
||||
logger.warning(f"Non-vendor user {user.username} attempted vendor API")
|
||||
raise InsufficientPermissionsException("Vendor privileges required")
|
||||
|
||||
# Validate vendor access if token is vendor-scoped
|
||||
if hasattr(user, "token_vendor_id"):
|
||||
vendor_id = user.token_vendor_id
|
||||
|
||||
# Verify user still has access to this vendor
|
||||
if not user.is_member_of(vendor_id):
|
||||
logger.warning(
|
||||
f"User {user.username} lost access to vendor_id={vendor_id}"
|
||||
)
|
||||
raise InsufficientPermissionsException(
|
||||
"Access to vendor has been revoked. Please login again."
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Vendor API access: user={user.username}, vendor_id={vendor_id}, "
|
||||
f"vendor_code={getattr(user, 'token_vendor_code', 'N/A')}"
|
||||
)
|
||||
|
||||
return user
|
||||
|
||||
|
||||
|
||||
342
app/api/v1/admin/logs.py
Normal file
342
app/api/v1/admin/logs.py
Normal file
@@ -0,0 +1,342 @@
|
||||
# app/api/v1/admin/logs.py
|
||||
"""
|
||||
Log management endpoints for admin.
|
||||
|
||||
Provides endpoints for:
|
||||
- Viewing database logs with filters
|
||||
- Reading file logs
|
||||
- Log statistics
|
||||
- Log settings management
|
||||
- Log cleanup operations
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Response
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.deps import get_current_admin_api
|
||||
from app.core.database import get_db
|
||||
from app.core.logging import reload_log_level
|
||||
from app.services.admin_audit_service import admin_audit_service
|
||||
from app.services.admin_settings_service import admin_settings_service
|
||||
from app.services.log_service import log_service
|
||||
from models.database.user import User
|
||||
from models.schema.admin import (
|
||||
ApplicationLogFilters,
|
||||
ApplicationLogListResponse,
|
||||
FileLogResponse,
|
||||
LogSettingsResponse,
|
||||
LogSettingsUpdate,
|
||||
LogStatistics,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/logs")
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# DATABASE LOGS ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/database", response_model=ApplicationLogListResponse)
|
||||
def get_database_logs(
|
||||
level: str | None = Query(None, description="Filter by log level"),
|
||||
logger_name: str | None = Query(None, description="Filter by logger name"),
|
||||
module: str | None = Query(None, description="Filter by module"),
|
||||
user_id: int | None = Query(None, description="Filter by user ID"),
|
||||
vendor_id: int | None = Query(None, description="Filter by vendor ID"),
|
||||
search: str | None = Query(None, description="Search in message"),
|
||||
skip: int = Query(0, ge=0),
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get logs from database with filtering.
|
||||
|
||||
Supports filtering by level, logger, module, user, vendor, and date range.
|
||||
Returns paginated results.
|
||||
"""
|
||||
filters = ApplicationLogFilters(
|
||||
level=level,
|
||||
logger_name=logger_name,
|
||||
module=module,
|
||||
user_id=user_id,
|
||||
vendor_id=vendor_id,
|
||||
search=search,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
return log_service.get_database_logs(db, filters)
|
||||
|
||||
|
||||
@router.get("/statistics", response_model=LogStatistics)
|
||||
def get_log_statistics(
|
||||
days: int = Query(7, ge=1, le=90, description="Number of days to analyze"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Get log statistics for the last N days.
|
||||
|
||||
Returns counts by level, module, and recent critical errors.
|
||||
"""
|
||||
return log_service.get_log_statistics(db, days)
|
||||
|
||||
|
||||
@router.delete("/database/cleanup")
|
||||
def cleanup_old_logs(
|
||||
retention_days: int = Query(30, ge=1, le=365),
|
||||
confirm: bool = Query(False, description="Must be true to confirm cleanup"),
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Delete logs older than retention period.
|
||||
|
||||
Requires confirmation parameter.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
if not confirm:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cleanup requires confirmation parameter: confirm=true",
|
||||
)
|
||||
|
||||
deleted_count = log_service.cleanup_old_logs(db, retention_days)
|
||||
|
||||
# Log action
|
||||
admin_audit_service.log_action(
|
||||
db=db,
|
||||
admin_user_id=current_admin.id,
|
||||
action="cleanup_logs",
|
||||
target_type="application_logs",
|
||||
target_id="bulk",
|
||||
details={"retention_days": retention_days, "deleted_count": deleted_count},
|
||||
)
|
||||
|
||||
return {
|
||||
"message": f"Deleted {deleted_count} log entries older than {retention_days} days",
|
||||
"deleted_count": deleted_count,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/database/{log_id}")
|
||||
def delete_log(
|
||||
log_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Delete a specific log entry."""
|
||||
message = log_service.delete_log(db, log_id)
|
||||
|
||||
# Log action
|
||||
admin_audit_service.log_action(
|
||||
db=db,
|
||||
admin_user_id=current_admin.id,
|
||||
action="delete_log",
|
||||
target_type="application_log",
|
||||
target_id=str(log_id),
|
||||
details={},
|
||||
)
|
||||
|
||||
return {"message": message}
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# FILE LOGS ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/files")
|
||||
def list_log_files(
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
List all available log files.
|
||||
|
||||
Returns list of log files with size and modification date.
|
||||
"""
|
||||
return {"files": log_service.list_log_files()}
|
||||
|
||||
|
||||
@router.get("/files/{filename}", response_model=FileLogResponse)
|
||||
def get_file_log(
|
||||
filename: str,
|
||||
lines: int = Query(500, ge=1, le=10000, description="Number of lines to read"),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Read log file content.
|
||||
|
||||
Returns the last N lines from the specified log file.
|
||||
"""
|
||||
return log_service.get_file_logs(filename, lines)
|
||||
|
||||
|
||||
@router.get("/files/{filename}/download")
|
||||
def download_log_file(
|
||||
filename: str,
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Download log file.
|
||||
|
||||
Returns the entire log file for download.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
# Determine log file path
|
||||
log_file_path = settings.log_file
|
||||
if log_file_path:
|
||||
log_file = Path(log_file_path).parent / filename
|
||||
else:
|
||||
log_file = Path("logs") / filename
|
||||
|
||||
if not log_file.exists():
|
||||
raise HTTPException(status_code=404, detail=f"Log file '{filename}' not found")
|
||||
|
||||
# Log action
|
||||
from app.core.database import get_db
|
||||
|
||||
db_gen = get_db()
|
||||
db = next(db_gen)
|
||||
try:
|
||||
admin_audit_service.log_action(
|
||||
db=db,
|
||||
admin_user_id=current_admin.id,
|
||||
action="download_log_file",
|
||||
target_type="log_file",
|
||||
target_id=filename,
|
||||
details={"size_bytes": log_file.stat().st_size},
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return FileResponse(
|
||||
log_file,
|
||||
media_type="text/plain",
|
||||
filename=filename,
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# LOG SETTINGS ENDPOINTS
|
||||
# ============================================================================
|
||||
|
||||
|
||||
@router.get("/settings", response_model=LogSettingsResponse)
|
||||
def get_log_settings(
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""Get current log configuration settings."""
|
||||
log_level = admin_settings_service.get_setting_value(db, "log_level", "INFO")
|
||||
max_size_mb = admin_settings_service.get_setting_value(
|
||||
db, "log_file_max_size_mb", 10
|
||||
)
|
||||
backup_count = admin_settings_service.get_setting_value(
|
||||
db, "log_file_backup_count", 5
|
||||
)
|
||||
retention_days = admin_settings_service.get_setting_value(
|
||||
db, "db_log_retention_days", 30
|
||||
)
|
||||
file_enabled = admin_settings_service.get_setting_value(
|
||||
db, "file_logging_enabled", "true"
|
||||
)
|
||||
db_enabled = admin_settings_service.get_setting_value(
|
||||
db, "db_logging_enabled", "true"
|
||||
)
|
||||
|
||||
return LogSettingsResponse(
|
||||
log_level=str(log_level),
|
||||
log_file_max_size_mb=int(max_size_mb),
|
||||
log_file_backup_count=int(backup_count),
|
||||
db_log_retention_days=int(retention_days),
|
||||
file_logging_enabled=str(file_enabled).lower() == "true",
|
||||
db_logging_enabled=str(db_enabled).lower() == "true",
|
||||
)
|
||||
|
||||
|
||||
@router.put("/settings")
|
||||
def update_log_settings(
|
||||
settings_update: LogSettingsUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_admin: User = Depends(get_current_admin_api),
|
||||
):
|
||||
"""
|
||||
Update log configuration settings.
|
||||
|
||||
Changes are applied immediately without restart (for log level).
|
||||
File rotation settings require restart.
|
||||
"""
|
||||
from models.schema.admin import AdminSettingUpdate
|
||||
|
||||
updated = []
|
||||
|
||||
# Update log level
|
||||
if settings_update.log_level:
|
||||
admin_settings_service.update_setting(
|
||||
db,
|
||||
"log_level",
|
||||
AdminSettingUpdate(value=settings_update.log_level),
|
||||
current_admin.id,
|
||||
)
|
||||
updated.append("log_level")
|
||||
|
||||
# Reload log level immediately
|
||||
reload_log_level()
|
||||
|
||||
# Update file rotation settings
|
||||
if settings_update.log_file_max_size_mb:
|
||||
admin_settings_service.update_setting(
|
||||
db,
|
||||
"log_file_max_size_mb",
|
||||
AdminSettingUpdate(value=str(settings_update.log_file_max_size_mb)),
|
||||
current_admin.id,
|
||||
)
|
||||
updated.append("log_file_max_size_mb")
|
||||
|
||||
if settings_update.log_file_backup_count is not None:
|
||||
admin_settings_service.update_setting(
|
||||
db,
|
||||
"log_file_backup_count",
|
||||
AdminSettingUpdate(value=str(settings_update.log_file_backup_count)),
|
||||
current_admin.id,
|
||||
)
|
||||
updated.append("log_file_backup_count")
|
||||
|
||||
# Update retention
|
||||
if settings_update.db_log_retention_days:
|
||||
admin_settings_service.update_setting(
|
||||
db,
|
||||
"db_log_retention_days",
|
||||
AdminSettingUpdate(value=str(settings_update.db_log_retention_days)),
|
||||
current_admin.id,
|
||||
)
|
||||
updated.append("db_log_retention_days")
|
||||
|
||||
# Log action
|
||||
admin_audit_service.log_action(
|
||||
db=db,
|
||||
admin_user_id=current_admin.id,
|
||||
action="update_log_settings",
|
||||
target_type="settings",
|
||||
target_id="logging",
|
||||
details={"updated_fields": updated},
|
||||
)
|
||||
|
||||
return {
|
||||
"message": "Log settings updated successfully",
|
||||
"updated_fields": updated,
|
||||
"note": "Log level changes are applied immediately. File rotation settings require restart.",
|
||||
}
|
||||
22
app/api/v1/vendor/auth.py
vendored
22
app/api/v1/vendor/auth.py
vendored
@@ -142,28 +142,36 @@ def vendor_login(
|
||||
f"for vendor {vendor.vendor_code} as {vendor_role}"
|
||||
)
|
||||
|
||||
# Create vendor-scoped access token with vendor information
|
||||
token_data = auth_service.auth_manager.create_access_token(
|
||||
user=user,
|
||||
vendor_id=vendor.id,
|
||||
vendor_code=vendor.vendor_code,
|
||||
vendor_role=vendor_role,
|
||||
)
|
||||
|
||||
# Set HTTP-only cookie for browser navigation
|
||||
# CRITICAL: path=/vendor restricts cookie to vendor routes only
|
||||
response.set_cookie(
|
||||
key="vendor_token",
|
||||
value=login_result["token_data"]["access_token"],
|
||||
value=token_data["access_token"],
|
||||
httponly=True, # JavaScript cannot access (XSS protection)
|
||||
secure=should_use_secure_cookies(), # HTTPS only in production/staging
|
||||
samesite="lax", # CSRF protection
|
||||
max_age=login_result["token_data"]["expires_in"], # Match JWT expiry
|
||||
max_age=token_data["expires_in"], # Match JWT expiry
|
||||
path="/vendor", # RESTRICTED TO VENDOR ROUTES ONLY
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f"Set vendor_token cookie with {login_result['token_data']['expires_in']}s expiry "
|
||||
f"Set vendor_token cookie with {token_data['expires_in']}s expiry "
|
||||
f"(path=/vendor, httponly=True, secure={should_use_secure_cookies()})"
|
||||
)
|
||||
|
||||
# Return full login response
|
||||
# Return full login response with vendor-scoped token
|
||||
return VendorLoginResponse(
|
||||
access_token=login_result["token_data"]["access_token"],
|
||||
token_type=login_result["token_data"]["token_type"],
|
||||
expires_in=login_result["token_data"]["expires_in"],
|
||||
access_token=token_data["access_token"],
|
||||
token_type=token_data["token_type"],
|
||||
expires_in=token_data["expires_in"],
|
||||
user={
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
|
||||
28
app/api/v1/vendor/dashboard.py
vendored
28
app/api/v1/vendor/dashboard.py
vendored
@@ -32,31 +32,29 @@ def get_vendor_dashboard_stats(
|
||||
- Total customers
|
||||
- Revenue metrics
|
||||
|
||||
Vendor is determined from the authenticated user's vendor_user association.
|
||||
Vendor is determined from the JWT token (vendor_id claim).
|
||||
Requires Authorization header (API endpoint).
|
||||
"""
|
||||
# Get vendor from authenticated user's vendor_user record
|
||||
from models.database.vendor import VendorUser
|
||||
|
||||
vendor_user = (
|
||||
db.query(VendorUser).filter(VendorUser.user_id == current_user.id).first()
|
||||
)
|
||||
|
||||
if not vendor_user:
|
||||
from fastapi import HTTPException
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token (set by get_current_vendor_api)
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=403, detail="User is not associated with any vendor"
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor = vendor_user.vendor
|
||||
if not vendor or not vendor.is_active:
|
||||
from fastapi import HTTPException
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
# Get vendor object to include in response
|
||||
from models.database.vendor import Vendor
|
||||
|
||||
vendor = db.query(Vendor).filter(Vendor.id == vendor_id).first()
|
||||
if not vendor or not vendor.is_active:
|
||||
raise HTTPException(status_code=404, detail="Vendor not found or inactive")
|
||||
|
||||
# Get vendor-scoped statistics
|
||||
stats_data = stats_service.get_vendor_stats(db=db, vendor_id=vendor.id)
|
||||
stats_data = stats_service.get_vendor_stats(db=db, vendor_id=vendor_id)
|
||||
|
||||
return {
|
||||
"vendor": {
|
||||
|
||||
45
app/api/v1/vendor/orders.py
vendored
45
app/api/v1/vendor/orders.py
vendored
@@ -11,9 +11,7 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.services.order_service import order_service
|
||||
from middleware.vendor_context import require_vendor_context
|
||||
from models.database.user import User
|
||||
from models.database.vendor import Vendor
|
||||
from models.schema.order import (
|
||||
OrderDetailResponse,
|
||||
OrderListResponse,
|
||||
@@ -31,7 +29,6 @@ def get_vendor_orders(
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
status: str | None = Query(None, description="Filter by order status"),
|
||||
customer_id: int | None = Query(None, description="Filter by customer"),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -42,11 +39,23 @@ def get_vendor_orders(
|
||||
- status: Order status (pending, processing, shipped, delivered, cancelled)
|
||||
- customer_id: Filter orders from specific customer
|
||||
|
||||
Vendor is determined from JWT token (vendor_id claim).
|
||||
Requires Authorization header (API endpoint).
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
orders, total = order_service.get_vendor_orders(
|
||||
db=db,
|
||||
vendor_id=vendor.id,
|
||||
vendor_id=vendor_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
status=status,
|
||||
@@ -64,7 +73,6 @@ def get_vendor_orders(
|
||||
@router.get("/{order_id}", response_model=OrderDetailResponse)
|
||||
def get_order_details(
|
||||
order_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -73,7 +81,18 @@ def get_order_details(
|
||||
|
||||
Requires Authorization header (API endpoint).
|
||||
"""
|
||||
order = order_service.get_order(db=db, vendor_id=vendor.id, order_id=order_id)
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
order = order_service.get_order(db=db, vendor_id=vendor_id, order_id=order_id)
|
||||
|
||||
return OrderDetailResponse.model_validate(order)
|
||||
|
||||
@@ -82,7 +101,6 @@ def get_order_details(
|
||||
def update_order_status(
|
||||
order_id: int,
|
||||
order_update: OrderUpdate,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -99,8 +117,19 @@ def update_order_status(
|
||||
|
||||
Requires Authorization header (API endpoint).
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
order = order_service.update_order_status(
|
||||
db=db, vendor_id=vendor.id, order_id=order_id, order_update=order_update
|
||||
db=db, vendor_id=vendor_id, order_id=order_id, order_update=order_update
|
||||
)
|
||||
|
||||
logger.info(
|
||||
|
||||
128
app/api/v1/vendor/products.py
vendored
128
app/api/v1/vendor/products.py
vendored
@@ -11,9 +11,7 @@ from sqlalchemy.orm import Session
|
||||
from app.api.deps import get_current_vendor_api
|
||||
from app.core.database import get_db
|
||||
from app.services.product_service import product_service
|
||||
from middleware.vendor_context import require_vendor_context
|
||||
from models.database.user import User
|
||||
from models.database.vendor import Vendor
|
||||
from models.schema.product import (
|
||||
ProductCreate,
|
||||
ProductDetailResponse,
|
||||
@@ -32,7 +30,6 @@ def get_vendor_products(
|
||||
limit: int = Query(100, ge=1, le=1000),
|
||||
is_active: bool | None = Query(None),
|
||||
is_featured: bool | None = Query(None),
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -42,10 +39,23 @@ def get_vendor_products(
|
||||
Supports filtering by:
|
||||
- is_active: Filter active/inactive products
|
||||
- is_featured: Filter featured products
|
||||
|
||||
Vendor is determined from JWT token (vendor_id claim).
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
products, total = product_service.get_vendor_products(
|
||||
db=db,
|
||||
vendor_id=vendor.id,
|
||||
vendor_id=vendor_id,
|
||||
skip=skip,
|
||||
limit=limit,
|
||||
is_active=is_active,
|
||||
@@ -63,13 +73,23 @@ def get_vendor_products(
|
||||
@router.get("/{product_id}", response_model=ProductDetailResponse)
|
||||
def get_product_details(
|
||||
product_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Get detailed product information including inventory."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
product = product_service.get_product(
|
||||
db=db, vendor_id=vendor.id, product_id=product_id
|
||||
db=db, vendor_id=vendor_id, product_id=product_id
|
||||
)
|
||||
|
||||
return ProductDetailResponse.model_validate(product)
|
||||
@@ -78,7 +98,6 @@ def get_product_details(
|
||||
@router.post("", response_model=ProductResponse)
|
||||
def add_product_to_catalog(
|
||||
product_data: ProductCreate,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -87,13 +106,24 @@ def add_product_to_catalog(
|
||||
|
||||
This publishes a MarketplaceProduct to the vendor's public catalog.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
product = product_service.create_product(
|
||||
db=db, vendor_id=vendor.id, product_data=product_data
|
||||
db=db, vendor_id=vendor_id, product_data=product_data
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Product {product.id} added to catalog by user {current_user.username} "
|
||||
f"for vendor {vendor.vendor_code}"
|
||||
f"for vendor {current_user.token_vendor_code}"
|
||||
)
|
||||
|
||||
return ProductResponse.model_validate(product)
|
||||
@@ -103,18 +133,28 @@ def add_product_to_catalog(
|
||||
def update_product(
|
||||
product_id: int,
|
||||
product_data: ProductUpdate,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Update product in vendor catalog."""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
product = product_service.update_product(
|
||||
db=db, vendor_id=vendor.id, product_id=product_id, product_update=product_data
|
||||
db=db, vendor_id=vendor_id, product_id=product_id, product_update=product_data
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Product {product_id} updated by user {current_user.username} "
|
||||
f"for vendor {vendor.vendor_code}"
|
||||
f"for vendor {current_user.token_vendor_code}"
|
||||
)
|
||||
|
||||
return ProductResponse.model_validate(product)
|
||||
@@ -123,16 +163,26 @@ def update_product(
|
||||
@router.delete("/{product_id}")
|
||||
def remove_product_from_catalog(
|
||||
product_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Remove product from vendor catalog."""
|
||||
product_service.delete_product(db=db, vendor_id=vendor.id, product_id=product_id)
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
product_service.delete_product(db=db, vendor_id=vendor_id, product_id=product_id)
|
||||
|
||||
logger.info(
|
||||
f"Product {product_id} removed from catalog by user {current_user.username} "
|
||||
f"for vendor {vendor.vendor_code}"
|
||||
f"for vendor {current_user.token_vendor_code}"
|
||||
)
|
||||
|
||||
return {"message": f"Product {product_id} removed from catalog"}
|
||||
@@ -141,7 +191,6 @@ def remove_product_from_catalog(
|
||||
@router.post("/from-import/{marketplace_product_id}", response_model=ProductResponse)
|
||||
def publish_from_marketplace(
|
||||
marketplace_product_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -150,17 +199,28 @@ def publish_from_marketplace(
|
||||
|
||||
Shortcut endpoint for publishing directly from marketplace import.
|
||||
"""
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
product_data = ProductCreate(
|
||||
marketplace_product_id=marketplace_product_id, is_active=True
|
||||
)
|
||||
|
||||
product = product_service.create_product(
|
||||
db=db, vendor_id=vendor.id, product_data=product_data
|
||||
db=db, vendor_id=vendor_id, product_data=product_data
|
||||
)
|
||||
|
||||
logger.info(
|
||||
f"Marketplace product {marketplace_product_id} published to catalog "
|
||||
f"by user {current_user.username} for vendor {vendor.vendor_code}"
|
||||
f"by user {current_user.username} for vendor {current_user.token_vendor_code}"
|
||||
)
|
||||
|
||||
return ProductResponse.model_validate(product)
|
||||
@@ -169,19 +229,29 @@ def publish_from_marketplace(
|
||||
@router.put("/{product_id}/toggle-active")
|
||||
def toggle_product_active(
|
||||
product_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Toggle product active status."""
|
||||
product = product_service.get_product(db, vendor.id, product_id)
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
product = product_service.get_product(db, vendor_id, product_id)
|
||||
|
||||
product.is_active = not product.is_active
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
|
||||
status = "activated" if product.is_active else "deactivated"
|
||||
logger.info(f"Product {product_id} {status} for vendor {vendor.vendor_code}")
|
||||
logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}")
|
||||
|
||||
return {"message": f"Product {status}", "is_active": product.is_active}
|
||||
|
||||
@@ -189,18 +259,28 @@ def toggle_product_active(
|
||||
@router.put("/{product_id}/toggle-featured")
|
||||
def toggle_product_featured(
|
||||
product_id: int,
|
||||
vendor: Vendor = Depends(require_vendor_context()),
|
||||
current_user: User = Depends(get_current_vendor_api),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""Toggle product featured status."""
|
||||
product = product_service.get_product(db, vendor.id, product_id)
|
||||
from fastapi import HTTPException
|
||||
|
||||
# Get vendor ID from token
|
||||
if not hasattr(current_user, "token_vendor_id"):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Token missing vendor information. Please login again.",
|
||||
)
|
||||
|
||||
vendor_id = current_user.token_vendor_id
|
||||
|
||||
product = product_service.get_product(db, vendor_id, product_id)
|
||||
|
||||
product.is_featured = not product.is_featured
|
||||
db.commit()
|
||||
db.refresh(product)
|
||||
|
||||
status = "featured" if product.is_featured else "unfeatured"
|
||||
logger.info(f"Product {product_id} {status} for vendor {vendor.vendor_code}")
|
||||
logger.info(f"Product {product_id} {status} for vendor {current_user.token_vendor_code}")
|
||||
|
||||
return {"message": f"Product {status}", "is_featured": product.is_featured}
|
||||
|
||||
@@ -1,50 +1,235 @@
|
||||
# app/core/logging.py
|
||||
"""Summary description ....
|
||||
"""Hybrid logging system with file rotation and database storage.
|
||||
|
||||
This module provides classes and functions for:
|
||||
- ....
|
||||
- ....
|
||||
- ....
|
||||
- File-based logging with automatic rotation
|
||||
- Database logging for critical events (WARNING, ERROR, CRITICAL)
|
||||
- Dynamic log level configuration from database settings
|
||||
- Log retention and cleanup policies
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
import traceback
|
||||
from datetime import UTC, datetime
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from pathlib import Path
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
class DatabaseLogHandler(logging.Handler):
|
||||
"""
|
||||
Custom logging handler that stores WARNING, ERROR, and CRITICAL logs in database.
|
||||
|
||||
Runs asynchronously to avoid blocking application performance.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setLevel(logging.WARNING) # Only log WARNING and above to database
|
||||
|
||||
def emit(self, record):
|
||||
"""Emit a log record to the database."""
|
||||
try:
|
||||
from app.core.database import SessionLocal
|
||||
from models.database.admin import ApplicationLog
|
||||
|
||||
# Skip if no database session available
|
||||
db = SessionLocal()
|
||||
if not db:
|
||||
return
|
||||
|
||||
try:
|
||||
# Extract exception information if present
|
||||
exception_type = None
|
||||
exception_message = None
|
||||
stack_trace = None
|
||||
|
||||
if record.exc_info:
|
||||
exception_type = record.exc_info[0].__name__ if record.exc_info[0] else None
|
||||
exception_message = str(record.exc_info[1]) if record.exc_info[1] else None
|
||||
stack_trace = "".join(traceback.format_exception(*record.exc_info))
|
||||
|
||||
# Extract context from record (if middleware added it)
|
||||
user_id = getattr(record, "user_id", None)
|
||||
vendor_id = getattr(record, "vendor_id", None)
|
||||
request_id = getattr(record, "request_id", None)
|
||||
context = getattr(record, "context", None)
|
||||
|
||||
# Create log entry
|
||||
log_entry = ApplicationLog(
|
||||
timestamp=datetime.fromtimestamp(record.created, tz=UTC),
|
||||
level=record.levelname,
|
||||
logger_name=record.name,
|
||||
module=record.module,
|
||||
function_name=record.funcName,
|
||||
line_number=record.lineno,
|
||||
message=record.getMessage(),
|
||||
exception_type=exception_type,
|
||||
exception_message=exception_message,
|
||||
stack_trace=stack_trace,
|
||||
request_id=request_id,
|
||||
user_id=user_id,
|
||||
vendor_id=vendor_id,
|
||||
context=context,
|
||||
)
|
||||
|
||||
db.add(log_entry)
|
||||
db.commit()
|
||||
|
||||
except Exception as e:
|
||||
# If database logging fails, don't crash the app
|
||||
# Just print to stderr
|
||||
print(f"Failed to write log to database: {e}", file=sys.stderr)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
except Exception:
|
||||
# Silently fail - logging should never crash the app
|
||||
pass
|
||||
|
||||
|
||||
def get_log_level_from_db():
|
||||
"""
|
||||
Get log level from database settings.
|
||||
Falls back to environment variable if not found.
|
||||
"""
|
||||
try:
|
||||
from app.core.database import SessionLocal
|
||||
from app.services.admin_settings_service import admin_settings_service
|
||||
|
||||
db = SessionLocal()
|
||||
if not db:
|
||||
return settings.log_level
|
||||
|
||||
try:
|
||||
log_level = admin_settings_service.get_setting_value(
|
||||
db, "log_level", default=settings.log_level
|
||||
)
|
||||
return log_level.upper() if log_level else settings.log_level.upper()
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
# If database not ready or error, fall back to settings
|
||||
return settings.log_level.upper()
|
||||
|
||||
|
||||
def get_rotation_settings_from_db():
|
||||
"""
|
||||
Get log rotation settings from database.
|
||||
Returns tuple: (max_bytes, backup_count)
|
||||
"""
|
||||
try:
|
||||
from app.core.database import SessionLocal
|
||||
from app.services.admin_settings_service import admin_settings_service
|
||||
|
||||
db = SessionLocal()
|
||||
if not db:
|
||||
return (10 * 1024 * 1024, 5) # 10MB, 5 backups
|
||||
|
||||
try:
|
||||
max_mb = admin_settings_service.get_setting_value(
|
||||
db, "log_file_max_size_mb", default=10
|
||||
)
|
||||
backup_count = admin_settings_service.get_setting_value(
|
||||
db, "log_file_backup_count", default=5
|
||||
)
|
||||
return (int(max_mb) * 1024 * 1024, int(backup_count))
|
||||
finally:
|
||||
db.close()
|
||||
except Exception:
|
||||
# Fall back to defaults
|
||||
return (10 * 1024 * 1024, 5)
|
||||
|
||||
|
||||
def reload_log_level():
|
||||
"""
|
||||
Reload log level from database without restarting application.
|
||||
Useful when log level is changed via admin panel.
|
||||
"""
|
||||
try:
|
||||
new_level = get_log_level_from_db()
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(getattr(logging, new_level))
|
||||
logging.info(f"Log level changed to: {new_level}")
|
||||
return new_level
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to reload log level: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def setup_logging():
|
||||
"""Configure application logging with file and console handlers."""
|
||||
"""Configure application logging with file rotation and database handlers."""
|
||||
# Determine log file path
|
||||
log_file_path = settings.log_file
|
||||
if log_file_path:
|
||||
log_file = Path(log_file_path)
|
||||
else:
|
||||
# Default to logs/app.log
|
||||
log_file = Path("logs") / "app.log"
|
||||
|
||||
# Create logs directory if it doesn't exist
|
||||
log_file = Path(settings.log_file)
|
||||
log_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Get log level from database (or fall back to env)
|
||||
log_level = get_log_level_from_db()
|
||||
|
||||
# Get rotation settings from database (or fall back to defaults)
|
||||
max_bytes, backup_count = get_rotation_settings_from_db()
|
||||
|
||||
# Configure root logger
|
||||
logger = logging.getLogger()
|
||||
logger.setLevel(getattr(logging, settings.log_level.upper()))
|
||||
logger.setLevel(getattr(logging, log_level))
|
||||
|
||||
# Remove existing handlers
|
||||
for handler in logger.handlers[:]:
|
||||
logger.removeHandler(handler)
|
||||
|
||||
# Create formatters
|
||||
formatter = logging.Formatter(
|
||||
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
||||
detailed_formatter = logging.Formatter(
|
||||
"%(asctime)s - %(name)s - %(levelname)s - [%(module)s:%(funcName)s:%(lineno)d] - %(message)s"
|
||||
)
|
||||
simple_formatter = logging.Formatter(
|
||||
"%(asctime)s - %(levelname)s - %(message)s"
|
||||
)
|
||||
|
||||
# Console handler
|
||||
# Console handler (simple format)
|
||||
console_handler = logging.StreamHandler(sys.stdout)
|
||||
console_handler.setFormatter(formatter)
|
||||
console_handler.setFormatter(simple_formatter)
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
# File handler
|
||||
file_handler = logging.FileHandler(log_file)
|
||||
file_handler.setFormatter(formatter)
|
||||
# Rotating file handler (detailed format)
|
||||
file_handler = RotatingFileHandler(
|
||||
log_file,
|
||||
maxBytes=max_bytes,
|
||||
backupCount=backup_count,
|
||||
encoding="utf-8"
|
||||
)
|
||||
file_handler.setFormatter(detailed_formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
# Configure specific loggers
|
||||
# Database handler for critical events (WARNING and above)
|
||||
try:
|
||||
db_handler = DatabaseLogHandler()
|
||||
db_handler.setFormatter(detailed_formatter)
|
||||
logger.addHandler(db_handler)
|
||||
except Exception as e:
|
||||
# If database handler fails, just use file logging
|
||||
print(f"Warning: Database logging handler could not be initialized: {e}", file=sys.stderr)
|
||||
|
||||
# Configure specific loggers to reduce noise
|
||||
logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
|
||||
logging.getLogger("sqlalchemy.engine").setLevel(logging.WARNING)
|
||||
|
||||
# Log startup info
|
||||
logger.info("=" * 80)
|
||||
logger.info("LOGGING SYSTEM INITIALIZED")
|
||||
logger.info(f"Log Level: {log_level}")
|
||||
logger.info(f"Log File: {log_file}")
|
||||
logger.info(f"Max File Size: {max_bytes / (1024 * 1024):.1f} MB")
|
||||
logger.info(f"Backup Count: {backup_count}")
|
||||
logger.info(f"Database Logging: Enabled (WARNING and above)")
|
||||
logger.info("=" * 80)
|
||||
|
||||
return logging.getLogger(__name__)
|
||||
|
||||
@@ -54,6 +54,18 @@ from .cart import (
|
||||
ProductNotAvailableForCartException,
|
||||
)
|
||||
|
||||
# Company exceptions
|
||||
from .company import (
|
||||
CompanyAlreadyExistsException,
|
||||
CompanyHasVendorsException,
|
||||
CompanyNotActiveException,
|
||||
CompanyNotFoundException,
|
||||
CompanyNotVerifiedException,
|
||||
CompanyValidationException,
|
||||
InvalidCompanyDataException,
|
||||
UnauthorizedCompanyAccessException,
|
||||
)
|
||||
|
||||
# Customer exceptions
|
||||
from .customer import (
|
||||
CustomerAlreadyExistsException,
|
||||
@@ -284,6 +296,15 @@ __all__ = [
|
||||
"InsufficientInventoryForCartException",
|
||||
"InvalidCartQuantityException",
|
||||
"ProductNotAvailableForCartException",
|
||||
# Company exceptions
|
||||
"CompanyNotFoundException",
|
||||
"CompanyAlreadyExistsException",
|
||||
"CompanyNotActiveException",
|
||||
"CompanyNotVerifiedException",
|
||||
"UnauthorizedCompanyAccessException",
|
||||
"InvalidCompanyDataException",
|
||||
"CompanyValidationException",
|
||||
"CompanyHasVendorsException",
|
||||
# MarketplaceProduct exceptions
|
||||
"MarketplaceProductNotFoundException",
|
||||
"MarketplaceProductAlreadyExistsException",
|
||||
|
||||
@@ -364,8 +364,30 @@ def _redirect_to_login(request: Request) -> RedirectResponse:
|
||||
logger.debug("Redirecting to /admin/login")
|
||||
return RedirectResponse(url="/admin/login", status_code=302)
|
||||
if context_type == RequestContext.VENDOR_DASHBOARD:
|
||||
logger.debug("Redirecting to /vendor/login")
|
||||
return RedirectResponse(url="/vendor/login", status_code=302)
|
||||
# Extract vendor code from the request path
|
||||
# Path format: /vendor/{vendor_code}/...
|
||||
path_parts = request.url.path.split('/')
|
||||
vendor_code = None
|
||||
|
||||
# Find vendor code in path
|
||||
if len(path_parts) >= 3 and path_parts[1] == 'vendor':
|
||||
vendor_code = path_parts[2]
|
||||
|
||||
# Fallback: try to get from request state
|
||||
if not vendor_code:
|
||||
vendor = getattr(request.state, "vendor", None)
|
||||
if vendor:
|
||||
vendor_code = vendor.subdomain
|
||||
|
||||
# Construct proper login URL with vendor code
|
||||
if vendor_code:
|
||||
login_url = f"/vendor/{vendor_code}/login"
|
||||
else:
|
||||
# Fallback if we can't determine vendor code
|
||||
login_url = "/vendor/login"
|
||||
|
||||
logger.debug(f"Redirecting to {login_url}")
|
||||
return RedirectResponse(url=login_url, status_code=302)
|
||||
if context_type == RequestContext.SHOP:
|
||||
# For shop context, redirect to shop login (customer login)
|
||||
# Calculate base_url for proper routing (supports domain, subdomain, and path-based access)
|
||||
|
||||
@@ -1,179 +0,0 @@
|
||||
"""
|
||||
Architecture Scan Models
|
||||
Database models for tracking code quality scans and violations
|
||||
"""
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
Boolean,
|
||||
Column,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
class ArchitectureScan(Base):
|
||||
"""Represents a single run of the architecture validator"""
|
||||
|
||||
__tablename__ = "architecture_scans"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
timestamp = Column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False, index=True
|
||||
)
|
||||
total_files = Column(Integer, default=0)
|
||||
total_violations = Column(Integer, default=0)
|
||||
errors = Column(Integer, default=0)
|
||||
warnings = Column(Integer, default=0)
|
||||
duration_seconds = Column(Float, default=0.0)
|
||||
triggered_by = Column(String(100)) # 'manual', 'scheduled', 'ci/cd'
|
||||
git_commit_hash = Column(String(40))
|
||||
|
||||
# Relationship to violations
|
||||
violations = relationship(
|
||||
"ArchitectureViolation", back_populates="scan", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ArchitectureScan(id={self.id}, violations={self.total_violations}, errors={self.errors})>"
|
||||
|
||||
|
||||
class ArchitectureViolation(Base):
|
||||
"""Represents a single architectural violation found during a scan"""
|
||||
|
||||
__tablename__ = "architecture_violations"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
scan_id = Column(
|
||||
Integer, ForeignKey("architecture_scans.id"), nullable=False, index=True
|
||||
)
|
||||
rule_id = Column(String(20), nullable=False, index=True) # e.g., 'API-001'
|
||||
rule_name = Column(String(200), nullable=False)
|
||||
severity = Column(
|
||||
String(10), nullable=False, index=True
|
||||
) # 'error', 'warning', 'info'
|
||||
file_path = Column(String(500), nullable=False, index=True)
|
||||
line_number = Column(Integer, nullable=False)
|
||||
message = Column(Text, nullable=False)
|
||||
context = Column(Text) # Code snippet
|
||||
suggestion = Column(Text)
|
||||
status = Column(
|
||||
String(20), default="open", index=True
|
||||
) # 'open', 'assigned', 'resolved', 'ignored', 'technical_debt'
|
||||
assigned_to = Column(Integer, ForeignKey("users.id"))
|
||||
resolved_at = Column(DateTime(timezone=True))
|
||||
resolved_by = Column(Integer, ForeignKey("users.id"))
|
||||
resolution_note = Column(Text)
|
||||
created_at = Column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
# Relationships
|
||||
scan = relationship("ArchitectureScan", back_populates="violations")
|
||||
assigned_user = relationship(
|
||||
"User", foreign_keys=[assigned_to], backref="assigned_violations"
|
||||
)
|
||||
resolver = relationship(
|
||||
"User", foreign_keys=[resolved_by], backref="resolved_violations"
|
||||
)
|
||||
assignments = relationship(
|
||||
"ViolationAssignment", back_populates="violation", cascade="all, delete-orphan"
|
||||
)
|
||||
comments = relationship(
|
||||
"ViolationComment", back_populates="violation", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ArchitectureViolation(id={self.id}, rule={self.rule_id}, file={self.file_path}:{self.line_number})>"
|
||||
|
||||
|
||||
class ArchitectureRule(Base):
|
||||
"""Architecture rules configuration (from YAML with database overrides)"""
|
||||
|
||||
__tablename__ = "architecture_rules"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
rule_id = Column(
|
||||
String(20), unique=True, nullable=False, index=True
|
||||
) # e.g., 'API-001'
|
||||
category = Column(
|
||||
String(50), nullable=False
|
||||
) # 'api_endpoint', 'service_layer', etc.
|
||||
name = Column(String(200), nullable=False)
|
||||
description = Column(Text)
|
||||
severity = Column(String(10), nullable=False) # Can override default from YAML
|
||||
enabled = Column(Boolean, default=True, nullable=False)
|
||||
custom_config = Column(JSON) # For rule-specific settings
|
||||
created_at = Column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
updated_at = Column(
|
||||
DateTime(timezone=True),
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ArchitectureRule(id={self.rule_id}, name={self.name}, enabled={self.enabled})>"
|
||||
|
||||
|
||||
class ViolationAssignment(Base):
|
||||
"""Tracks assignment of violations to developers"""
|
||||
|
||||
__tablename__ = "violation_assignments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
violation_id = Column(
|
||||
Integer, ForeignKey("architecture_violations.id"), nullable=False, index=True
|
||||
)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
assigned_at = Column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
assigned_by = Column(Integer, ForeignKey("users.id"))
|
||||
due_date = Column(DateTime(timezone=True))
|
||||
priority = Column(
|
||||
String(10), default="medium"
|
||||
) # 'low', 'medium', 'high', 'critical'
|
||||
|
||||
# Relationships
|
||||
violation = relationship("ArchitectureViolation", back_populates="assignments")
|
||||
user = relationship("User", foreign_keys=[user_id], backref="violation_assignments")
|
||||
assigner = relationship(
|
||||
"User", foreign_keys=[assigned_by], backref="assigned_by_me"
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ViolationAssignment(id={self.id}, violation_id={self.violation_id}, user_id={self.user_id})>"
|
||||
|
||||
|
||||
class ViolationComment(Base):
|
||||
"""Comments on violations for collaboration"""
|
||||
|
||||
__tablename__ = "violation_comments"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
violation_id = Column(
|
||||
Integer, ForeignKey("architecture_violations.id"), nullable=False, index=True
|
||||
)
|
||||
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
|
||||
comment = Column(Text, nullable=False)
|
||||
created_at = Column(
|
||||
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
# Relationships
|
||||
violation = relationship("ArchitectureViolation", back_populates="comments")
|
||||
user = relationship("User", backref="violation_comments")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ViolationComment(id={self.id}, violation_id={self.violation_id}, user_id={self.user_id})>"
|
||||
@@ -11,7 +11,7 @@ from datetime import datetime
|
||||
from sqlalchemy import desc, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.architecture_scan import (
|
||||
from models.database.architecture_scan import (
|
||||
ArchitectureScan,
|
||||
ArchitectureViolation,
|
||||
ViolationAssignment,
|
||||
|
||||
379
app/services/log_service.py
Normal file
379
app/services/log_service.py
Normal file
@@ -0,0 +1,379 @@
|
||||
# app/services/log_service.py
|
||||
"""
|
||||
Log management service for viewing and managing application logs.
|
||||
|
||||
This module provides functions for:
|
||||
- Querying database logs with filters
|
||||
- Reading file logs
|
||||
- Log statistics and analytics
|
||||
- Log retention and cleanup
|
||||
- Downloading log files
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from sqlalchemy import and_, func, or_
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import settings
|
||||
from app.exceptions import AdminOperationException, ResourceNotFoundException
|
||||
from models.database.admin import ApplicationLog
|
||||
from models.schema.admin import (
|
||||
ApplicationLogFilters,
|
||||
ApplicationLogListResponse,
|
||||
ApplicationLogResponse,
|
||||
FileLogResponse,
|
||||
LogStatistics,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LogService:
|
||||
"""Service for managing application logs."""
|
||||
|
||||
def get_database_logs(
|
||||
self, db: Session, filters: ApplicationLogFilters
|
||||
) -> ApplicationLogListResponse:
|
||||
"""
|
||||
Get logs from database with filtering and pagination.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
filters: Filter criteria
|
||||
|
||||
Returns:
|
||||
Paginated list of logs
|
||||
"""
|
||||
try:
|
||||
query = db.query(ApplicationLog)
|
||||
|
||||
# Apply filters
|
||||
conditions = []
|
||||
|
||||
if filters.level:
|
||||
conditions.append(ApplicationLog.level == filters.level.upper())
|
||||
|
||||
if filters.logger_name:
|
||||
conditions.append(ApplicationLog.logger_name.like(f"%{filters.logger_name}%"))
|
||||
|
||||
if filters.module:
|
||||
conditions.append(ApplicationLog.module.like(f"%{filters.module}%"))
|
||||
|
||||
if filters.user_id:
|
||||
conditions.append(ApplicationLog.user_id == filters.user_id)
|
||||
|
||||
if filters.vendor_id:
|
||||
conditions.append(ApplicationLog.vendor_id == filters.vendor_id)
|
||||
|
||||
if filters.date_from:
|
||||
conditions.append(ApplicationLog.timestamp >= filters.date_from)
|
||||
|
||||
if filters.date_to:
|
||||
conditions.append(ApplicationLog.timestamp <= filters.date_to)
|
||||
|
||||
if filters.search:
|
||||
search_pattern = f"%{filters.search}%"
|
||||
conditions.append(
|
||||
or_(
|
||||
ApplicationLog.message.like(search_pattern),
|
||||
ApplicationLog.exception_message.like(search_pattern),
|
||||
)
|
||||
)
|
||||
|
||||
if conditions:
|
||||
query = query.filter(and_(*conditions))
|
||||
|
||||
# Get total count
|
||||
total = query.count()
|
||||
|
||||
# Apply pagination and sorting
|
||||
logs = (
|
||||
query.order_by(ApplicationLog.timestamp.desc())
|
||||
.offset(filters.skip)
|
||||
.limit(filters.limit)
|
||||
.all()
|
||||
)
|
||||
|
||||
return ApplicationLogListResponse(
|
||||
logs=[ApplicationLogResponse.model_validate(log) for log in logs],
|
||||
total=total,
|
||||
skip=filters.skip,
|
||||
limit=filters.limit,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get database logs: {e}")
|
||||
raise AdminOperationException(
|
||||
operation="get_database_logs", reason=f"Database query failed: {str(e)}"
|
||||
)
|
||||
|
||||
def get_log_statistics(self, db: Session, days: int = 7) -> LogStatistics:
|
||||
"""
|
||||
Get statistics about logs from the last N days.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
days: Number of days to analyze
|
||||
|
||||
Returns:
|
||||
Log statistics
|
||||
"""
|
||||
try:
|
||||
cutoff_date = datetime.now(UTC) - timedelta(days=days)
|
||||
|
||||
# Total counts
|
||||
total_count = (
|
||||
db.query(func.count(ApplicationLog.id))
|
||||
.filter(ApplicationLog.timestamp >= cutoff_date)
|
||||
.scalar()
|
||||
)
|
||||
|
||||
warning_count = (
|
||||
db.query(func.count(ApplicationLog.id))
|
||||
.filter(
|
||||
and_(
|
||||
ApplicationLog.timestamp >= cutoff_date,
|
||||
ApplicationLog.level == "WARNING",
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
|
||||
error_count = (
|
||||
db.query(func.count(ApplicationLog.id))
|
||||
.filter(
|
||||
and_(
|
||||
ApplicationLog.timestamp >= cutoff_date,
|
||||
ApplicationLog.level == "ERROR",
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
|
||||
critical_count = (
|
||||
db.query(func.count(ApplicationLog.id))
|
||||
.filter(
|
||||
and_(
|
||||
ApplicationLog.timestamp >= cutoff_date,
|
||||
ApplicationLog.level == "CRITICAL",
|
||||
)
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
|
||||
# Count by level
|
||||
by_level_raw = (
|
||||
db.query(ApplicationLog.level, func.count(ApplicationLog.id))
|
||||
.filter(ApplicationLog.timestamp >= cutoff_date)
|
||||
.group_by(ApplicationLog.level)
|
||||
.all()
|
||||
)
|
||||
by_level = {level: count for level, count in by_level_raw}
|
||||
|
||||
# Count by module (top 10)
|
||||
by_module_raw = (
|
||||
db.query(ApplicationLog.module, func.count(ApplicationLog.id))
|
||||
.filter(ApplicationLog.timestamp >= cutoff_date)
|
||||
.filter(ApplicationLog.module.isnot(None))
|
||||
.group_by(ApplicationLog.module)
|
||||
.order_by(func.count(ApplicationLog.id).desc())
|
||||
.limit(10)
|
||||
.all()
|
||||
)
|
||||
by_module = {module: count for module, count in by_module_raw}
|
||||
|
||||
# Recent errors (last 5)
|
||||
recent_errors = (
|
||||
db.query(ApplicationLog)
|
||||
.filter(
|
||||
and_(
|
||||
ApplicationLog.timestamp >= cutoff_date,
|
||||
ApplicationLog.level.in_(["ERROR", "CRITICAL"]),
|
||||
)
|
||||
)
|
||||
.order_by(ApplicationLog.timestamp.desc())
|
||||
.limit(5)
|
||||
.all()
|
||||
)
|
||||
|
||||
return LogStatistics(
|
||||
total_count=total_count or 0,
|
||||
warning_count=warning_count or 0,
|
||||
error_count=error_count or 0,
|
||||
critical_count=critical_count or 0,
|
||||
by_level=by_level,
|
||||
by_module=by_module,
|
||||
recent_errors=[
|
||||
ApplicationLogResponse.model_validate(log) for log in recent_errors
|
||||
],
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get log statistics: {e}")
|
||||
raise AdminOperationException(
|
||||
operation="get_log_statistics", reason=f"Database query failed: {str(e)}"
|
||||
)
|
||||
|
||||
def get_file_logs(
|
||||
self, filename: str = "app.log", lines: int = 500
|
||||
) -> FileLogResponse:
|
||||
"""
|
||||
Read logs from file.
|
||||
|
||||
Args:
|
||||
filename: Log filename (default: app.log)
|
||||
lines: Number of lines to return from end of file
|
||||
|
||||
Returns:
|
||||
File log content
|
||||
"""
|
||||
try:
|
||||
# Determine log file path
|
||||
log_file_path = settings.log_file
|
||||
if log_file_path:
|
||||
log_file = Path(log_file_path)
|
||||
else:
|
||||
log_file = Path("logs") / "app.log"
|
||||
|
||||
# Allow reading backup files
|
||||
if filename != "app.log":
|
||||
log_file = log_file.parent / filename
|
||||
|
||||
if not log_file.exists():
|
||||
raise ResourceNotFoundException(
|
||||
resource_type="log_file", identifier=str(log_file)
|
||||
)
|
||||
|
||||
# Get file stats
|
||||
stat = log_file.stat()
|
||||
|
||||
# Read last N lines efficiently
|
||||
with open(log_file, "r", encoding="utf-8", errors="replace") as f:
|
||||
# For large files, seek to end and read backwards
|
||||
all_lines = f.readlines()
|
||||
log_lines = all_lines[-lines:] if len(all_lines) > lines else all_lines
|
||||
|
||||
return FileLogResponse(
|
||||
filename=log_file.name,
|
||||
size_bytes=stat.st_size,
|
||||
last_modified=datetime.fromtimestamp(stat.st_mtime, tz=UTC),
|
||||
lines=[line.rstrip("\n") for line in log_lines],
|
||||
total_lines=len(all_lines),
|
||||
)
|
||||
|
||||
except ResourceNotFoundException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to read log file: {e}")
|
||||
raise AdminOperationException(
|
||||
operation="get_file_logs", reason=f"File read failed: {str(e)}"
|
||||
)
|
||||
|
||||
def list_log_files(self) -> list[dict]:
|
||||
"""
|
||||
List all available log files.
|
||||
|
||||
Returns:
|
||||
List of log file info (name, size, modified date)
|
||||
"""
|
||||
try:
|
||||
# Determine log directory
|
||||
log_file_path = settings.log_file
|
||||
if log_file_path:
|
||||
log_dir = Path(log_file_path).parent
|
||||
else:
|
||||
log_dir = Path("logs")
|
||||
|
||||
if not log_dir.exists():
|
||||
return []
|
||||
|
||||
files = []
|
||||
for log_file in log_dir.glob("*.log*"):
|
||||
if log_file.is_file():
|
||||
stat = log_file.stat()
|
||||
files.append(
|
||||
{
|
||||
"filename": log_file.name,
|
||||
"size_bytes": stat.st_size,
|
||||
"size_mb": round(stat.st_size / (1024 * 1024), 2),
|
||||
"last_modified": datetime.fromtimestamp(
|
||||
stat.st_mtime, tz=UTC
|
||||
).isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
# Sort by modified date (newest first)
|
||||
files.sort(key=lambda x: x["last_modified"], reverse=True)
|
||||
|
||||
return files
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to list log files: {e}")
|
||||
raise AdminOperationException(
|
||||
operation="list_log_files", reason=f"Directory read failed: {str(e)}"
|
||||
)
|
||||
|
||||
def cleanup_old_logs(self, db: Session, retention_days: int) -> int:
|
||||
"""
|
||||
Delete logs older than retention period from database.
|
||||
|
||||
Args:
|
||||
db: Database session
|
||||
retention_days: Days to retain logs
|
||||
|
||||
Returns:
|
||||
Number of logs deleted
|
||||
"""
|
||||
try:
|
||||
cutoff_date = datetime.now(UTC) - timedelta(days=retention_days)
|
||||
|
||||
deleted_count = (
|
||||
db.query(ApplicationLog)
|
||||
.filter(ApplicationLog.timestamp < cutoff_date)
|
||||
.delete()
|
||||
)
|
||||
|
||||
db.commit()
|
||||
|
||||
logger.info(
|
||||
f"Cleaned up {deleted_count} logs older than {retention_days} days"
|
||||
)
|
||||
|
||||
return deleted_count
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to cleanup old logs: {e}")
|
||||
raise AdminOperationException(
|
||||
operation="cleanup_old_logs", reason=f"Delete operation failed: {str(e)}"
|
||||
)
|
||||
|
||||
def delete_log(self, db: Session, log_id: int) -> str:
|
||||
"""Delete a specific log entry."""
|
||||
try:
|
||||
log_entry = db.query(ApplicationLog).filter(ApplicationLog.id == log_id).first()
|
||||
|
||||
if not log_entry:
|
||||
raise ResourceNotFoundException(resource_type="log", identifier=str(log_id))
|
||||
|
||||
db.delete(log_entry)
|
||||
db.commit()
|
||||
|
||||
return f"Log entry {log_id} deleted successfully"
|
||||
|
||||
except ResourceNotFoundException:
|
||||
raise
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error(f"Failed to delete log {log_id}: {e}")
|
||||
raise AdminOperationException(
|
||||
operation="delete_log", reason=f"Delete operation failed: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
# Create service instance
|
||||
log_service = LogService()
|
||||
@@ -71,7 +71,7 @@
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||
<!-- Card: Total Violations -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-red-100 dark:bg-red-500">
|
||||
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-white dark:bg-red-600">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
@@ -87,7 +87,7 @@
|
||||
<!-- Card: Errors -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-100 dark:bg-orange-500">
|
||||
<span x-html="$icon('alert', 'w-5 h-5')"></span>
|
||||
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
@@ -102,7 +102,7 @@
|
||||
<!-- Card: Warnings -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-yellow-500 bg-yellow-100 rounded-full dark:text-yellow-100 dark:bg-yellow-500">
|
||||
<span x-html="$icon('info', 'w-5 h-5')"></span>
|
||||
<span x-html="$icon('information-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
@@ -122,7 +122,7 @@
|
||||
'text-yellow-500 bg-yellow-100 dark:text-yellow-100 dark:bg-yellow-500': stats.technical_debt_score >= 50 && stats.technical_debt_score < 80,
|
||||
'text-red-500 bg-red-100 dark:text-red-100 dark:bg-red-500': stats.technical_debt_score < 50
|
||||
}">
|
||||
<span x-html="$icon('chart', 'w-5 h-5')"></span>
|
||||
<span x-html="$icon('chart-bar', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
@@ -275,7 +275,7 @@
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a href="/admin/code-quality/violations"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
|
||||
<span x-html="$icon('list', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-html="$icon('clipboard-list', 'w-4 h-4 mr-2')"></span>
|
||||
View All Violations
|
||||
</a>
|
||||
<a href="/admin/code-quality/violations?status=open"
|
||||
@@ -285,7 +285,7 @@
|
||||
</a>
|
||||
<a href="/admin/code-quality/violations?severity=error"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:shadow-outline-gray">
|
||||
<span x-html="$icon('alert', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-html="$icon('exclamation', 'w-4 h-4 mr-2')"></span>
|
||||
Errors Only
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -18,8 +18,9 @@ function codeQualityViolationDetail(violationId) {
|
||||
updating: false,
|
||||
commenting: false,
|
||||
newComment: '',
|
||||
newStatus: '',
|
||||
assignedTo: '',
|
||||
assignUserId: '',
|
||||
resolutionNote: '',
|
||||
ignoreReason: '',
|
||||
|
||||
async init() {
|
||||
await this.loadViolation();
|
||||
@@ -30,10 +31,8 @@ function codeQualityViolationDetail(violationId) {
|
||||
this.error = null;
|
||||
|
||||
try {
|
||||
const response = await apiClient.get(`/api/v1/admin/code-quality/violations/${this.violationId}`);
|
||||
this.violation = response.data;
|
||||
this.newStatus = this.violation.status;
|
||||
this.assignedTo = this.violation.assigned_to || '';
|
||||
const response = await apiClient.get(`/admin/code-quality/violations/${this.violationId}`);
|
||||
this.violation = response;
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Load Violation');
|
||||
this.error = error.response?.data?.message || 'Failed to load violation details';
|
||||
@@ -42,41 +41,75 @@ function codeQualityViolationDetail(violationId) {
|
||||
}
|
||||
},
|
||||
|
||||
async updateStatus() {
|
||||
if (!this.newStatus) return;
|
||||
async assignViolation() {
|
||||
const userId = parseInt(this.assignUserId);
|
||||
if (!userId || isNaN(userId)) {
|
||||
Utils.showToast('Please enter a valid user ID', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.updating = true;
|
||||
|
||||
try {
|
||||
await apiClient.patch(`/api/v1/admin/code-quality/violations/${this.violationId}/status`, {
|
||||
status: this.newStatus
|
||||
await apiClient.post(`/admin/code-quality/violations/${this.violationId}/assign`, {
|
||||
user_id: userId,
|
||||
priority: 'medium'
|
||||
});
|
||||
|
||||
Utils.showToast('Status updated successfully', 'success');
|
||||
this.assignUserId = '';
|
||||
Utils.showToast('Violation assigned successfully', 'success');
|
||||
await this.loadViolation();
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Update Status');
|
||||
Utils.showToast(error.response?.data?.message || 'Failed to update status', 'error');
|
||||
window.LogConfig.logError(error, 'Assign Violation');
|
||||
Utils.showToast(error.message || 'Failed to assign violation', 'error');
|
||||
} finally {
|
||||
this.updating = false;
|
||||
}
|
||||
},
|
||||
|
||||
async assignViolation() {
|
||||
if (!this.assignedTo) return;
|
||||
async resolveViolation() {
|
||||
if (!this.resolutionNote.trim()) {
|
||||
Utils.showToast('Please enter a resolution note', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.updating = true;
|
||||
|
||||
try {
|
||||
await apiClient.patch(`/api/v1/admin/code-quality/violations/${this.violationId}/assign`, {
|
||||
assigned_to: this.assignedTo
|
||||
await apiClient.post(`/admin/code-quality/violations/${this.violationId}/resolve`, {
|
||||
resolution_note: this.resolutionNote
|
||||
});
|
||||
|
||||
Utils.showToast('Violation assigned successfully', 'success');
|
||||
this.resolutionNote = '';
|
||||
Utils.showToast('Violation resolved successfully', 'success');
|
||||
await this.loadViolation();
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Assign Violation');
|
||||
Utils.showToast(error.response?.data?.message || 'Failed to assign violation', 'error');
|
||||
window.LogConfig.logError(error, 'Resolve Violation');
|
||||
Utils.showToast(error.message || 'Failed to resolve violation', 'error');
|
||||
} finally {
|
||||
this.updating = false;
|
||||
}
|
||||
},
|
||||
|
||||
async ignoreViolation() {
|
||||
if (!this.ignoreReason.trim()) {
|
||||
Utils.showToast('Please enter a reason for ignoring', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.updating = true;
|
||||
|
||||
try {
|
||||
await apiClient.post(`/admin/code-quality/violations/${this.violationId}/ignore`, {
|
||||
reason: this.ignoreReason
|
||||
});
|
||||
|
||||
this.ignoreReason = '';
|
||||
Utils.showToast('Violation ignored successfully', 'success');
|
||||
await this.loadViolation();
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Ignore Violation');
|
||||
Utils.showToast(error.message || 'Failed to ignore violation', 'error');
|
||||
} finally {
|
||||
this.updating = false;
|
||||
}
|
||||
@@ -88,7 +121,7 @@ function codeQualityViolationDetail(violationId) {
|
||||
this.commenting = true;
|
||||
|
||||
try {
|
||||
await apiClient.post(`/api/v1/admin/code-quality/violations/${this.violationId}/comments`, {
|
||||
await apiClient.post(`/admin/code-quality/violations/${this.violationId}/comments`, {
|
||||
comment: this.newComment
|
||||
});
|
||||
|
||||
@@ -97,7 +130,7 @@ function codeQualityViolationDetail(violationId) {
|
||||
await this.loadViolation();
|
||||
} catch (error) {
|
||||
window.LogConfig.logError(error, 'Add Comment');
|
||||
Utils.showToast(error.response?.data?.message || 'Failed to add comment', 'error');
|
||||
Utils.showToast(error.message || 'Failed to add comment', 'error');
|
||||
} finally {
|
||||
this.commenting = false;
|
||||
}
|
||||
@@ -222,48 +255,72 @@ function codeQualityViolationDetail(violationId) {
|
||||
<div class="p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<h4 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">Manage Violation</h4>
|
||||
|
||||
<div class="grid gap-4 md:grid-cols-2 mb-6">
|
||||
<!-- Update Status -->
|
||||
<!-- Assign Section -->
|
||||
<div class="mb-6" x-show="violation.status === 'open' || violation.status === 'assigned'">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">Assign to User</label>
|
||||
<div class="flex gap-2">
|
||||
<input x-model="assignUserId"
|
||||
type="number"
|
||||
placeholder="User ID"
|
||||
class="flex-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input rounded-md">
|
||||
<button @click="assignViolation()"
|
||||
:disabled="updating || !assignUserId"
|
||||
class="px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!updating">Assign</span>
|
||||
<span x-show="updating">Assigning...</span>
|
||||
</button>
|
||||
</div>
|
||||
<p x-show="violation.assigned_to" class="mt-1 text-xs text-gray-500">
|
||||
Currently assigned to user ID: <span x-text="violation.assigned_to"></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="grid gap-4 md:grid-cols-2" x-show="violation.status === 'open' || violation.status === 'assigned'">
|
||||
<!-- Resolve -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">Status</label>
|
||||
<div class="flex gap-2">
|
||||
<select x-model="newStatus"
|
||||
class="flex-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-select rounded-md">
|
||||
<option value="open">Open</option>
|
||||
<option value="assigned">Assigned</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="ignored">Ignored</option>
|
||||
</select>
|
||||
<button @click="updateStatus()"
|
||||
:disabled="updating || newStatus === violation.status"
|
||||
class="px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!updating">Update</span>
|
||||
<span x-show="updating">Updating...</span>
|
||||
</button>
|
||||
</div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">Resolve Violation</label>
|
||||
<textarea x-model="resolutionNote"
|
||||
rows="2"
|
||||
placeholder="Resolution note..."
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-textarea rounded-md"></textarea>
|
||||
<button @click="resolveViolation()"
|
||||
:disabled="updating || !resolutionNote.trim()"
|
||||
class="mt-2 w-full px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-green-600 border border-transparent rounded-lg active:bg-green-600 hover:bg-green-700 focus:outline-none focus:shadow-outline-green disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!updating">Mark as Resolved</span>
|
||||
<span x-show="updating">Resolving...</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Assign -->
|
||||
<!-- Ignore -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">Assign To</label>
|
||||
<div class="flex gap-2">
|
||||
<input x-model="assignedTo"
|
||||
type="text"
|
||||
placeholder="Username or email"
|
||||
class="flex-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input rounded-md">
|
||||
<button @click="assignViolation()"
|
||||
:disabled="updating || !assignedTo"
|
||||
class="px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg active:bg-purple-600 hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!updating">Assign</span>
|
||||
<span x-show="updating">Assigning...</span>
|
||||
</button>
|
||||
</div>
|
||||
<p x-show="violation.assigned_to" class="mt-1 text-xs text-gray-500">
|
||||
Currently assigned to: <span x-text="violation.assigned_to"></span>
|
||||
</p>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">Ignore Violation</label>
|
||||
<textarea x-model="ignoreReason"
|
||||
rows="2"
|
||||
placeholder="Reason for ignoring..."
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-textarea rounded-md"></textarea>
|
||||
<button @click="ignoreViolation()"
|
||||
:disabled="updating || !ignoreReason.trim()"
|
||||
class="mt-2 w-full px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-gray-600 border border-transparent rounded-lg active:bg-gray-600 hover:bg-gray-700 focus:outline-none focus:shadow-outline-gray disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
<span x-show="!updating">Ignore Violation</span>
|
||||
<span x-show="updating">Ignoring...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Resolution Info (for resolved/ignored) -->
|
||||
<div x-show="violation.status === 'resolved' || violation.status === 'ignored'" class="p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
This violation has been <span x-text="violation.status"></span>
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400" x-show="violation.resolution_note">
|
||||
Note: <span x-text="violation.resolution_note"></span>
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 mt-1" x-show="violation.resolved_at">
|
||||
<span x-text="formatDate(violation.resolved_at)"></span> by user ID <span x-text="violation.resolved_by"></span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Comments Section -->
|
||||
<div>
|
||||
<h5 class="text-md font-semibold text-gray-700 dark:text-gray-200 mb-3">Comments</h5>
|
||||
@@ -293,7 +350,9 @@ function codeQualityViolationDetail(violationId) {
|
||||
<template x-for="comment in (violation.comments || [])" :key="comment.id">
|
||||
<div class="p-3 bg-gray-50 dark:bg-gray-900 rounded-lg">
|
||||
<div class="flex items-start justify-between mb-2">
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300" x-text="comment.user"></p>
|
||||
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
User ID: <span x-text="comment.user_id"></span>
|
||||
</p>
|
||||
<p class="text-xs text-gray-500" x-text="formatDate(comment.created_at)"></p>
|
||||
</div>
|
||||
<p class="text-sm text-gray-900 dark:text-white" x-text="comment.comment"></p>
|
||||
|
||||
@@ -304,7 +304,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Usage Guide -->
|
||||
<div class="mt-6 bg-blue-50 dark:bg-gray-800 border border-blue-200 dark:border-gray-700 rounded-lg p-6">
|
||||
<div class="mb-8 mt-6 bg-blue-50 dark:bg-gray-800 border border-blue-200 dark:border-gray-700 rounded-lg p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center">
|
||||
<span x-html="$icon('book-open', 'w-5 h-5 mr-2 text-blue-600')"></span>
|
||||
How to Use Icons
|
||||
|
||||
432
app/templates/admin/imports.html
Normal file
432
app/templates/admin/imports.html
Normal file
@@ -0,0 +1,432 @@
|
||||
{# app/templates/admin/imports.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Import Jobs - Platform Monitoring{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminImports(){% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="/static/admin/js/imports.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between my-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Platform Import Jobs
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
System-wide monitoring of all marketplace import jobs
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="refreshJobs()"
|
||||
:disabled="loading"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Stats Cards -->
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||
<!-- Total Jobs -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
|
||||
<span x-html="$icon('cube', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Total Jobs
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Active Jobs -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-yellow-500 bg-yellow-100 rounded-full dark:text-yellow-100 dark:bg-yellow-500">
|
||||
<span x-html="$icon('clock', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Active Jobs
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.active">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Completed -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-100 dark:bg-green-500">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Completed
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.completed">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Failed -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-white dark:bg-red-600">
|
||||
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">
|
||||
Failed
|
||||
</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.failed">
|
||||
0
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div x-show="error" x-transition class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold">Error</p>
|
||||
<p class="text-sm" x-text="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mb-4 bg-white rounded-lg shadow-xs dark:bg-gray-800 p-4">
|
||||
<div class="grid gap-4 md:grid-cols-5">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Filter by Vendor
|
||||
</label>
|
||||
<select
|
||||
x-model="filters.vendor_id"
|
||||
@change="applyFilters()"
|
||||
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Vendors</option>
|
||||
<template x-for="vendor in vendors" :key="vendor.id">
|
||||
<option :value="vendor.id" x-text="`${vendor.name} (${vendor.vendor_code})`"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Filter by Status
|
||||
</label>
|
||||
<select
|
||||
x-model="filters.status"
|
||||
@change="applyFilters()"
|
||||
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="completed_with_errors">Completed with Errors</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Filter by Marketplace
|
||||
</label>
|
||||
<select
|
||||
x-model="filters.marketplace"
|
||||
@change="applyFilters()"
|
||||
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Marketplaces</option>
|
||||
<option value="Letzshop">Letzshop</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Created By
|
||||
</label>
|
||||
<select
|
||||
x-model="filters.created_by"
|
||||
@change="applyFilters()"
|
||||
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Users</option>
|
||||
<option value="me">My Jobs Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
@click="clearFilters()"
|
||||
class="px-3 py-1 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none"
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Jobs List -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Import Jobs
|
||||
</h3>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="text-center py-12">
|
||||
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading import jobs...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="!loading && jobs.length === 0" class="text-center py-12">
|
||||
<span x-html="$icon('inbox', 'inline w-12 h-12 text-gray-400 mb-4')"></span>
|
||||
<p class="text-gray-600 dark:text-gray-400">No import jobs found</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-500">Try adjusting your filters or wait for new imports</p>
|
||||
</div>
|
||||
|
||||
<!-- Jobs Table -->
|
||||
<div x-show="!loading && jobs.length > 0" class="w-full overflow-hidden rounded-lg shadow-xs">
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="w-full whitespace-no-wrap">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3">Job ID</th>
|
||||
<th class="px-4 py-3">Vendor</th>
|
||||
<th class="px-4 py-3">Marketplace</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Progress</th>
|
||||
<th class="px-4 py-3">Started</th>
|
||||
<th class="px-4 py-3">Duration</th>
|
||||
<th class="px-4 py-3">Created By</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-for="job in jobs" :key="job.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400">
|
||||
<td class="px-4 py-3 text-sm">
|
||||
#<span x-text="job.id"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="getVendorName(job.vendor_id)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="job.marketplace"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
|
||||
:class="{
|
||||
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': job.status === 'completed',
|
||||
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': job.status === 'processing',
|
||||
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': job.status === 'pending',
|
||||
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': job.status === 'failed',
|
||||
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': job.status === 'completed_with_errors'
|
||||
}"
|
||||
x-text="job.status.replace('_', ' ').toUpperCase()">
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<span class="text-green-600 dark:text-green-400" x-text="job.imported_count"></span> imported,
|
||||
<span class="text-blue-600 dark:text-blue-400" x-text="job.updated_count"></span> updated
|
||||
</div>
|
||||
<div x-show="job.error_count > 0" class="text-xs text-red-600 dark:text-red-400">
|
||||
<span x-text="job.error_count"></span> errors
|
||||
</div>
|
||||
<div x-show="job.total_processed > 0" class="text-xs text-gray-500 dark:text-gray-500">
|
||||
Total: <span x-text="job.total_processed"></span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="job.started_at ? formatDate(job.started_at) : 'Not started'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="calculateDuration(job)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="job.created_by_name || 'System'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="viewJobDetails(job.id)"
|
||||
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-purple-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="View Details"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button
|
||||
x-show="job.status === 'processing' || job.status === 'pending'"
|
||||
@click="refreshJobStatus(job.id)"
|
||||
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-blue-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="Refresh Status"
|
||||
>
|
||||
<span x-html="$icon('refresh', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div x-show="!loading && totalJobs > limit" class="px-4 py-3 border-t dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-400">
|
||||
Showing <span x-text="((page - 1) * limit) + 1"></span> to
|
||||
<span x-text="Math.min(page * limit, totalJobs)"></span> of
|
||||
<span x-text="totalJobs"></span> jobs
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
@click="previousPage()"
|
||||
:disabled="page === 1"
|
||||
class="px-3 py-1 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-md hover:bg-purple-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
@click="nextPage()"
|
||||
:disabled="page * limit >= totalJobs"
|
||||
class="px-3 py-1 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-md hover:bg-purple-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Job Details Modal (same as marketplace version) -->
|
||||
<div x-show="showJobModal"
|
||||
x-cloak
|
||||
@click.away="closeJobModal()"
|
||||
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0">
|
||||
<div @click.away="closeJobModal()"
|
||||
class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-2xl"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 transform translate-y-1/2"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0 transform translate-y-1/2">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Import Job Details
|
||||
</h3>
|
||||
<button @click="closeJobModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-html="$icon('close', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div x-show="selectedJob" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Job ID</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.id"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Vendor</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="getVendorName(selectedJob?.vendor_id)"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Marketplace</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.marketplace"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Status</p>
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
|
||||
:class="{
|
||||
'text-green-700 bg-green-100': selectedJob?.status === 'completed',
|
||||
'text-blue-700 bg-blue-100': selectedJob?.status === 'processing',
|
||||
'text-yellow-700 bg-yellow-100': selectedJob?.status === 'pending',
|
||||
'text-red-700 bg-red-100': selectedJob?.status === 'failed',
|
||||
'text-orange-700 bg-orange-100': selectedJob?.status === 'completed_with_errors'
|
||||
}"
|
||||
x-text="selectedJob?.status.replace('_', ' ').toUpperCase()">
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Source URL</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100 break-all" x-text="selectedJob?.source_url"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Imported</p>
|
||||
<p class="text-sm text-green-600 dark:text-green-400" x-text="selectedJob?.imported_count"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Updated</p>
|
||||
<p class="text-sm text-blue-600 dark:text-blue-400" x-text="selectedJob?.updated_count"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Errors</p>
|
||||
<p class="text-sm text-red-600 dark:text-red-400" x-text="selectedJob?.error_count"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Total Processed</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.total_processed"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Started At</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.started_at ? formatDate(selectedJob.started_at) : 'Not started'"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Completed At</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.completed_at ? formatDate(selectedJob.completed_at) : 'Not completed'"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Created By</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.created_by_name || 'System'"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Details -->
|
||||
<div x-show="selectedJob?.error_details && selectedJob.error_details.length > 0" class="mt-4">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Error Details</p>
|
||||
<div class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg max-h-48 overflow-y-auto">
|
||||
<pre class="text-xs text-red-700 dark:text-red-300 whitespace-pre-wrap" x-text="JSON.stringify(selectedJob.error_details, null, 2)"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="flex justify-end mt-6">
|
||||
<button
|
||||
@click="closeJobModal()"
|
||||
class="px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 border border-gray-300 rounded-lg dark:text-gray-400 dark:border-gray-700 hover:border-gray-500 focus:outline-none"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
378
app/templates/admin/logs.html
Normal file
378
app/templates/admin/logs.html
Normal file
@@ -0,0 +1,378 @@
|
||||
{# app/templates/admin/logs.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Application Logs{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminLogs(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between my-6">
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Application Logs
|
||||
</h2>
|
||||
<div class="flex items-center space-x-3">
|
||||
<button
|
||||
@click="refresh()"
|
||||
:disabled="loading"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div x-show="successMessage" x-transition class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold">Success</p>
|
||||
<p class="text-sm" x-text="successMessage"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div x-show="error" x-transition class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold">Error</p>
|
||||
<p class="text-sm" x-text="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div x-show="stats" class="grid gap-6 mb-8 md:grid-cols-2 xl:grid-cols-4">
|
||||
<!-- Total Logs -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-100 dark:bg-blue-500">
|
||||
<span x-html="$icon('document-text', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Total Logs (7d)</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.total_count">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warnings -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-yellow-500 bg-yellow-100 rounded-full dark:text-yellow-100 dark:bg-yellow-500">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Warnings</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.warning_count">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Errors -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-red-500 bg-red-100 rounded-full dark:text-white dark:bg-red-600">
|
||||
<span x-html="$icon('x-circle', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Errors</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.error_count">0</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Critical -->
|
||||
<div class="flex items-center p-4 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-100 dark:bg-purple-500">
|
||||
<span x-html="$icon('lightning-bolt', 'w-5 h-5')"></span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mb-2 text-sm font-medium text-gray-600 dark:text-gray-400">Critical</p>
|
||||
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="stats.critical_count">0</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Source Tabs -->
|
||||
<div class="mb-6">
|
||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
<button
|
||||
@click="logSource = 'database'; loadLogs()"
|
||||
:class="logSource === 'database' ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'"
|
||||
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||
>
|
||||
<span x-html="$icon('database', 'inline w-5 h-5 mr-2')"></span>
|
||||
Database Logs
|
||||
</button>
|
||||
<button
|
||||
@click="logSource = 'file'; loadFileLogs()"
|
||||
:class="logSource === 'file' ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'"
|
||||
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||
>
|
||||
<span x-html="$icon('document', 'inline w-5 h-5 mr-2')"></span>
|
||||
File Logs
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Database Logs Section -->
|
||||
<div x-show="logSource === 'database'" x-transition>
|
||||
<!-- Filters -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Filters</h3>
|
||||
<button
|
||||
@click="resetFilters()"
|
||||
class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400"
|
||||
>
|
||||
Reset Filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<!-- Log Level Filter -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Log Level</label>
|
||||
<select
|
||||
x-model="filters.level"
|
||||
@change="loadLogs()"
|
||||
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
>
|
||||
<option value="">All Levels</option>
|
||||
<option value="WARNING">WARNING</option>
|
||||
<option value="ERROR">ERROR</option>
|
||||
<option value="CRITICAL">CRITICAL</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Module Filter -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Module</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="filters.module"
|
||||
@keyup.enter="loadLogs()"
|
||||
placeholder="Filter by module..."
|
||||
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Search -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Search</label>
|
||||
<input
|
||||
type="text"
|
||||
x-model="filters.search"
|
||||
@keyup.enter="loadLogs()"
|
||||
placeholder="Search in messages..."
|
||||
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Database Logs Table -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full whitespace-no-wrap">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3">Timestamp</th>
|
||||
<th class="px-4 py-3">Level</th>
|
||||
<th class="px-4 py-3">Module</th>
|
||||
<th class="px-4 py-3">Message</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-if="loading">
|
||||
<tr>
|
||||
<td colspan="5" class="px-4 py-8 text-center">
|
||||
<span x-html="$icon('spinner', 'inline w-6 h-6 text-purple-600')"></span>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">Loading logs...</p>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<template x-if="!loading && logs.length === 0">
|
||||
<tr>
|
||||
<td colspan="5" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
|
||||
No logs found
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<template x-for="log in logs" :key="log.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="formatTimestamp(log.timestamp)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-xs">
|
||||
<span
|
||||
:class="{
|
||||
'bg-yellow-100 text-yellow-800 dark:bg-yellow-800 dark:text-yellow-100': log.level === 'WARNING',
|
||||
'bg-red-100 text-red-800 dark:bg-red-800 dark:text-red-100': log.level === 'ERROR',
|
||||
'bg-purple-100 text-purple-800 dark:bg-purple-800 dark:text-purple-100': log.level === 'CRITICAL'
|
||||
}"
|
||||
class="px-2 py-1 font-semibold leading-tight rounded-full"
|
||||
x-text="log.level"
|
||||
></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="log.module || '-'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="max-w-2xl truncate" x-text="log.message"></div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<button
|
||||
@click="showLogDetail(log)"
|
||||
class="text-purple-600 hover:text-purple-700 dark:text-purple-400"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div x-show="!loading && logs.length > 0" class="px-4 py-3 border-t dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-400">
|
||||
Showing <span x-text="filters.skip + 1"></span> to <span x-text="Math.min(filters.skip + filters.limit, totalLogs)"></span> of <span x-text="totalLogs"></span>
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
@click="previousPage()"
|
||||
:disabled="filters.skip === 0"
|
||||
class="px-3 py-1 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
@click="nextPage()"
|
||||
:disabled="filters.skip + filters.limit >= totalLogs"
|
||||
class="px-3 py-1 text-sm font-medium text-gray-700 bg-white border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed dark:bg-gray-700 dark:text-gray-300 dark:border-gray-600"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Logs Section -->
|
||||
<div x-show="logSource === 'file'" x-transition>
|
||||
<!-- File Selection -->
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6 mb-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">Log Files</h3>
|
||||
<button
|
||||
@click="loadFileLogs()"
|
||||
class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400"
|
||||
>
|
||||
Refresh List
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Select Log File</label>
|
||||
<select
|
||||
x-model="selectedFile"
|
||||
@change="loadFileContent()"
|
||||
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
>
|
||||
<option value="">Select a file...</option>
|
||||
<template x-for="file in logFiles" :key="file.filename">
|
||||
<option :value="file.filename" x-text="`${file.filename} (${file.size_mb} MB)`"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
@click="downloadLogFile()"
|
||||
:disabled="!selectedFile"
|
||||
class="px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-green-600 border border-transparent rounded-lg hover:bg-green-700 focus:outline-none focus:shadow-outline-green disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-html="$icon('download', 'inline w-4 h-4 mr-2')"></span>
|
||||
Download File
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Content -->
|
||||
<div x-show="fileContent" class="bg-white dark:bg-gray-800 rounded-lg shadow-md overflow-hidden">
|
||||
<div class="p-4 border-b dark:border-gray-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="selectedFile"></h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
Showing last <span x-text="fileContent?.lines?.length || 0"></span> lines of <span x-text="fileContent?.total_lines || 0"></span> total
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4 bg-gray-900 overflow-x-auto">
|
||||
<pre class="text-xs text-green-400 font-mono"><code x-text="fileContent?.lines?.join('\n')"></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Log Detail Modal -->
|
||||
<div x-show="selectedLog" x-transition class="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-50" @click.self="selectedLog = null">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-4xl w-full mx-4 max-h-[90vh] overflow-hidden">
|
||||
<div class="p-6 border-b dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-xl font-semibold text-gray-700 dark:text-gray-200">Log Details</h3>
|
||||
<button @click="selectedLog = null" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300">
|
||||
<span x-html="$icon('close', 'w-6 h-6')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-6 overflow-y-auto max-h-[calc(90vh-120px)]">
|
||||
<template x-if="selectedLog">
|
||||
<div class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Timestamp</p>
|
||||
<p class="text-sm text-gray-800 dark:text-gray-200" x-text="formatTimestamp(selectedLog.timestamp)"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Level</p>
|
||||
<p class="text-sm text-gray-800 dark:text-gray-200" x-text="selectedLog.level"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Logger</p>
|
||||
<p class="text-sm text-gray-800 dark:text-gray-200" x-text="selectedLog.logger_name"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Module</p>
|
||||
<p class="text-sm text-gray-800 dark:text-gray-200" x-text="selectedLog.module || '-'"></p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Message</p>
|
||||
<p class="text-sm text-gray-800 dark:text-gray-200 bg-gray-50 dark:bg-gray-700 p-3 rounded" x-text="selectedLog.message"></p>
|
||||
</div>
|
||||
<div x-show="selectedLog.exception_message">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Exception</p>
|
||||
<p class="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900 p-3 rounded" x-text="selectedLog.exception_type + ': ' + selectedLog.exception_message"></p>
|
||||
</div>
|
||||
<div x-show="selectedLog.stack_trace">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Stack Trace</p>
|
||||
<pre class="text-xs text-gray-800 dark:text-gray-200 bg-gray-50 dark:bg-gray-700 p-3 rounded overflow-x-auto"><code x-text="selectedLog.stack_trace"></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('static', path='admin/js/logs.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -1,11 +1,513 @@
|
||||
<DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>System-wide marketplace monitoring</title>
|
||||
</head>
|
||||
<body>
|
||||
<-- System-wide marketplace monitoring -->
|
||||
</body>
|
||||
</html>
|
||||
{# app/templates/admin/marketplace.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Marketplace Import{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminMarketplace(){% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="/static/admin/js/marketplace.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between my-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Marketplace Import
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Import products from Letzshop marketplace for any vendor (self-service)
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="refreshJobs()"
|
||||
:disabled="loading"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div x-show="successMessage" x-transition class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold" x-text="successMessage"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div x-show="error" x-transition class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold">Error</p>
|
||||
<p class="text-sm" x-text="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Form Card -->
|
||||
<div class="mb-8 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Start New Import
|
||||
</h3>
|
||||
|
||||
<form @submit.prevent="startImport()">
|
||||
<div class="grid gap-6 mb-4 md:grid-cols-2">
|
||||
<!-- Vendor Selection -->
|
||||
<div class="md:col-span-2">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Vendor <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
x-model="importForm.vendor_id"
|
||||
@change="onVendorChange()"
|
||||
required
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
|
||||
>
|
||||
<option value="">Select a vendor...</option>
|
||||
<template x-for="vendor in vendors" :key="vendor.id">
|
||||
<option :value="vendor.id" x-text="`${vendor.name} (${vendor.vendor_code})`"></option>
|
||||
</template>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Select the vendor to import products for
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- CSV URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
CSV URL <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
x-model="importForm.csv_url"
|
||||
type="url"
|
||||
required
|
||||
placeholder="https://example.com/products.csv"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Enter the URL of the Letzshop CSV feed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Language Selection -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Language
|
||||
</label>
|
||||
<select
|
||||
x-model="importForm.language"
|
||||
@change="onLanguageChange()"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
|
||||
>
|
||||
<option value="fr">French (FR)</option>
|
||||
<option value="en">English (EN)</option>
|
||||
<option value="de">German (DE)</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Select the language of the CSV feed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Marketplace -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Marketplace
|
||||
</label>
|
||||
<input
|
||||
x-model="importForm.marketplace"
|
||||
type="text"
|
||||
readonly
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-600 border border-gray-300 dark:border-gray-600 rounded-md cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Batch Size -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Batch Size
|
||||
</label>
|
||||
<input
|
||||
x-model.number="importForm.batch_size"
|
||||
type="number"
|
||||
min="100"
|
||||
max="5000"
|
||||
step="100"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Number of products to process per batch (100-5000)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Fill Buttons (when vendor is selected) -->
|
||||
<div class="mb-4" x-show="importForm.vendor_id && selectedVendor">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Quick Fill (from vendor settings)
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="quickFill('fr')"
|
||||
x-show="selectedVendor?.letzshop_csv_url_fr"
|
||||
class="flex items-center px-3 py-1 text-xs font-medium leading-5 text-purple-600 transition-colors duration-150 bg-purple-100 border border-purple-300 rounded-md hover:bg-purple-200 focus:outline-none"
|
||||
>
|
||||
<span x-html="$icon('lightning-bolt', 'w-3 h-3 mr-1')"></span>
|
||||
French CSV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="quickFill('en')"
|
||||
x-show="selectedVendor?.letzshop_csv_url_en"
|
||||
class="flex items-center px-3 py-1 text-xs font-medium leading-5 text-purple-600 transition-colors duration-150 bg-purple-100 border border-purple-300 rounded-md hover:bg-purple-200 focus:outline-none"
|
||||
>
|
||||
<span x-html="$icon('lightning-bolt', 'w-3 h-3 mr-1')"></span>
|
||||
English CSV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="quickFill('de')"
|
||||
x-show="selectedVendor?.letzshop_csv_url_de"
|
||||
class="flex items-center px-3 py-1 text-xs font-medium leading-5 text-purple-600 transition-colors duration-150 bg-purple-100 border border-purple-300 rounded-md hover:bg-purple-200 focus:outline-none"
|
||||
>
|
||||
<span x-html="$icon('lightning-bolt', 'w-3 h-3 mr-1')"></span>
|
||||
German CSV
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400" x-show="!selectedVendor?.letzshop_csv_url_fr && !selectedVendor?.letzshop_csv_url_en && !selectedVendor?.letzshop_csv_url_de">
|
||||
This vendor has no Letzshop CSV URLs configured
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex items-center justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="importing || !importForm.csv_url || !importForm.vendor_id"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="!importing" x-html="$icon('upload', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="importing" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="importing ? 'Starting Import...' : 'Start Import'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="mb-4 bg-white rounded-lg shadow-xs dark:bg-gray-800 p-4">
|
||||
<div class="grid gap-4 md:grid-cols-4">
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Filter by Vendor
|
||||
</label>
|
||||
<select
|
||||
x-model="filters.vendor_id"
|
||||
@change="loadJobs()"
|
||||
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Vendors</option>
|
||||
<template x-for="vendor in vendors" :key="vendor.id">
|
||||
<option :value="vendor.id" x-text="`${vendor.name} (${vendor.vendor_code})`"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Filter by Status
|
||||
</label>
|
||||
<select
|
||||
x-model="filters.status"
|
||||
@change="loadJobs()"
|
||||
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Statuses</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="failed">Failed</option>
|
||||
<option value="completed_with_errors">Completed with Errors</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-medium text-gray-700 dark:text-gray-400 mb-1">
|
||||
Filter by Marketplace
|
||||
</label>
|
||||
<select
|
||||
x-model="filters.marketplace"
|
||||
@change="loadJobs()"
|
||||
class="block w-full px-2 py-1 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none"
|
||||
>
|
||||
<option value="">All Marketplaces</option>
|
||||
<option value="Letzshop">Letzshop</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="flex items-end">
|
||||
<button
|
||||
@click="clearFilters()"
|
||||
class="px-3 py-1 text-sm font-medium leading-5 text-gray-700 dark:text-gray-300 transition-colors duration-150 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none"
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Jobs List -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
My Import Jobs
|
||||
</h3>
|
||||
<a href="/admin/imports" class="text-sm text-purple-600 hover:text-purple-700 dark:text-purple-400 dark:hover:text-purple-300">
|
||||
View all system imports →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="text-center py-12">
|
||||
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading import jobs...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="!loading && jobs.length === 0" class="text-center py-12">
|
||||
<span x-html="$icon('inbox', 'inline w-12 h-12 text-gray-400 mb-4')"></span>
|
||||
<p class="text-gray-600 dark:text-gray-400">You haven't triggered any imports yet</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-500">Start a new import using the form above</p>
|
||||
</div>
|
||||
|
||||
<!-- Jobs Table -->
|
||||
<div x-show="!loading && jobs.length > 0" class="w-full overflow-hidden rounded-lg shadow-xs">
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="w-full whitespace-no-wrap">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3">Job ID</th>
|
||||
<th class="px-4 py-3">Vendor</th>
|
||||
<th class="px-4 py-3">Marketplace</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Progress</th>
|
||||
<th class="px-4 py-3">Started</th>
|
||||
<th class="px-4 py-3">Duration</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-for="job in jobs" :key="job.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400">
|
||||
<td class="px-4 py-3 text-sm">
|
||||
#<span x-text="job.id"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="getVendorName(job.vendor_id)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="job.marketplace"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
|
||||
:class="{
|
||||
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': job.status === 'completed',
|
||||
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': job.status === 'processing',
|
||||
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': job.status === 'pending',
|
||||
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': job.status === 'failed',
|
||||
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': job.status === 'completed_with_errors'
|
||||
}"
|
||||
x-text="job.status.replace('_', ' ').toUpperCase()">
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<span class="text-green-600 dark:text-green-400" x-text="job.imported_count"></span> imported,
|
||||
<span class="text-blue-600 dark:text-blue-400" x-text="job.updated_count"></span> updated
|
||||
</div>
|
||||
<div x-show="job.error_count > 0" class="text-xs text-red-600 dark:text-red-400">
|
||||
<span x-text="job.error_count"></span> errors
|
||||
</div>
|
||||
<div x-show="job.total_processed > 0" class="text-xs text-gray-500 dark:text-gray-500">
|
||||
Total: <span x-text="job.total_processed"></span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="job.started_at ? formatDate(job.started_at) : 'Not started'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="calculateDuration(job)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="viewJobDetails(job.id)"
|
||||
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-purple-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="View Details"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button
|
||||
x-show="job.status === 'processing' || job.status === 'pending'"
|
||||
@click="refreshJobStatus(job.id)"
|
||||
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-blue-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="Refresh Status"
|
||||
>
|
||||
<span x-html="$icon('refresh', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div x-show="!loading && totalJobs > limit" class="px-4 py-3 border-t dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-400">
|
||||
Showing <span x-text="((page - 1) * limit) + 1"></span> to
|
||||
<span x-text="Math.min(page * limit, totalJobs)"></span> of
|
||||
<span x-text="totalJobs"></span> jobs
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
@click="previousPage()"
|
||||
:disabled="page === 1"
|
||||
class="px-3 py-1 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-md hover:bg-purple-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
@click="nextPage()"
|
||||
:disabled="page * limit >= totalJobs"
|
||||
class="px-3 py-1 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-md hover:bg-purple-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Job Details Modal (same as vendor version) -->
|
||||
<div x-show="showJobModal"
|
||||
x-cloak
|
||||
@click.away="closeJobModal()"
|
||||
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0">
|
||||
<div @click.away="closeJobModal()"
|
||||
class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-2xl"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 transform translate-y-1/2"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0 transform translate-y-1/2">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Import Job Details
|
||||
</h3>
|
||||
<button @click="closeJobModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-html="$icon('close', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div x-show="selectedJob" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Job ID</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.id"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Vendor</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="getVendorName(selectedJob?.vendor_id)"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Marketplace</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.marketplace"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Status</p>
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
|
||||
:class="{
|
||||
'text-green-700 bg-green-100': selectedJob?.status === 'completed',
|
||||
'text-blue-700 bg-blue-100': selectedJob?.status === 'processing',
|
||||
'text-yellow-700 bg-yellow-100': selectedJob?.status === 'pending',
|
||||
'text-red-700 bg-red-100': selectedJob?.status === 'failed',
|
||||
'text-orange-700 bg-orange-100': selectedJob?.status === 'completed_with_errors'
|
||||
}"
|
||||
x-text="selectedJob?.status.replace('_', ' ').toUpperCase()">
|
||||
</span>
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Source URL</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100 truncate" x-text="selectedJob?.source_url" :title="selectedJob?.source_url"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Imported</p>
|
||||
<p class="text-sm text-green-600 dark:text-green-400" x-text="selectedJob?.imported_count"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Updated</p>
|
||||
<p class="text-sm text-blue-600 dark:text-blue-400" x-text="selectedJob?.updated_count"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Errors</p>
|
||||
<p class="text-sm text-red-600 dark:text-red-400" x-text="selectedJob?.error_count"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Total Processed</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.total_processed"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Started At</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.started_at ? formatDate(selectedJob.started_at) : 'Not started'"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Completed At</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.completed_at ? formatDate(selectedJob.completed_at) : 'Not completed'"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Details -->
|
||||
<div x-show="selectedJob?.error_details && selectedJob.error_details.length > 0" class="mt-4">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Error Details</p>
|
||||
<div class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg max-h-48 overflow-y-auto">
|
||||
<pre class="text-xs text-red-700 dark:text-red-300 whitespace-pre-wrap" x-text="JSON.stringify(selectedJob.error_details, null, 2)"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="flex justify-end mt-6">
|
||||
<button
|
||||
@click="closeJobModal()"
|
||||
class="px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 border border-gray-300 rounded-lg dark:text-gray-400 dark:border-gray-700 hover:border-gray-500 focus:outline-none"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
261
app/templates/admin/settings.html
Normal file
261
app/templates/admin/settings.html
Normal file
@@ -0,0 +1,261 @@
|
||||
{# app/templates/admin/settings.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Platform Settings{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminSettings(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between my-6">
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Platform Settings
|
||||
</h2>
|
||||
<button
|
||||
@click="refresh()"
|
||||
:disabled="loading"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Success Message -->
|
||||
<div x-show="successMessage" x-transition class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold">Success</p>
|
||||
<p class="text-sm" x-text="successMessage"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div x-show="error" x-transition class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold">Error</p>
|
||||
<p class="text-sm" x-text="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Settings Categories Tabs -->
|
||||
<div class="mb-6">
|
||||
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav class="-mb-px flex space-x-8">
|
||||
<button
|
||||
@click="activeTab = 'logging'"
|
||||
:class="activeTab === 'logging' ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'"
|
||||
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||
>
|
||||
<span x-html="$icon('document-text', 'inline w-5 h-5 mr-2')"></span>
|
||||
Logging
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'system'"
|
||||
:class="activeTab === 'system' ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'"
|
||||
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||
>
|
||||
<span x-html="$icon('cog', 'inline w-5 h-5 mr-2')"></span>
|
||||
System
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'security'"
|
||||
:class="activeTab === 'security' ? 'border-purple-500 text-purple-600 dark:text-purple-400' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'"
|
||||
class="whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm transition-colors"
|
||||
>
|
||||
<span x-html="$icon('shield-check', 'inline w-5 h-5 mr-2')"></span>
|
||||
Security
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Logging Settings Tab -->
|
||||
<div x-show="activeTab === 'logging'" x-transition>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">
|
||||
Logging Configuration
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Configure application logging behavior, file rotation, and retention policies.
|
||||
</p>
|
||||
|
||||
<!-- Log Level -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Log Level
|
||||
</label>
|
||||
<select
|
||||
x-model="logSettings.log_level"
|
||||
class="block w-full md:w-1/2 px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
>
|
||||
<option value="DEBUG">DEBUG - Detailed information for diagnosing problems</option>
|
||||
<option value="INFO">INFO - General informational messages</option>
|
||||
<option value="WARNING">WARNING - Warning messages</option>
|
||||
<option value="ERROR">ERROR - Error messages</option>
|
||||
<option value="CRITICAL">CRITICAL - Critical errors only</option>
|
||||
</select>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Changes take effect immediately without restart.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- File Rotation Settings -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Max File Size (MB)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
x-model.number="logSettings.log_file_max_size_mb"
|
||||
min="1"
|
||||
max="1000"
|
||||
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Log file will rotate when it reaches this size.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Backup File Count
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
x-model.number="logSettings.log_file_backup_count"
|
||||
min="0"
|
||||
max="50"
|
||||
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Number of rotated backup files to keep.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Database Retention -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Database Log Retention (Days)
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
x-model.number="logSettings.db_log_retention_days"
|
||||
min="1"
|
||||
max="365"
|
||||
class="block w-full md:w-1/2 px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
Logs older than this will be automatically deleted from database.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Logging Toggles -->
|
||||
<div class="space-y-4 mb-6">
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div>
|
||||
<p class="font-medium text-gray-700 dark:text-gray-200">File Logging</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Write logs to rotating files on disk</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" x-model="logSettings.file_logging_enabled" class="sr-only peer">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:peer-focus:ring-purple-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-purple-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div>
|
||||
<p class="font-medium text-gray-700 dark:text-gray-200">Database Logging</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Store WARNING/ERROR/CRITICAL logs in database for searching</p>
|
||||
</div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" x-model="logSettings.db_logging_enabled" class="sr-only peer">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-purple-300 dark:peer-focus:ring-purple-800 rounded-full peer dark:bg-gray-600 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-purple-600"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
<span x-html="$icon('information-circle', 'inline w-4 h-4 mr-1')"></span>
|
||||
File rotation settings require application restart to take effect.
|
||||
</p>
|
||||
<button
|
||||
@click="saveLogSettings()"
|
||||
:disabled="saving"
|
||||
class="px-6 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="!saving">Save Logging Settings</span>
|
||||
<span x-show="saving">Saving...</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="mt-8">
|
||||
<div class="mb-8 p-6 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<h4 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Quick Actions
|
||||
</h4>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a
|
||||
href="/admin/logs"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
|
||||
>
|
||||
<span x-html="$icon('document-text', 'w-4 h-4 mr-2')"></span>
|
||||
View Logs
|
||||
</a>
|
||||
<button
|
||||
@click="cleanupOldLogs()"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 transition-colors duration-150 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:shadow-outline-gray"
|
||||
>
|
||||
<span x-html="$icon('delete', 'w-4 h-4 mr-2')"></span>
|
||||
Cleanup Old Logs
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- System Settings Tab -->
|
||||
<div x-show="activeTab === 'system'" x-transition>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">
|
||||
System Configuration
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
General system settings and configuration options.
|
||||
</p>
|
||||
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<span x-html="$icon('cog', 'inline w-12 h-12 mb-4')"></span>
|
||||
<p>System settings coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Security Settings Tab -->
|
||||
<div x-show="activeTab === 'security'" x-transition>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200 mb-4">
|
||||
Security Configuration
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-6">
|
||||
Security and authentication settings.
|
||||
</p>
|
||||
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
|
||||
<span x-html="$icon('shield-check', 'inline w-12 h-12 mb-4')"></span>
|
||||
<p>Security settings coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('static', path='admin/js/settings.js') }}"></script>
|
||||
{% endblock %}
|
||||
@@ -223,7 +223,7 @@
|
||||
@click="deleteUser(user)"
|
||||
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300"
|
||||
title="Delete"
|
||||
x-html="$icon('trash', 'w-5 h-5')"
|
||||
x-html="$icon('delete', 'w-5 h-5')"
|
||||
></button>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
@@ -18,23 +18,11 @@
|
||||
<span x-text="vendor?.subdomain"></span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
:href="`/admin/vendors/${vendorCode}/edit`"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
|
||||
<span x-html="$icon('edit', 'w-4 h-4 mr-2')"></span>
|
||||
Edit Vendor
|
||||
</a>
|
||||
<a :href="`/admin/vendors/${vendorCode}/theme`"
|
||||
class="px-4 py-2 text-sm font-medium text-purple-700 bg-white border border-purple-600 rounded-lg hover:bg-purple-50">
|
||||
Customize Theme
|
||||
</a>
|
||||
<a href="/admin/vendors"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-white border border-gray-300 rounded-lg dark:text-gray-400 dark:border-gray-600 dark:bg-gray-800 hover:border-gray-400 focus:outline-none">
|
||||
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
|
||||
Back
|
||||
</a>
|
||||
</div>
|
||||
<a href="/admin/vendors"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 bg-white border border-gray-300 rounded-lg dark:text-gray-400 dark:border-gray-600 dark:bg-gray-800 hover:border-gray-400 focus:outline-none">
|
||||
<span x-html="$icon('arrow-left', 'w-4 h-4 mr-2')"></span>
|
||||
Back
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
@@ -54,6 +42,27 @@
|
||||
|
||||
<!-- Vendor Details -->
|
||||
<div x-show="!loading && vendor">
|
||||
<!-- Quick Actions Card -->
|
||||
<div class="px-4 py-3 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Quick Actions
|
||||
</h3>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<a
|
||||
:href="`/admin/vendors/${vendorCode}/edit`"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
|
||||
<span x-html="$icon('edit', 'w-4 h-4 mr-2')"></span>
|
||||
Edit Vendor
|
||||
</a>
|
||||
<button
|
||||
@click="deleteVendor()"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-red-600 border border-transparent rounded-lg hover:bg-red-700 focus:outline-none focus:shadow-outline-red">
|
||||
<span x-html="$icon('delete', 'w-4 h-4 mr-2')"></span>
|
||||
Delete Vendor
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status Cards -->
|
||||
<div class="grid gap-6 mb-8 md:grid-cols-4">
|
||||
<!-- Verification Status -->
|
||||
@@ -255,14 +264,20 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-3">
|
||||
<button
|
||||
@click="deleteVendor()"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-red-600 border border-transparent rounded-lg hover:bg-red-700 focus:outline-none focus:shadow-outline-red">
|
||||
<span x-html="$icon('delete', 'w-4 h-4 mr-2')"></span>
|
||||
Delete Vendor
|
||||
</button>
|
||||
<!-- More Actions -->
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
More Actions
|
||||
</h3>
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<a
|
||||
:href="`/admin/vendors/${vendorCode}/theme`"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple"
|
||||
>
|
||||
<span x-html="$icon('color-swatch', 'w-4 h-4 mr-2')"></span>
|
||||
Customize Theme
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -267,6 +267,70 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Marketplace Integration -->
|
||||
<div class="mb-8">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
<span x-html="$icon('shopping-bag', 'inline w-5 h-5 mr-2')"></span>
|
||||
Letzshop Marketplace URLs
|
||||
</h3>
|
||||
<p class="mb-4 text-sm text-gray-600 dark:text-gray-400">
|
||||
Configure CSV feed URLs for automatic product imports from Letzshop marketplace
|
||||
</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- French CSV URL -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
French CSV URL
|
||||
</span>
|
||||
<input
|
||||
type="url"
|
||||
x-model="formData.letzshop_csv_url_fr"
|
||||
:disabled="saving"
|
||||
placeholder="https://letzshop.lu/feed/fr/products.csv"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
|
||||
>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
URL for French language product feed
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- English CSV URL -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
English CSV URL
|
||||
</span>
|
||||
<input
|
||||
type="url"
|
||||
x-model="formData.letzshop_csv_url_en"
|
||||
:disabled="saving"
|
||||
placeholder="https://letzshop.lu/feed/en/products.csv"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
|
||||
>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
URL for English language product feed
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- German CSV URL -->
|
||||
<label class="block text-sm">
|
||||
<span class="text-gray-700 dark:text-gray-400">
|
||||
German CSV URL
|
||||
</span>
|
||||
<input
|
||||
type="url"
|
||||
x-model="formData.letzshop_csv_url_de"
|
||||
:disabled="saving"
|
||||
placeholder="https://letzshop.lu/feed/de/products.csv"
|
||||
class="block w-full mt-1 text-sm dark:text-gray-300 dark:border-gray-600 dark:bg-gray-700 focus:border-purple-400 focus:outline-none focus:shadow-outline-purple dark:focus:shadow-outline-gray form-input"
|
||||
>
|
||||
<span class="text-xs text-gray-600 dark:text-gray-400 mt-1">
|
||||
URL for German language product feed
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save Button -->
|
||||
<div class="flex items-center justify-end gap-3 pt-6 border-t dark:border-gray-700">
|
||||
<a
|
||||
|
||||
99
app/templates/admin/vendor-themes.html
Normal file
99
app/templates/admin/vendor-themes.html
Normal file
@@ -0,0 +1,99 @@
|
||||
{# app/templates/admin/vendor-themes.html #}
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Vendor Themes{% endblock %}
|
||||
|
||||
{% block alpine_data %}adminVendorThemes(){% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between my-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Vendor Themes
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Customize vendor theme colors and branding
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vendor Selection -->
|
||||
<div class="px-4 py-6 mb-6 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Select Vendor
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
Choose a vendor to customize their theme
|
||||
</p>
|
||||
|
||||
<div class="max-w-md">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
Vendor
|
||||
</label>
|
||||
<select
|
||||
x-model="selectedVendorCode"
|
||||
@change="navigateToTheme()"
|
||||
class="block w-full px-3 py-2 text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-600"
|
||||
>
|
||||
<option value="">Select a vendor...</option>
|
||||
<template x-for="vendor in vendors" :key="vendor.vendor_code">
|
||||
<option :value="vendor.vendor_code" x-text="`${vendor.name} (${vendor.vendor_code})`"></option>
|
||||
</template>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="text-center py-12">
|
||||
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading vendors...</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div x-show="error && !loading" class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold">Error loading vendors</p>
|
||||
<p class="text-sm" x-text="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Vendors List -->
|
||||
<div x-show="!loading && vendors.length > 0">
|
||||
<div class="px-4 py-3 bg-white rounded-lg shadow-md dark:bg-gray-800">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
All Vendors
|
||||
</h3>
|
||||
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
<template x-for="vendor in vendors" :key="vendor.vendor_code">
|
||||
<a
|
||||
:href="`/admin/vendors/${vendor.vendor_code}/theme`"
|
||||
class="block p-4 border border-gray-200 dark:border-gray-700 rounded-lg hover:border-purple-500 dark:hover:border-purple-500 hover:shadow-md transition-all"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h4 class="font-semibold text-gray-700 dark:text-gray-200" x-text="vendor.name"></h4>
|
||||
<span x-html="$icon('color-swatch', 'w-5 h-5 text-purple-600')"></span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400" x-text="vendor.vendor_code"></p>
|
||||
<div class="mt-3 flex items-center text-xs text-purple-600 dark:text-purple-400">
|
||||
<span>Customize theme</span>
|
||||
<span x-html="$icon('chevron-right', 'w-4 h-4 ml-1')"></span>
|
||||
</div>
|
||||
</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="!loading && vendors.length === 0" class="text-center py-12">
|
||||
<span x-html="$icon('shopping-bag', 'inline w-12 h-12 text-gray-400 mb-4')"></span>
|
||||
<p class="text-gray-600 dark:text-gray-400">No vendors found</p>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="{{ url_for('static', path='admin/js/vendor-themes.js') }}"></script>
|
||||
{% endblock %}
|
||||
421
app/templates/vendor/marketplace.html
vendored
421
app/templates/vendor/marketplace.html
vendored
@@ -1,31 +1,416 @@
|
||||
{# app/templates/vendor/marketplace.html #}
|
||||
{% extends "vendor/base.html" %}
|
||||
|
||||
{% block title %}Marketplace{% endblock %}
|
||||
{% block title %}Marketplace Import{% endblock %}
|
||||
|
||||
{% block alpine_data %}data(){% endblock %}
|
||||
{% block alpine_data %}vendorMarketplace(){% endblock %}
|
||||
|
||||
{% block extra_scripts %}
|
||||
<script src="/static/vendor/js/marketplace.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Page Header -->
|
||||
<div class="flex items-center justify-between my-6">
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Marketplace Import
|
||||
</h2>
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold text-gray-700 dark:text-gray-200">
|
||||
Marketplace Import
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">
|
||||
Import products from Letzshop marketplace CSV feeds
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@click="refreshJobs()"
|
||||
:disabled="loading"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="loading ? 'Loading...' : 'Refresh'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Coming Soon Notice -->
|
||||
<div class="w-full mb-8 overflow-hidden rounded-lg shadow-xs">
|
||||
<div class="w-full p-12 bg-white dark:bg-gray-800 text-center">
|
||||
<div class="text-6xl mb-4">🌐</div>
|
||||
<h3 class="text-xl font-semibold text-gray-700 dark:text-gray-200 mb-2">
|
||||
Marketplace Import Coming Soon
|
||||
<!-- Success Message -->
|
||||
<div x-show="successMessage" x-transition class="mb-6 p-4 bg-green-100 border border-green-400 text-green-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('check-circle', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold" x-text="successMessage"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Message -->
|
||||
<div x-show="error" x-transition class="mb-6 p-4 bg-red-100 border border-red-400 text-red-700 rounded-lg flex items-start">
|
||||
<span x-html="$icon('exclamation', 'w-5 h-5 mr-3 mt-0.5 flex-shrink-0')"></span>
|
||||
<div>
|
||||
<p class="font-semibold">Error</p>
|
||||
<p class="text-sm" x-text="error"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Form Card -->
|
||||
<div class="mb-8 bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Start New Import
|
||||
</h3>
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||
This page is under development. You'll be able to import products from marketplace here.
|
||||
</p>
|
||||
<a href="/vendor/{{ vendor_code }}/dashboard"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple">
|
||||
Back to Dashboard
|
||||
</a>
|
||||
|
||||
<form @submit.prevent="startImport()">
|
||||
<div class="grid gap-6 mb-4 md:grid-cols-2">
|
||||
<!-- CSV URL -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
CSV URL <span class="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
x-model="importForm.csv_url"
|
||||
type="url"
|
||||
required
|
||||
placeholder="https://example.com/products.csv"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Enter the URL of the Letzshop CSV feed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Language Selection -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Language
|
||||
</label>
|
||||
<select
|
||||
x-model="importForm.language"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
|
||||
>
|
||||
<option value="fr">French (FR)</option>
|
||||
<option value="en">English (EN)</option>
|
||||
<option value="de">German (DE)</option>
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Select the language of the CSV feed
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Marketplace -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Marketplace
|
||||
</label>
|
||||
<input
|
||||
x-model="importForm.marketplace"
|
||||
type="text"
|
||||
readonly
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-600 border border-gray-300 dark:border-gray-600 rounded-md cursor-not-allowed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Batch Size -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Batch Size
|
||||
</label>
|
||||
<input
|
||||
x-model.number="importForm.batch_size"
|
||||
type="number"
|
||||
min="100"
|
||||
max="5000"
|
||||
step="100"
|
||||
class="block w-full px-3 py-2 text-sm text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md focus:border-purple-400 focus:outline-none focus:shadow-outline-purple"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
Number of products to process per batch (100-5000)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Fill Buttons -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-400 mb-2">
|
||||
Quick Fill
|
||||
</label>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="quickFill('fr')"
|
||||
x-show="vendorSettings.letzshop_csv_url_fr"
|
||||
class="flex items-center px-3 py-1 text-xs font-medium leading-5 text-purple-600 transition-colors duration-150 bg-purple-100 border border-purple-300 rounded-md hover:bg-purple-200 focus:outline-none"
|
||||
>
|
||||
<span x-html="$icon('lightning-bolt', 'w-3 h-3 mr-1')"></span>
|
||||
French CSV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="quickFill('en')"
|
||||
x-show="vendorSettings.letzshop_csv_url_en"
|
||||
class="flex items-center px-3 py-1 text-xs font-medium leading-5 text-purple-600 transition-colors duration-150 bg-purple-100 border border-purple-300 rounded-md hover:bg-purple-200 focus:outline-none"
|
||||
>
|
||||
<span x-html="$icon('lightning-bolt', 'w-3 h-3 mr-1')"></span>
|
||||
English CSV
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="quickFill('de')"
|
||||
x-show="vendorSettings.letzshop_csv_url_de"
|
||||
class="flex items-center px-3 py-1 text-xs font-medium leading-5 text-purple-600 transition-colors duration-150 bg-purple-100 border border-purple-300 rounded-md hover:bg-purple-200 focus:outline-none"
|
||||
>
|
||||
<span x-html="$icon('lightning-bolt', 'w-3 h-3 mr-1')"></span>
|
||||
German CSV
|
||||
</button>
|
||||
</div>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400" x-show="!vendorSettings.letzshop_csv_url_fr && !vendorSettings.letzshop_csv_url_en && !vendorSettings.letzshop_csv_url_de">
|
||||
Configure Letzshop CSV URLs in settings to use quick fill
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Submit Button -->
|
||||
<div class="flex items-center justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="importing || !importForm.csv_url"
|
||||
class="flex items-center px-4 py-2 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-lg hover:bg-purple-700 focus:outline-none focus:shadow-outline-purple disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<span x-show="!importing" x-html="$icon('upload', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-show="importing" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
|
||||
<span x-text="importing ? 'Starting Import...' : 'Start Import'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Import Jobs List -->
|
||||
<div class="bg-white rounded-lg shadow-xs dark:bg-gray-800">
|
||||
<div class="p-6">
|
||||
<h3 class="mb-4 text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Import History
|
||||
</h3>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div x-show="loading" class="text-center py-12">
|
||||
<span x-html="$icon('spinner', 'inline w-8 h-8 text-purple-600')"></span>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Loading import jobs...</p>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="!loading && jobs.length === 0" class="text-center py-12">
|
||||
<span x-html="$icon('inbox', 'inline w-12 h-12 text-gray-400 mb-4')"></span>
|
||||
<p class="text-gray-600 dark:text-gray-400">No import jobs yet</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-500">Start your first import using the form above</p>
|
||||
</div>
|
||||
|
||||
<!-- Jobs Table -->
|
||||
<div x-show="!loading && jobs.length > 0" class="w-full overflow-hidden rounded-lg shadow-xs">
|
||||
<div class="w-full overflow-x-auto">
|
||||
<table class="w-full whitespace-no-wrap">
|
||||
<thead>
|
||||
<tr class="text-xs font-semibold tracking-wide text-left text-gray-500 uppercase border-b dark:border-gray-700 bg-gray-50 dark:text-gray-400 dark:bg-gray-800">
|
||||
<th class="px-4 py-3">Job ID</th>
|
||||
<th class="px-4 py-3">Marketplace</th>
|
||||
<th class="px-4 py-3">Status</th>
|
||||
<th class="px-4 py-3">Progress</th>
|
||||
<th class="px-4 py-3">Started</th>
|
||||
<th class="px-4 py-3">Duration</th>
|
||||
<th class="px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white divide-y dark:divide-gray-700 dark:bg-gray-800">
|
||||
<template x-for="job in jobs" :key="job.id">
|
||||
<tr class="text-gray-700 dark:text-gray-400">
|
||||
<td class="px-4 py-3 text-sm">
|
||||
#<span x-text="job.id"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="job.marketplace"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
|
||||
:class="{
|
||||
'text-green-700 bg-green-100 dark:bg-green-700 dark:text-green-100': job.status === 'completed',
|
||||
'text-blue-700 bg-blue-100 dark:bg-blue-700 dark:text-blue-100': job.status === 'processing',
|
||||
'text-yellow-700 bg-yellow-100 dark:bg-yellow-700 dark:text-yellow-100': job.status === 'pending',
|
||||
'text-red-700 bg-red-100 dark:bg-red-700 dark:text-red-100': job.status === 'failed',
|
||||
'text-orange-700 bg-orange-100 dark:bg-orange-700 dark:text-orange-100': job.status === 'completed_with_errors'
|
||||
}"
|
||||
x-text="job.status.replace('_', ' ').toUpperCase()">
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="space-y-1">
|
||||
<div class="text-xs text-gray-600 dark:text-gray-400">
|
||||
<span class="text-green-600 dark:text-green-400" x-text="job.imported_count"></span> imported,
|
||||
<span class="text-blue-600 dark:text-blue-400" x-text="job.updated_count"></span> updated
|
||||
</div>
|
||||
<div x-show="job.error_count > 0" class="text-xs text-red-600 dark:text-red-400">
|
||||
<span x-text="job.error_count"></span> errors
|
||||
</div>
|
||||
<div x-show="job.total_processed > 0" class="text-xs text-gray-500 dark:text-gray-500">
|
||||
Total: <span x-text="job.total_processed"></span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="job.started_at ? formatDate(job.started_at) : 'Not started'"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<span x-text="calculateDuration(job)"></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-sm">
|
||||
<div class="flex items-center space-x-2">
|
||||
<button
|
||||
@click="viewJobDetails(job.id)"
|
||||
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-purple-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="View Details"
|
||||
>
|
||||
<span x-html="$icon('eye', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
<button
|
||||
x-show="job.status === 'processing' || job.status === 'pending'"
|
||||
@click="refreshJobStatus(job.id)"
|
||||
class="flex items-center justify-between px-2 py-1 text-xs font-medium leading-5 text-blue-600 rounded-lg dark:text-gray-400 focus:outline-none hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
title="Refresh Status"
|
||||
>
|
||||
<span x-html="$icon('refresh', 'w-4 h-4')"></span>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<div x-show="!loading && totalJobs > limit" class="px-4 py-3 border-t dark:border-gray-700">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-sm text-gray-700 dark:text-gray-400">
|
||||
Showing <span x-text="((page - 1) * limit) + 1"></span> to
|
||||
<span x-text="Math.min(page * limit, totalJobs)"></span> of
|
||||
<span x-text="totalJobs"></span> jobs
|
||||
</div>
|
||||
<div class="flex space-x-2">
|
||||
<button
|
||||
@click="previousPage()"
|
||||
:disabled="page === 1"
|
||||
class="px-3 py-1 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-md hover:bg-purple-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
@click="nextPage()"
|
||||
:disabled="page * limit >= totalJobs"
|
||||
class="px-3 py-1 text-sm font-medium leading-5 text-white transition-colors duration-150 bg-purple-600 border border-transparent rounded-md hover:bg-purple-700 focus:outline-none disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Job Details Modal -->
|
||||
<div x-show="showJobModal"
|
||||
x-cloak
|
||||
@click.away="closeJobModal()"
|
||||
class="fixed inset-0 z-30 flex items-end bg-black bg-opacity-50 sm:items-center sm:justify-center"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0">
|
||||
<div @click.away="closeJobModal()"
|
||||
class="w-full px-6 py-4 overflow-hidden bg-white rounded-t-lg dark:bg-gray-800 sm:rounded-lg sm:m-4 sm:max-w-2xl"
|
||||
x-transition:enter="transition ease-out duration-150"
|
||||
x-transition:enter-start="opacity-0 transform translate-y-1/2"
|
||||
x-transition:enter-end="opacity-100"
|
||||
x-transition:leave="transition ease-in duration-150"
|
||||
x-transition:leave-start="opacity-100"
|
||||
x-transition:leave-end="opacity-0 transform translate-y-1/2">
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-700 dark:text-gray-200">
|
||||
Import Job Details
|
||||
</h3>
|
||||
<button @click="closeJobModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<span x-html="$icon('close', 'w-5 h-5')"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div x-show="selectedJob" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Job ID</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.id"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Marketplace</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.marketplace"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Status</p>
|
||||
<span class="px-2 py-1 font-semibold leading-tight rounded-full text-xs"
|
||||
:class="{
|
||||
'text-green-700 bg-green-100': selectedJob?.status === 'completed',
|
||||
'text-blue-700 bg-blue-100': selectedJob?.status === 'processing',
|
||||
'text-yellow-700 bg-yellow-100': selectedJob?.status === 'pending',
|
||||
'text-red-700 bg-red-100': selectedJob?.status === 'failed',
|
||||
'text-orange-700 bg-orange-100': selectedJob?.status === 'completed_with_errors'
|
||||
}"
|
||||
x-text="selectedJob?.status.replace('_', ' ').toUpperCase()">
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Source URL</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100 truncate" x-text="selectedJob?.source_url" :title="selectedJob?.source_url"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Imported</p>
|
||||
<p class="text-sm text-green-600 dark:text-green-400" x-text="selectedJob?.imported_count"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Updated</p>
|
||||
<p class="text-sm text-blue-600 dark:text-blue-400" x-text="selectedJob?.updated_count"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Errors</p>
|
||||
<p class="text-sm text-red-600 dark:text-red-400" x-text="selectedJob?.error_count"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Total Processed</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.total_processed"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Started At</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.started_at ? formatDate(selectedJob.started_at) : 'Not started'"></p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400">Completed At</p>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100" x-text="selectedJob?.completed_at ? formatDate(selectedJob.completed_at) : 'Not completed'"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error Details -->
|
||||
<div x-show="selectedJob?.error_details && selectedJob.error_details.length > 0" class="mt-4">
|
||||
<p class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Error Details</p>
|
||||
<div class="p-3 bg-red-50 dark:bg-red-900/20 rounded-lg max-h-48 overflow-y-auto">
|
||||
<pre class="text-xs text-red-700 dark:text-red-300 whitespace-pre-wrap" x-text="JSON.stringify(selectedJob.error_details, null, 2)"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="flex justify-end mt-6">
|
||||
<button
|
||||
@click="closeJobModal()"
|
||||
class="px-4 py-2 text-sm font-medium leading-5 text-gray-700 transition-colors duration-150 border border-gray-300 rounded-lg dark:text-gray-400 dark:border-gray-700 hover:border-gray-500 focus:outline-none"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user