feat: add capacity planning docs, image upload system, and platform health monitoring

Documentation:
- Add comprehensive capacity planning guide (docs/architecture/capacity-planning.md)
- Add operations docs: platform-health, capacity-monitoring, image-storage
- Link pricing strategy to capacity planning documentation
- Update mkdocs.yml with new Operations section

Image Upload System:
- Add ImageService with WebP conversion and sharded directory structure
- Generate multiple size variants (original, 800px, 200px)
- Add storage stats endpoint for monitoring
- Add Pillow dependency for image processing

Platform Health Monitoring:
- Add /admin/platform-health page with real-time metrics
- Show CPU, memory, disk usage with progress bars
- Display capacity thresholds with status indicators
- Generate scaling recommendations automatically
- Determine infrastructure tier based on usage
- Add psutil dependency for system metrics

Admin UI:
- Add Capacity Monitor to Platform Health section in sidebar
- Create platform-health.html template with stats cards
- Create platform-health.js for Alpine.js state management

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-25 17:17:09 +01:00
parent b25d119899
commit dc7fb5ca19
16 changed files with 2352 additions and 0 deletions

View File

@@ -33,6 +33,7 @@ from . import (
content_pages,
customers,
dashboard,
images,
inventory,
letzshop,
logs,
@@ -42,6 +43,7 @@ from . import (
notifications,
order_item_exceptions,
orders,
platform_health,
products,
settings,
tests,
@@ -162,6 +164,14 @@ router.include_router(messages.router, tags=["admin-messages"])
# Include log management endpoints
router.include_router(logs.router, tags=["admin-logs"])
# Include image management endpoints
router.include_router(images.router, tags=["admin-images"])
# Include platform health endpoints
router.include_router(
platform_health.router, prefix="/platform", tags=["admin-platform-health"]
)
# ============================================================================
# Code Quality & Architecture

121
app/api/v1/admin/images.py Normal file
View File

@@ -0,0 +1,121 @@
# app/api/v1/admin/images.py
"""
Admin image management endpoints.
Provides:
- Image upload with automatic processing
- Image deletion
- Storage statistics
"""
import logging
from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
from app.api.deps import get_current_admin_api
from app.services.image_service import image_service
from models.database.user import User
from models.schema.image import (
ImageDeleteResponse,
ImageStorageStats,
ImageUploadResponse,
)
router = APIRouter(prefix="/images")
logger = logging.getLogger(__name__)
# Maximum upload size (10MB)
MAX_UPLOAD_SIZE = 10 * 1024 * 1024
@router.post("/upload", response_model=ImageUploadResponse)
async def upload_image(
file: UploadFile = File(...),
vendor_id: int = Form(...),
product_id: int | None = Form(None),
current_admin: User = Depends(get_current_admin_api),
):
"""Upload and process an image.
The image will be:
- Converted to WebP format
- Resized to multiple variants (original, 800px, 200px)
- Stored in a sharded directory structure
Args:
file: Image file to upload
vendor_id: Vendor ID for the image
product_id: Optional product ID
Returns:
Image URLs and metadata
"""
# Validate file size
content = await file.read()
if len(content) > MAX_UPLOAD_SIZE:
raise HTTPException(
status_code=413,
detail=f"File too large. Maximum size: {MAX_UPLOAD_SIZE // (1024*1024)}MB",
)
# Validate content type
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(
status_code=400,
detail="Invalid file type. Only images are allowed.",
)
try:
result = image_service.upload_product_image(
file_content=content,
filename=file.filename or "image.jpg",
vendor_id=vendor_id,
product_id=product_id,
)
logger.info(f"Image uploaded: {result['id']} for vendor {vendor_id}")
return ImageUploadResponse(success=True, image=result)
except ValueError as e:
logger.warning(f"Image upload failed: {e}")
return ImageUploadResponse(success=False, error=str(e))
except Exception as e:
logger.error(f"Image upload error: {e}")
raise HTTPException(status_code=500, detail="Failed to process image")
@router.delete("/{image_hash}", response_model=ImageDeleteResponse)
async def delete_image(
image_hash: str,
current_admin: User = Depends(get_current_admin_api),
):
"""Delete an image and all its variants.
Args:
image_hash: The image ID/hash
Returns:
Deletion status
"""
deleted = image_service.delete_product_image(image_hash)
if deleted:
logger.info(f"Image deleted: {image_hash}")
return ImageDeleteResponse(success=True, message="Image deleted successfully")
else:
return ImageDeleteResponse(success=False, message="Image not found")
@router.get("/stats", response_model=ImageStorageStats)
async def get_storage_stats(
current_admin: User = Depends(get_current_admin_api),
):
"""Get image storage statistics.
Returns:
Storage metrics including file counts, sizes, and directory info
"""
stats = image_service.get_storage_stats()
return ImageStorageStats(**stats)

View File

@@ -0,0 +1,532 @@
# app/api/v1/admin/platform_health.py
"""
Platform health and capacity monitoring endpoints.
Provides:
- Overall platform health status
- Capacity metrics and thresholds
- Scaling recommendations
"""
import logging
import os
import platform
import psutil
from datetime import datetime
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy import func, text
from sqlalchemy.orm import Session
from app.api.deps import get_current_admin_api
from app.core.database import get_db
from app.services.image_service import image_service
from models.database.inventory import Inventory
from models.database.order import Order
from models.database.product import Product
from models.database.user import User
from models.database.vendor import Vendor
router = APIRouter()
logger = logging.getLogger(__name__)
# ============================================================================
# Schemas
# ============================================================================
class SystemMetrics(BaseModel):
"""System resource metrics."""
cpu_percent: float
memory_percent: float
memory_used_gb: float
memory_total_gb: float
disk_percent: float
disk_used_gb: float
disk_total_gb: float
class DatabaseMetrics(BaseModel):
"""Database metrics."""
size_mb: float
products_count: int
orders_count: int
vendors_count: int
inventory_count: int
class ImageStorageMetrics(BaseModel):
"""Image storage metrics."""
total_files: int
total_size_mb: float
total_size_gb: float
max_files_per_dir: int
products_estimated: int
class CapacityThreshold(BaseModel):
"""Capacity threshold status."""
name: str
current: float
warning: float
critical: float
limit: float
status: str # ok, warning, critical
percent_used: float
class ScalingRecommendation(BaseModel):
"""Scaling recommendation."""
priority: str # info, warning, critical
title: str
description: str
action: str | None = None
class PlatformHealthResponse(BaseModel):
"""Complete platform health response."""
timestamp: str
overall_status: str # healthy, degraded, critical
system: SystemMetrics
database: DatabaseMetrics
image_storage: ImageStorageMetrics
thresholds: list[CapacityThreshold]
recommendations: list[ScalingRecommendation]
infrastructure_tier: str
next_tier_trigger: str | None = None
class CapacityMetricsResponse(BaseModel):
"""Capacity-focused metrics."""
products_total: int
products_by_vendor: dict[str, int]
images_total: int
storage_used_gb: float
database_size_mb: float
orders_this_month: int
active_vendors: int
# ============================================================================
# Thresholds Configuration
# ============================================================================
CAPACITY_THRESHOLDS = {
"products_total": {
"warning": 400_000,
"critical": 475_000,
"limit": 500_000,
},
"storage_gb": {
"warning": 800,
"critical": 950,
"limit": 1000,
},
"db_size_mb": {
"warning": 20_000,
"critical": 24_000,
"limit": 25_000,
},
"disk_percent": {
"warning": 70,
"critical": 85,
"limit": 100,
},
"memory_percent": {
"warning": 75,
"critical": 90,
"limit": 100,
},
"cpu_percent": {
"warning": 70,
"critical": 85,
"limit": 100,
},
}
INFRASTRUCTURE_TIERS = [
{"name": "Starter", "max_clients": 50, "max_products": 10_000},
{"name": "Small", "max_clients": 100, "max_products": 30_000},
{"name": "Medium", "max_clients": 300, "max_products": 100_000},
{"name": "Large", "max_clients": 500, "max_products": 250_000},
{"name": "Scale", "max_clients": 1000, "max_products": 500_000},
{"name": "Enterprise", "max_clients": None, "max_products": None},
]
# ============================================================================
# Endpoints
# ============================================================================
@router.get("/health", response_model=PlatformHealthResponse)
async def get_platform_health(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Get comprehensive platform health status.
Returns system metrics, database stats, storage info, and recommendations.
"""
# System metrics
system = _get_system_metrics()
# Database metrics
database = _get_database_metrics(db)
# Image storage metrics
image_stats = image_service.get_storage_stats()
image_storage = ImageStorageMetrics(
total_files=image_stats["total_files"],
total_size_mb=image_stats["total_size_mb"],
total_size_gb=image_stats["total_size_gb"],
max_files_per_dir=image_stats["max_files_per_dir"],
products_estimated=image_stats["products_estimated"],
)
# Calculate thresholds
thresholds = _calculate_thresholds(system, database, image_storage)
# Generate recommendations
recommendations = _generate_recommendations(thresholds, database)
# Determine infrastructure tier
tier, next_trigger = _determine_tier(database.vendors_count, database.products_count)
# Overall status
overall_status = _determine_overall_status(thresholds)
return PlatformHealthResponse(
timestamp=datetime.utcnow().isoformat(),
overall_status=overall_status,
system=system,
database=database,
image_storage=image_storage,
thresholds=thresholds,
recommendations=recommendations,
infrastructure_tier=tier,
next_tier_trigger=next_trigger,
)
@router.get("/capacity", response_model=CapacityMetricsResponse)
async def get_capacity_metrics(
db: Session = Depends(get_db),
current_admin: User = Depends(get_current_admin_api),
):
"""Get capacity-focused metrics for planning."""
# Products total
products_total = db.query(func.count(Product.id)).scalar() or 0
# Products by vendor
vendor_counts = (
db.query(Vendor.name, func.count(Product.id))
.join(Product, Vendor.id == Product.vendor_id)
.group_by(Vendor.name)
.all()
)
products_by_vendor = {name or "Unknown": count for name, count in vendor_counts}
# Image storage
image_stats = image_service.get_storage_stats()
# Database size (approximate for SQLite)
db_size = _get_database_size(db)
# Orders this month
start_of_month = datetime.utcnow().replace(day=1, hour=0, minute=0, second=0)
orders_this_month = (
db.query(func.count(Order.id))
.filter(Order.created_at >= start_of_month)
.scalar()
or 0
)
# Active vendors
active_vendors = db.query(func.count(Vendor.id)).filter(Vendor.is_active == True).scalar() or 0 # noqa: E712
return CapacityMetricsResponse(
products_total=products_total,
products_by_vendor=products_by_vendor,
images_total=image_stats["total_files"],
storage_used_gb=image_stats["total_size_gb"],
database_size_mb=db_size,
orders_this_month=orders_this_month,
active_vendors=active_vendors,
)
# ============================================================================
# Helper Functions
# ============================================================================
def _get_system_metrics() -> SystemMetrics:
"""Get current system resource metrics."""
cpu_percent = psutil.cpu_percent(interval=0.1)
memory = psutil.virtual_memory()
disk = psutil.disk_usage("/")
return SystemMetrics(
cpu_percent=cpu_percent,
memory_percent=memory.percent,
memory_used_gb=round(memory.used / (1024**3), 2),
memory_total_gb=round(memory.total / (1024**3), 2),
disk_percent=disk.percent,
disk_used_gb=round(disk.used / (1024**3), 2),
disk_total_gb=round(disk.total / (1024**3), 2),
)
def _get_database_metrics(db: Session) -> DatabaseMetrics:
"""Get database statistics."""
products_count = db.query(func.count(Product.id)).scalar() or 0
orders_count = db.query(func.count(Order.id)).scalar() or 0
vendors_count = db.query(func.count(Vendor.id)).scalar() or 0
inventory_count = db.query(func.count(Inventory.id)).scalar() or 0
db_size = _get_database_size(db)
return DatabaseMetrics(
size_mb=db_size,
products_count=products_count,
orders_count=orders_count,
vendors_count=vendors_count,
inventory_count=inventory_count,
)
def _get_database_size(db: Session) -> float:
"""Get database size in MB."""
try:
# Try SQLite approach
result = db.execute(text("SELECT page_count * page_size as size FROM pragma_page_count(), pragma_page_size()"))
row = result.fetchone()
if row:
return round(row[0] / (1024 * 1024), 2)
except Exception:
pass
try:
# Try PostgreSQL approach
result = db.execute(text("SELECT pg_database_size(current_database())"))
row = result.fetchone()
if row:
return round(row[0] / (1024 * 1024), 2)
except Exception:
pass
return 0.0
def _calculate_thresholds(
system: SystemMetrics,
database: DatabaseMetrics,
image_storage: ImageStorageMetrics,
) -> list[CapacityThreshold]:
"""Calculate threshold status for each metric."""
thresholds = []
# Products threshold
products_config = CAPACITY_THRESHOLDS["products_total"]
thresholds.append(
_create_threshold(
"Products",
database.products_count,
products_config["warning"],
products_config["critical"],
products_config["limit"],
)
)
# Storage threshold
storage_config = CAPACITY_THRESHOLDS["storage_gb"]
thresholds.append(
_create_threshold(
"Image Storage (GB)",
image_storage.total_size_gb,
storage_config["warning"],
storage_config["critical"],
storage_config["limit"],
)
)
# Database size threshold
db_config = CAPACITY_THRESHOLDS["db_size_mb"]
thresholds.append(
_create_threshold(
"Database (MB)",
database.size_mb,
db_config["warning"],
db_config["critical"],
db_config["limit"],
)
)
# Disk threshold
disk_config = CAPACITY_THRESHOLDS["disk_percent"]
thresholds.append(
_create_threshold(
"Disk Usage (%)",
system.disk_percent,
disk_config["warning"],
disk_config["critical"],
disk_config["limit"],
)
)
# Memory threshold
memory_config = CAPACITY_THRESHOLDS["memory_percent"]
thresholds.append(
_create_threshold(
"Memory Usage (%)",
system.memory_percent,
memory_config["warning"],
memory_config["critical"],
memory_config["limit"],
)
)
# CPU threshold
cpu_config = CAPACITY_THRESHOLDS["cpu_percent"]
thresholds.append(
_create_threshold(
"CPU Usage (%)",
system.cpu_percent,
cpu_config["warning"],
cpu_config["critical"],
cpu_config["limit"],
)
)
return thresholds
def _create_threshold(
name: str, current: float, warning: float, critical: float, limit: float
) -> CapacityThreshold:
"""Create a threshold status object."""
percent_used = (current / limit) * 100 if limit > 0 else 0
if current >= critical:
status = "critical"
elif current >= warning:
status = "warning"
else:
status = "ok"
return CapacityThreshold(
name=name,
current=current,
warning=warning,
critical=critical,
limit=limit,
status=status,
percent_used=round(percent_used, 1),
)
def _generate_recommendations(
thresholds: list[CapacityThreshold], database: DatabaseMetrics
) -> list[ScalingRecommendation]:
"""Generate scaling recommendations based on thresholds."""
recommendations = []
for threshold in thresholds:
if threshold.status == "critical":
recommendations.append(
ScalingRecommendation(
priority="critical",
title=f"{threshold.name} at critical level",
description=f"Currently at {threshold.percent_used:.0f}% of capacity ({threshold.current:.0f} of {threshold.limit:.0f})",
action="Immediate scaling or cleanup required",
)
)
elif threshold.status == "warning":
recommendations.append(
ScalingRecommendation(
priority="warning",
title=f"{threshold.name} approaching limit",
description=f"Currently at {threshold.percent_used:.0f}% of capacity ({threshold.current:.0f} of {threshold.limit:.0f})",
action="Plan scaling in the next 2-4 weeks",
)
)
# Add tier-based recommendations
if database.vendors_count > 0:
tier, next_trigger = _determine_tier(database.vendors_count, database.products_count)
if next_trigger:
recommendations.append(
ScalingRecommendation(
priority="info",
title=f"Current tier: {tier}",
description=next_trigger,
action="Review capacity planning documentation",
)
)
# If no issues, add positive status
if not recommendations:
recommendations.append(
ScalingRecommendation(
priority="info",
title="All systems healthy",
description="No capacity concerns at this time",
action=None,
)
)
return recommendations
def _determine_tier(vendors: int, products: int) -> tuple[str, str | None]:
"""Determine current infrastructure tier and next trigger."""
current_tier = "Starter"
next_trigger = None
for i, tier in enumerate(INFRASTRUCTURE_TIERS):
max_clients = tier["max_clients"]
max_products = tier["max_products"]
if max_clients is None:
current_tier = tier["name"]
break
if vendors <= max_clients and products <= max_products:
current_tier = tier["name"]
# Check proximity to next tier
if i < len(INFRASTRUCTURE_TIERS) - 1:
next_tier = INFRASTRUCTURE_TIERS[i + 1]
vendor_percent = (vendors / max_clients) * 100
product_percent = (products / max_products) * 100
if vendor_percent > 70 or product_percent > 70:
next_trigger = (
f"Approaching {next_tier['name']} tier "
f"(vendors: {vendor_percent:.0f}%, products: {product_percent:.0f}%)"
)
break
return current_tier, next_trigger
def _determine_overall_status(thresholds: list[CapacityThreshold]) -> str:
"""Determine overall platform status."""
statuses = [t.status for t in thresholds]
if "critical" in statuses:
return "critical"
elif "warning" in statuses:
return "degraded"
else:
return "healthy"

View File

@@ -1200,3 +1200,27 @@ async def admin_code_quality_violation_detail(
"violation_id": violation_id,
},
)
# ============================================================================
# PLATFORM HEALTH & MONITORING ROUTES
# ============================================================================
@router.get("/platform-health", response_class=HTMLResponse, include_in_schema=False)
async def admin_platform_health(
request: Request,
current_user: User = Depends(get_current_admin_from_cookie_or_header),
db: Session = Depends(get_db),
):
"""
Render platform health monitoring page.
Shows system metrics, capacity thresholds, and scaling recommendations.
"""
return templates.TemplateResponse(
"admin/platform-health.html",
{
"request": request,
"user": current_user,
},
)

View File

@@ -0,0 +1,285 @@
# app/services/image_service.py
"""
Image upload and management service.
Provides:
- Image upload with automatic optimization
- WebP conversion
- Multiple size variant generation
- Sharded directory structure for performance
"""
import hashlib
import logging
import os
import shutil
from datetime import datetime
from io import BytesIO
from pathlib import Path
from PIL import Image
logger = logging.getLogger(__name__)
class ImageService:
"""Service for image upload and management."""
# Supported image formats
ALLOWED_EXTENSIONS = {"jpg", "jpeg", "png", "gif", "webp"}
# Size variants to generate
SIZES = {
"original": None, # No max dimension, just optimize
"800": 800, # Medium size for product cards
"200": 200, # Thumbnail for grids
}
# Quality settings
QUALITY = 85
MAX_DIMENSION = 2000 # Max dimension for original
def __init__(self, upload_dir: str = "static/uploads"):
"""Initialize image service.
Args:
upload_dir: Base directory for uploads (relative to project root)
"""
self.upload_dir = Path(upload_dir)
self.products_dir = self.upload_dir / "products"
# Ensure directories exist
self.products_dir.mkdir(parents=True, exist_ok=True)
def upload_product_image(
self,
file_content: bytes,
filename: str,
vendor_id: int,
product_id: int | None = None,
) -> dict:
"""Upload and process a product image.
Args:
file_content: Raw file bytes
filename: Original filename
vendor_id: Vendor ID for path generation
product_id: Optional product ID
Returns:
Dict with image info and URLs
"""
# Validate file extension
ext = self._get_extension(filename)
if ext not in self.ALLOWED_EXTENSIONS:
raise ValueError(f"Invalid file type: {ext}. Allowed: {self.ALLOWED_EXTENSIONS}")
# Generate unique hash for this image
image_hash = self._generate_hash(vendor_id, product_id, filename)
# Determine sharded directory path
shard_path = self._get_shard_path(image_hash)
full_dir = self.products_dir / shard_path
full_dir.mkdir(parents=True, exist_ok=True)
# Load and process image
try:
img = Image.open(BytesIO(file_content))
# Convert to RGB if necessary (for PNG with alpha)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
# Get original dimensions
original_width, original_height = img.size
# Process and save variants
urls = {}
total_size = 0
for size_name, max_dim in self.SIZES.items():
processed_img = self._resize_image(img.copy(), max_dim)
file_path = full_dir / f"{image_hash}_{size_name}.webp"
# Save as WebP
processed_img.save(file_path, "WEBP", quality=self.QUALITY)
# Track size
file_size = file_path.stat().st_size
total_size += file_size
# Generate URL path (relative to static)
url_path = f"/static/uploads/products/{shard_path}/{image_hash}_{size_name}.webp"
urls[size_name] = url_path
logger.debug(f"Saved {size_name}: {file_path} ({file_size} bytes)")
logger.info(
f"Uploaded image {image_hash} for vendor {vendor_id}: "
f"{len(urls)} variants, {total_size} bytes total"
)
return {
"id": image_hash,
"urls": urls,
"size_bytes": total_size,
"dimensions": {
"width": original_width,
"height": original_height,
},
"path": str(shard_path),
}
except Exception as e:
logger.error(f"Failed to process image: {e}")
raise ValueError(f"Failed to process image: {e}")
def delete_product_image(self, image_hash: str) -> bool:
"""Delete all variants of a product image.
Args:
image_hash: The image hash/ID
Returns:
True if deleted, False if not found
"""
shard_path = self._get_shard_path(image_hash)
full_dir = self.products_dir / shard_path
if not full_dir.exists():
return False
deleted = False
for size_name in self.SIZES:
file_path = full_dir / f"{image_hash}_{size_name}.webp"
if file_path.exists():
file_path.unlink()
deleted = True
logger.debug(f"Deleted: {file_path}")
# Clean up empty directories
self._cleanup_empty_dirs(full_dir)
if deleted:
logger.info(f"Deleted image {image_hash}")
return deleted
def get_storage_stats(self) -> dict:
"""Get storage statistics.
Returns:
Dict with storage metrics
"""
total_files = 0
total_size = 0
max_files_per_dir = 0
dir_count = 0
for root, dirs, files in os.walk(self.products_dir):
webp_files = [f for f in files if f.endswith(".webp")]
file_count = len(webp_files)
total_files += file_count
if file_count > 0:
dir_count += 1
max_files_per_dir = max(max_files_per_dir, file_count)
for f in webp_files:
file_path = Path(root) / f
total_size += file_path.stat().st_size
# Calculate average files per directory
avg_files_per_dir = total_files / dir_count if dir_count > 0 else 0
return {
"total_files": total_files,
"total_size_bytes": total_size,
"total_size_mb": round(total_size / (1024 * 1024), 2),
"total_size_gb": round(total_size / (1024 * 1024 * 1024), 3),
"directory_count": dir_count,
"max_files_per_dir": max_files_per_dir,
"avg_files_per_dir": round(avg_files_per_dir, 1),
"products_estimated": total_files // 3, # 3 variants per image
}
def _generate_hash(
self, vendor_id: int, product_id: int | None, filename: str
) -> str:
"""Generate unique hash for image.
Args:
vendor_id: Vendor ID
product_id: Product ID (optional)
filename: Original filename
Returns:
8-character hex hash
"""
timestamp = datetime.utcnow().isoformat()
content = f"{vendor_id}:{product_id}:{timestamp}:{filename}"
return hashlib.md5(content.encode()).hexdigest()[:8]
def _get_shard_path(self, image_hash: str) -> str:
"""Get sharded directory path from hash.
Uses first 4 characters to create 2-level directory structure.
This creates 256 possible directories at each level.
Args:
image_hash: 8-character hash
Returns:
Path like "0a/1b"
"""
return f"{image_hash[:2]}/{image_hash[2:4]}"
def _get_extension(self, filename: str) -> str:
"""Get lowercase file extension."""
return filename.rsplit(".", 1)[-1].lower() if "." in filename else ""
def _resize_image(self, img: Image.Image, max_dimension: int | None) -> Image.Image:
"""Resize image while maintaining aspect ratio.
Args:
img: PIL Image
max_dimension: Maximum width or height (None = use MAX_DIMENSION)
Returns:
Resized PIL Image
"""
if max_dimension is None:
max_dimension = self.MAX_DIMENSION
width, height = img.size
# Only resize if larger than max
if width <= max_dimension and height <= max_dimension:
return img
# Calculate new dimensions maintaining aspect ratio
if width > height:
new_width = max_dimension
new_height = int(height * (max_dimension / width))
else:
new_height = max_dimension
new_width = int(width * (max_dimension / height))
return img.resize((new_width, new_height), Image.Resampling.LANCZOS)
def _cleanup_empty_dirs(self, dir_path: Path):
"""Remove empty directories up the tree."""
try:
# Try to remove the directory and its parents if empty
while dir_path != self.products_dir:
if dir_path.exists() and not any(dir_path.iterdir()):
dir_path.rmdir()
dir_path = dir_path.parent
else:
break
except OSError:
pass # Directory not empty or other error
# Create service instance
image_service = ImageService()

View File

@@ -111,6 +111,7 @@
<!-- Platform Health Section -->
{{ section_header('Platform Health', 'platformHealth') }}
{% call section_content('platformHealth') %}
{{ menu_item('platform-health', '/admin/platform-health', 'chart-bar', 'Capacity Monitor') }}
{{ menu_item('testing', '/admin/testing', 'beaker', 'Testing Hub') }}
{{ menu_item('code-quality', '/admin/code-quality', 'shield-check', 'Code Quality') }}
{% endcall %}

View File

@@ -0,0 +1,275 @@
{# app/templates/admin/platform-health.html #}
{% extends "admin/base.html" %}
{% from 'shared/macros/alerts.html' import loading_state, error_state %}
{% from 'shared/macros/headers.html' import page_header %}
{% block title %}Platform Health{% endblock %}
{% block alpine_data %}adminPlatformHealth(){% endblock %}
{% block content %}
{% call page_header("Platform Health", subtitle="System metrics, capacity monitoring, and scaling recommendations") %}
<button
@click="refresh()"
:disabled="loading"
class="flex items-center px-4 py-2 text-sm font-medium text-white bg-purple-600 rounded-lg hover:bg-purple-700 transition-colors disabled:opacity-50"
>
<span x-show="loading" x-html="$icon('spinner', 'w-4 h-4 mr-2')"></span>
<span x-show="!loading" x-html="$icon('refresh', 'w-4 h-4 mr-2')"></span>
Refresh
</button>
{% endcall %}
{{ loading_state('Loading platform health...') }}
{{ error_state('Error loading platform health') }}
<!-- Main Content -->
<div x-show="!loading && !error" x-cloak class="space-y-6">
<!-- Overall Status Banner -->
<div
:class="{
'bg-green-50 border-green-200 dark:bg-green-900/20 dark:border-green-800': health?.overall_status === 'healthy',
'bg-yellow-50 border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800': health?.overall_status === 'degraded',
'bg-red-50 border-red-200 dark:bg-red-900/20 dark:border-red-800': health?.overall_status === 'critical'
}"
class="px-4 py-3 rounded-lg border flex items-center justify-between"
>
<div class="flex items-center gap-3">
<span
:class="{
'text-green-600 dark:text-green-400': health?.overall_status === 'healthy',
'text-yellow-600 dark:text-yellow-400': health?.overall_status === 'degraded',
'text-red-600 dark:text-red-400': health?.overall_status === 'critical'
}"
x-html="health?.overall_status === 'healthy' ? $icon('check-circle', 'w-6 h-6') : (health?.overall_status === 'degraded' ? $icon('exclamation', 'w-6 h-6') : $icon('x-circle', 'w-6 h-6'))"
></span>
<div>
<span class="font-semibold capitalize" x-text="health?.overall_status || 'Unknown'"></span>
<span class="text-sm text-gray-600 dark:text-gray-400 ml-2">
Infrastructure Tier: <span class="font-medium" x-text="health?.infrastructure_tier"></span>
</span>
</div>
</div>
<span class="text-xs text-gray-500 dark:text-gray-400" x-text="'Last updated: ' + formatTime(health?.timestamp)"></span>
</div>
<!-- Stats Cards -->
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<!-- Products -->
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="flex items-center">
<div class="p-3 mr-4 text-blue-500 bg-blue-100 rounded-full dark:text-blue-400 dark:bg-blue-900/50">
<span x-html="$icon('cube', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-500 dark:text-gray-400">Products</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(health?.database?.products_count || 0)"></p>
</div>
</div>
</div>
<!-- Image Storage -->
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="flex items-center">
<div class="p-3 mr-4 text-purple-500 bg-purple-100 rounded-full dark:text-purple-400 dark:bg-purple-900/50">
<span x-html="$icon('photograph', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-500 dark:text-gray-400">Image Storage</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatStorage(health?.image_storage?.total_size_gb || 0)"></p>
</div>
</div>
</div>
<!-- Database Size -->
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="flex items-center">
<div class="p-3 mr-4 text-green-500 bg-green-100 rounded-full dark:text-green-400 dark:bg-green-900/50">
<span x-html="$icon('database', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-500 dark:text-gray-400">Database</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(health?.database?.size_mb || 0) + ' MB'"></p>
</div>
</div>
</div>
<!-- Vendors -->
<div class="px-4 py-5 bg-white rounded-lg shadow-md dark:bg-gray-800">
<div class="flex items-center">
<div class="p-3 mr-4 text-orange-500 bg-orange-100 rounded-full dark:text-orange-400 dark:bg-orange-900/50">
<span x-html="$icon('office-building', 'w-5 h-5')"></span>
</div>
<div>
<p class="mb-1 text-sm font-medium text-gray-500 dark:text-gray-400">Vendors</p>
<p class="text-lg font-semibold text-gray-700 dark:text-gray-200" x-text="formatNumber(health?.database?.vendors_count || 0)"></p>
</div>
</div>
</div>
</div>
<!-- Two Column Layout -->
<div class="grid gap-6 lg:grid-cols-2">
<!-- System Resources -->
<div class="px-4 py-5 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">System Resources</h3>
<div class="space-y-4">
<!-- CPU -->
<div>
<div class="flex justify-between mb-1">
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">CPU</span>
<span class="text-sm font-semibold" x-text="(health?.system?.cpu_percent || 0).toFixed(1) + '%'"></span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
:class="{
'bg-green-500': (health?.system?.cpu_percent || 0) < 70,
'bg-yellow-500': (health?.system?.cpu_percent || 0) >= 70 && (health?.system?.cpu_percent || 0) < 85,
'bg-red-500': (health?.system?.cpu_percent || 0) >= 85
}"
class="h-2 rounded-full transition-all"
:style="'width: ' + Math.min(health?.system?.cpu_percent || 0, 100) + '%'"
></div>
</div>
</div>
<!-- Memory -->
<div>
<div class="flex justify-between mb-1">
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">Memory</span>
<span class="text-sm">
<span class="font-semibold" x-text="(health?.system?.memory_percent || 0).toFixed(1) + '%'"></span>
<span class="text-gray-500 dark:text-gray-400" x-text="' (' + (health?.system?.memory_used_gb || 0).toFixed(1) + ' / ' + (health?.system?.memory_total_gb || 0).toFixed(1) + ' GB)'"></span>
</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
:class="{
'bg-green-500': (health?.system?.memory_percent || 0) < 75,
'bg-yellow-500': (health?.system?.memory_percent || 0) >= 75 && (health?.system?.memory_percent || 0) < 90,
'bg-red-500': (health?.system?.memory_percent || 0) >= 90
}"
class="h-2 rounded-full transition-all"
:style="'width: ' + Math.min(health?.system?.memory_percent || 0, 100) + '%'"
></div>
</div>
</div>
<!-- Disk -->
<div>
<div class="flex justify-between mb-1">
<span class="text-sm font-medium text-gray-600 dark:text-gray-400">Disk</span>
<span class="text-sm">
<span class="font-semibold" x-text="(health?.system?.disk_percent || 0).toFixed(1) + '%'"></span>
<span class="text-gray-500 dark:text-gray-400" x-text="' (' + (health?.system?.disk_used_gb || 0).toFixed(1) + ' / ' + (health?.system?.disk_total_gb || 0).toFixed(1) + ' GB)'"></span>
</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
:class="{
'bg-green-500': (health?.system?.disk_percent || 0) < 70,
'bg-yellow-500': (health?.system?.disk_percent || 0) >= 70 && (health?.system?.disk_percent || 0) < 85,
'bg-red-500': (health?.system?.disk_percent || 0) >= 85
}"
class="h-2 rounded-full transition-all"
:style="'width: ' + Math.min(health?.system?.disk_percent || 0, 100) + '%'"
></div>
</div>
</div>
</div>
</div>
<!-- Capacity Thresholds -->
<div class="px-4 py-5 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">Capacity Thresholds</h3>
<div class="space-y-3">
<template x-for="threshold in health?.thresholds || []" :key="threshold.name">
<div class="flex items-center justify-between py-2 border-b border-gray-100 dark:border-gray-700 last:border-0">
<div class="flex items-center gap-2">
<span
:class="{
'bg-green-100 text-green-600 dark:bg-green-900/50 dark:text-green-400': threshold.status === 'ok',
'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/50 dark:text-yellow-400': threshold.status === 'warning',
'bg-red-100 text-red-600 dark:bg-red-900/50 dark:text-red-400': threshold.status === 'critical'
}"
class="w-2 h-2 rounded-full"
></span>
<span class="text-sm text-gray-700 dark:text-gray-300" x-text="threshold.name"></span>
</div>
<div class="text-right">
<span class="text-sm font-medium" x-text="formatNumber(threshold.current)"></span>
<span class="text-xs text-gray-500 dark:text-gray-400" x-text="' / ' + formatNumber(threshold.limit)"></span>
<span
:class="{
'text-green-600 dark:text-green-400': threshold.status === 'ok',
'text-yellow-600 dark:text-yellow-400': threshold.status === 'warning',
'text-red-600 dark:text-red-400': threshold.status === 'critical'
}"
class="text-xs ml-1"
x-text="'(' + threshold.percent_used.toFixed(0) + '%)'"
></span>
</div>
</div>
</template>
</div>
</div>
</div>
<!-- Recommendations -->
<div class="px-4 py-5 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">Scaling Recommendations</h3>
<div class="space-y-3">
<template x-for="rec in health?.recommendations || []" :key="rec.title">
<div
:class="{
'border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-900/20': rec.priority === 'info',
'border-yellow-200 bg-yellow-50 dark:border-yellow-800 dark:bg-yellow-900/20': rec.priority === 'warning',
'border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-900/20': rec.priority === 'critical'
}"
class="p-4 rounded-lg border"
>
<div class="flex items-start gap-3">
<span
:class="{
'text-blue-600 dark:text-blue-400': rec.priority === 'info',
'text-yellow-600 dark:text-yellow-400': rec.priority === 'warning',
'text-red-600 dark:text-red-400': rec.priority === 'critical'
}"
x-html="rec.priority === 'info' ? $icon('information-circle', 'w-5 h-5') : (rec.priority === 'warning' ? $icon('exclamation', 'w-5 h-5') : $icon('x-circle', 'w-5 h-5'))"
></span>
<div class="flex-1">
<p class="font-medium text-gray-800 dark:text-gray-200" x-text="rec.title"></p>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1" x-text="rec.description"></p>
<p x-show="rec.action" class="text-sm font-medium mt-2" x-text="rec.action"></p>
</div>
</div>
</div>
</template>
</div>
</div>
<!-- Quick Links -->
<div class="px-4 py-5 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">Related Resources</h3>
<div class="grid gap-4 md:grid-cols-3">
<a href="/admin/code-quality" class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<span class="text-purple-600 dark:text-purple-400" x-html="$icon('code', 'w-5 h-5')"></span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Code Quality Dashboard</span>
</a>
<a href="/admin/settings" class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<span class="text-gray-600 dark:text-gray-400" x-html="$icon('cog', 'w-5 h-5')"></span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Platform Settings</span>
</a>
<a href="https://docs.wizamart.com/architecture/capacity-planning/" target="_blank" class="flex items-center gap-3 p-3 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<span class="text-blue-600 dark:text-blue-400" x-html="$icon('book-open', 'w-5 h-5')"></span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Capacity Planning Docs</span>
</a>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script src="{{ url_for('static', path='admin/js/platform-health.js') }}"></script>
{% endblock %}