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:
@@ -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
121
app/api/v1/admin/images.py
Normal 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)
|
||||
532
app/api/v1/admin/platform_health.py
Normal file
532
app/api/v1/admin/platform_health.py
Normal 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"
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
285
app/services/image_service.py
Normal file
285
app/services/image_service.py
Normal 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()
|
||||
@@ -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 %}
|
||||
|
||||
275
app/templates/admin/platform-health.html
Normal file
275
app/templates/admin/platform-health.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user