Fix image not load correcttlly
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
dvirlabs 2026-05-08 18:07:46 +03:00
parent f9419c2f4a
commit 9176e32e6f
22 changed files with 509 additions and 30 deletions

386
IMAGE_UPLOAD_FIX.md Normal file
View File

@ -0,0 +1,386 @@
# Image Upload & Loading Fix - Brand Master
## Problem Summary
Images were not loading correctly in the Brand Master application due to:
1. **URL Resolution Issue**: Images stored with relative paths (`/uploads/products/...`) were resolving to the frontend domain instead of the backend
2. **Missing Category Upload Endpoint**: No dedicated endpoint for category image uploads
3. **Ingress Routing**: No route from frontend domain to backend for serving static uploads
4. **No Fallback Handling**: Broken images displayed when URLs were invalid
## Solutions Implemented
### 1. Backend Changes
#### a. Updated Settings Configuration
**File**: `backend/app/config.py`
- Added `backend_url` setting to store the backend URL
- This is set via `BACKEND_URL` environment variable in Kubernetes
```python
backend_url: str = "http://localhost:8000"
```
#### b. Enhanced Upload Utility
**File**: `backend/app/utils.py`
- Modified `save_upload_file()` to accept `backend_url` parameter
- Now returns full URLs (e.g., `https://api-brand-master.dvirlabs.com/uploads/products/...`) instead of relative paths
- Maintains backward compatibility with relative paths if `backend_url` not provided
```python
def save_upload_file(upload_file, folder: str = "products", backend_url: str = None) -> str:
# ... saves file ...
relative_path = f"/uploads/{folder}/{unique_filename}"
if backend_url:
backend_url = backend_url.rstrip('/')
return f"{backend_url}{relative_path}"
return relative_path
```
#### c. Updated Product Upload Endpoints
**File**: `backend/app/routers/products.py`
- Updated `/api/products/upload-image` to use `backend_url` from settings
- Updated `/api/products/upload-images` to use `backend_url` from settings
- Images now return full URLs immediately
#### d. Added Category Upload Endpoint
**File**: `backend/app/routers/categories.py`
- Added new endpoint: `POST /api/categories/upload-image`
- Validates image file types (jpeg, png, jpg, webp, gif)
- Returns full URL with backend domain
- Saves to `uploads/categories/` folder
### 2. Frontend Changes
#### a. Updated Admin Panel Image Upload
**File**: `frontend/src/pages/Admin.jsx`
**Product Images**:
- Removed URL building logic (backend returns full URLs)
- Simplified `handleImageUpload()` function
- Backend now handles URL generation
**Category Images**:
- Changed from `/products/upload-image` to `/categories/upload-image`
- Removed URL building logic
- Directly uses backend-returned full URLs
#### b. Added Fallback Image Handling
**File**: `frontend/src/components/ProductCard.jsx`
- Added error state tracking with `useState`
- Handles image load failures with `onError` handler
- Falls back to placeholder image if product image fails to load
- Fallback: `https://via.placeholder.com/400x400?text={product-name}`
**File**: `frontend/src/components/CategoryCard.jsx`
- Added error state tracking with `useState`
- Handles image load failures with `onError` handler
- Falls back to placeholder image if category image fails to load
- Fallback: `https://via.placeholder.com/300x300?text={category-name}`
### 3. Kubernetes/Helm Changes
#### a. Updated Frontend Ingress
**File**: `charts/brand-master-chart/templates/frontend-ingress.yaml`
- Added `/uploads` path routing to backend service
- This provides backward compatibility for any existing relative URLs
- Routes `/uploads/*` requests from frontend domain to backend service
- Ensures images work even if old relative URLs exist in database
**Path Priority**:
1. `/uploads` → Backend Service (for images)
2. `/` → Frontend Service (for everything else)
#### b. Persistence Configuration
**Already Configured** in `manifests/brand-master/values.yaml`:
- PersistentVolumeClaim enabled for backend
- Storage: 15Gi on NFS storage class
- Mount path: `/app/uploads`
- Ensures images persist across pod restarts
## Deployment Instructions
### 1. Backend Deployment
```bash
cd ~/OneDrive/Desktop/gitea/brand-master
# Build new backend image
docker build -t harbor.dvirlabs.com/my-apps/brand-master-backend:latest -f backend/Dockerfile backend/
# Push to Harbor
docker push harbor.dvirlabs.com/my-apps/brand-master-backend:latest
```
### 2. Frontend Deployment
```bash
cd ~/OneDrive/Desktop/gitea/brand-master
# Build new frontend image
docker build -t harbor.dvirlabs.com/my-apps/brand-master-frontend:latest -f frontend/Dockerfile frontend/
# Push to Harbor
docker push harbor.dvirlabs.com/my-apps/brand-master-frontend:latest
```
### 3. Update Helm Deployment
```bash
cd ~/OneDrive/Desktop/gitea/my-apps
# Update image tags in values.yaml if using specific tags
# Or use :latest for auto-pull
# Upgrade Helm release
helm upgrade brand-master charts/brand-master-chart \
-f manifests/brand-master/values.yaml \
-n my-apps
```
### 4. Verify Deployment
```bash
# Check pods are running
kubectl get pods -n my-apps -l app.kubernetes.io/name=brand-master
# Check backend logs
kubectl logs -n my-apps -l app.kubernetes.io/component=backend --tail=50
# Check frontend logs
kubectl logs -n my-apps -l app.kubernetes.io/component=frontend --tail=50
# Verify PVC is mounted
kubectl describe pod -n my-apps -l app.kubernetes.io/component=backend | grep uploads
```
## Testing Instructions
### 1. Test Category Image Upload
1. Login as admin: https://brand-master.dvirlabs.com/admin
2. Go to "Categories" tab
3. Create or edit a category
4. Upload an image
5. **Expected**: Image preview shows immediately
6. Save category
7. **Expected**: Image displays on home page category cards
8. Refresh page
9. **Expected**: Image still loads correctly
### 2. Test Product Image Upload
1. Login as admin
2. Go to "Products" tab
3. Create or edit a product
4. Upload one or more images
5. **Expected**: Image previews show immediately in upload section
6. Save product
7. **Expected**: Product displays correctly in products list
8. View product detail page
9. **Expected**: All images load and gallery works
10. Refresh page
11. **Expected**: All images still load correctly
### 3. Test Image Persistence
1. Upload some product/category images
2. Restart backend pod:
```bash
kubectl rollout restart deployment -n my-apps brand-master-backend
```
3. Wait for pod to be ready
4. **Expected**: All previously uploaded images still load correctly
### 4. Test Fallback Handling
1. Manually break an image URL in the database (or edit in admin with invalid URL)
2. View product/category with broken image
3. **Expected**: Placeholder image displays instead of broken image icon
4. No console errors about failed image loads
### 5. Verify URLs in Database
```bash
# Connect to database pod
kubectl exec -it -n my-apps brand-master-db-0 -- psql -U brand_master_user -d brand_master_db
# Check product image URLs
SELECT id, name, images FROM product LIMIT 5;
# Check category image URLs
SELECT id, name, image FROM category;
```
**Expected URL Format**:
- New images: `https://api-brand-master.dvirlabs.com/uploads/products/...`
- Old images (if any): `/uploads/products/...` (will still work via ingress routing)
### 6. Test Direct Image Access
1. Upload an image and note the URL returned
2. Copy the full URL
3. Open in new browser tab
4. **Expected**: Image loads directly
5. Test both domains:
- `https://api-brand-master.dvirlabs.com/uploads/...` (backend)
- `https://brand-master.dvirlabs.com/uploads/...` (frontend → routed to backend)
## Architecture Overview
```
┌─────────────────────────────────────────────────────────────┐
│ Ingress (Traefik) │
└─────────────────────────────────────────────────────────────┘
│ │
│ api-brand-master.dvirlabs.com │ brand-master.dvirlabs.com
│ │
▼ ▼
┌─────────────────┐ ┌─────────────────┐
│ Backend Svc │ │ Frontend Svc │
│ (Port 8000) │ │ (Port 80) │
└─────────────────┘ └─────────────────┘
│ │
│ ├── /uploads → Backend Svc
│ └── /* → Frontend Pod
┌─────────────────┐
│ Backend Pod │
│ - FastAPI │
│ - Static Files │
│ at /uploads │
└─────────────────┘
┌─────────────────┐
│ PVC (15Gi) │
│ /app/uploads │
└─────────────────┘
```
## Image Flow
### Upload Flow:
1. Admin uploads image via frontend form
2. Frontend sends file to `/api/products/upload-image` or `/api/categories/upload-image`
3. Backend saves file to `uploads/{folder}/{uuid}{ext}` on PVC
4. Backend returns full URL: `https://api-brand-master.dvirlabs.com/uploads/{folder}/{filename}`
5. Frontend stores full URL in database
### Display Flow (New Images):
1. Frontend renders `<img src="https://api-brand-master.dvirlabs.com/uploads/..."/>`
2. Browser requests image from backend domain
3. Ingress routes to Backend Service
4. FastAPI serves file via StaticFiles mount
5. Image displays
### Display Flow (Old Relative URLs - Backup):
1. Frontend renders `<img src="/uploads/..."/>`
2. Browser requests from frontend domain
3. Ingress routes `/uploads/*` to Backend Service
4. FastAPI serves file
5. Image displays
## Troubleshooting
### Issue: Images still not loading
**Check 1**: Verify backend URL is set correctly
```bash
kubectl get pod -n my-apps -l app.kubernetes.io/component=backend -o jsonpath='{.items[0].spec.containers[0].env[?(@.name=="BACKEND_URL")].value}'
```
Should return: `https://api-brand-master.dvirlabs.com`
**Check 2**: Verify StaticFiles is mounted
```bash
kubectl exec -n my-apps -l app.kubernetes.io/component=backend -- ls -la /app/uploads
```
**Check 3**: Verify ingress routes
```bash
kubectl get ingress -n my-apps brand-master-frontend -o yaml
```
Should show `/uploads` path routing to backend service.
**Check 4**: Test direct backend access
```bash
curl -I https://api-brand-master.dvirlabs.com/uploads/products/some-file.jpg
```
### Issue: Images disappear after pod restart
**Check**: PVC is properly mounted
```bash
kubectl get pvc -n my-apps
kubectl describe pvc -n my-apps brand-master-uploads-pvc
```
Verify:
- STATUS: Bound
- STORAGECLASS: nfs-client
- CAPACITY: 15Gi
### Issue: Upload fails with 500 error
**Check backend logs**:
```bash
kubectl logs -n my-apps -l app.kubernetes.io/component=backend --tail=100
```
Common causes:
- Permissions issue on /app/uploads
- PVC not mounted
- Disk space full
## Summary of Files Changed
### Backend (Code Repo)
- ✅ `backend/app/config.py` - Added backend_url setting
- ✅ `backend/app/utils.py` - Updated save_upload_file to return full URLs
- ✅ `backend/app/routers/products.py` - Updated to use backend_url
- ✅ `backend/app/routers/categories.py` - Added upload endpoint
### Frontend (Code Repo)
- ✅ `frontend/src/pages/Admin.jsx` - Removed URL building, use backend URLs
- ✅ `frontend/src/components/ProductCard.jsx` - Added fallback handling
- ✅ `frontend/src/components/CategoryCard.jsx` - Added fallback handling
### Kubernetes (GitOps Repo)
- ✅ `charts/brand-master-chart/templates/frontend-ingress.yaml` - Added /uploads routing
## Next Steps
1. Deploy backend changes
2. Deploy frontend changes
3. Update Helm deployment with new ingress config
4. Test all image upload scenarios
5. Verify images persist across restarts
6. Monitor logs for any errors
## Rollback Plan
If issues occur after deployment:
```bash
# Rollback Helm release
helm rollback brand-master -n my-apps
# Or rollback individual components
kubectl rollout undo deployment/brand-master-backend -n my-apps
kubectl rollout undo deployment/brand-master-frontend -n my-apps
```
## Environment Variables Reference
Required in backend deployment:
```yaml
env:
BACKEND_URL: "https://api-brand-master.dvirlabs.com" # Must be set!
FRONTEND_URL: "https://brand-master.dvirlabs.com"
PYTHONUNBUFFERED: "1"
# ... other env vars ...
```
Already configured in `manifests/brand-master/values.yaml`.

29
apply-migration.bat Normal file
View File

@ -0,0 +1,29 @@
@echo off
REM Apply database migration 005 - Add password reset fields
echo Getting database pod name...
for /f "delims=" %%i in ('kubectl get pod -n my-apps -l app.kubernetes.io/component=db -o jsonpath^="{.items[0].metadata.name}"') do set DB_POD=%%i
if "%DB_POD%"=="" (
echo ❌ Database pod not found
exit /b 1
)
echo 📦 Database pod: %DB_POD%
echo 📝 Applying migration 005_add_password_reset_fields.sql...
echo.
REM Copy migration file to pod
kubectl cp backend/migrations/005_add_password_reset_fields.sql my-apps/%DB_POD%:/tmp/migration.sql
REM Execute migration
kubectl exec -n my-apps %DB_POD% -- psql -U brand_master_user -d brand_master_db -f /tmp/migration.sql
echo.
echo ✅ Migration applied successfully!
echo.
echo 🔄 Restarting backend pod to pick up changes...
kubectl delete pod -n my-apps -l app.kubernetes.io/component=backend
echo.
echo ✅ Done! Backend will restart with updated schema.

29
apply-migration.sh Normal file
View File

@ -0,0 +1,29 @@
#!/bin/bash
# Apply database migration 005 - Add password reset fields
# Get the database pod name
DB_POD=$(kubectl get pod -n my-apps -l app.kubernetes.io/component=db -o jsonpath='{.items[0].metadata.name}')
if [ -z "$DB_POD" ]; then
echo "❌ Database pod not found"
exit 1
fi
echo "📦 Database pod: $DB_POD"
echo "📝 Applying migration 005_add_password_reset_fields.sql..."
echo ""
# Copy migration file to pod
kubectl cp backend/migrations/005_add_password_reset_fields.sql my-apps/$DB_POD:/tmp/migration.sql
# Execute migration
kubectl exec -n my-apps $DB_POD -- psql -U brand_master_user -d brand_master_db -f /tmp/migration.sql
echo ""
echo "✅ Migration applied successfully!"
echo ""
echo "🔄 Restarting backend pod to pick up changes..."
kubectl delete pod -n my-apps -l app.kubernetes.io/component=backend
echo ""
echo "✅ Done! Backend will restart with updated schema."

View File

@ -28,4 +28,3 @@ ENV PYTHONUNBUFFERED=1
# Run the application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@ -8,6 +8,7 @@ class Settings(BaseSettings):
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 30
frontend_url: str = "http://localhost:5173"
backend_url: str = "http://localhost:8000"
# Admin user credentials (created on first startup)
admin_email: str = "admin@brandmaster.com"

View File

@ -1,10 +1,12 @@
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from sqlalchemy.orm import Session
from typing import List
from app.database.database import get_db
from app.models import Category, User
from app.schemas.category import CategoryCreate, CategoryResponse, CategoryUpdate
from app.services.auth import get_current_admin_user
from app.utils import save_upload_file
from app.config import settings
router = APIRouter(prefix="/api/categories", tags=["categories"])
@ -69,3 +71,25 @@ def delete_category(
db.delete(category)
db.commit()
return {"message": "Category deleted successfully"}
@router.post("/upload-image")
async def upload_category_image(
file: UploadFile = File(...),
admin: User = Depends(get_current_admin_user),
):
"""Upload a category image and return the URL"""
# Validate file type
allowed_types = ["image/jpeg", "image/png", "image/jpg", "image/webp", "image/gif"]
if file.content_type not in allowed_types:
raise HTTPException(
status_code=400,
detail=f"File type {file.content_type} not allowed. Allowed types: {', '.join(allowed_types)}"
)
try:
# Save file and get full URL
file_path = save_upload_file(file, folder="categories", backend_url=settings.backend_url)
return {"url": file_path, "message": "Image uploaded successfully"}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Error uploading file: {str(e)}")

View File

@ -20,6 +20,7 @@ from app.services.product import (
)
from app.services.auth import get_current_admin_user
from app.utils import save_upload_file, generate_slug
from app.config import settings
router = APIRouter(prefix="/api/products", tags=["products"])
@ -152,7 +153,7 @@ async def upload_product_image(
try:
# Save file and get path
file_path = save_upload_file(file, folder="products")
file_path = save_upload_file(file, folder="products", backend_url=settings.backend_url)
# Return full URL
return {"url": file_path, "message": "Image uploaded successfully"}
except Exception as e:
@ -175,7 +176,7 @@ async def upload_multiple_images(
continue
try:
file_path = save_upload_file(file, folder="products")
file_path = save_upload_file(file, folder="products", backend_url=settings.backend_url)
uploaded_urls.append(file_path)
except Exception as e:
errors.append(f"File {file.filename}: {str(e)}")

View File

@ -13,8 +13,8 @@ def generate_slug(text: str) -> str:
slug = re.sub(r'^-+|-+$', '', slug)
return slug
def save_upload_file(upload_file, folder: str = "products") -> str:
"""Save uploaded file and return the file path"""
def save_upload_file(upload_file, folder: str = "products", backend_url: str = None) -> str:
"""Save uploaded file and return the file URL"""
# Create uploads directory if it doesn't exist
upload_dir = Path("uploads") / folder
upload_dir.mkdir(parents=True, exist_ok=True)
@ -28,5 +28,10 @@ def save_upload_file(upload_file, folder: str = "products") -> str:
with open(file_path, "wb") as buffer:
buffer.write(upload_file.file.read())
# Return relative path for URL
return f"/uploads/{folder}/{unique_filename}"
# Return full URL if backend_url provided, otherwise relative path
relative_path = f"/uploads/{folder}/{unique_filename}"
if backend_url:
# Remove trailing slash from backend_url if present
backend_url = backend_url.rstrip('/')
return f"{backend_url}{relative_path}"
return relative_path

View File

@ -67,3 +67,4 @@ EXPOSE 80
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@ -1,12 +1,18 @@
import React from 'react'
import React, { useState } from 'react'
import { Link } from 'react-router-dom'
export default function CategoryCard({ category }) {
const categoryImage = category.image || `https://via.placeholder.com/300x300?text=${category.name}`
const [imageError, setImageError] = useState(false)
const fallbackImage = `https://via.placeholder.com/300x300?text=${encodeURIComponent(category.name)}`
const categoryImage = (category.image && !imageError) ? category.image : fallbackImage
return (
<Link to={`/products?category=${category.slug}`} className="category-card">
<img src={categoryImage} alt={category.name} />
<img
src={categoryImage}
alt={category.name}
onError={() => setImageError(true)}
/>
<h3>{category.name}</h3>
<p>{category.description}</p>
</Link>

View File

@ -1,8 +1,9 @@
import React from 'react'
import React, { useState } from 'react'
import { Link } from 'react-router-dom'
import '../styles/global.css'
export default function ProductCard({ product }) {
const [imageError, setImageError] = useState(false)
const price = product.discount_price || product.price
const discount =
product.discount_price && product.is_on_sale
@ -11,11 +12,20 @@ export default function ProductCard({ product }) {
)
: 0
const fallbackImage = `https://via.placeholder.com/400x400?text=${encodeURIComponent(product.name)}`
const imageUrl = (product.images && product.images.length > 0 && !imageError)
? product.images[0]
: fallbackImage
return (
<Link to={`/product/${product.id}`} className="product-card-link">
<div className="product-card">
<div className="product-image-container">
<img src={product.images[0]} alt={product.name} />
<img
src={imageUrl}
alt={product.name}
onError={() => setImageError(true)}
/>
{product.is_on_sale && discount > 0 && (
<div className="discount-badge">{discount}% OFF</div>
)}

View File

@ -167,10 +167,6 @@ export default function Admin() {
const newImages = [...uploadedImages]
try {
// Get backend URL from environment
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'
const backendUrl = apiUrl.replace('/api', '') // Remove /api suffix to get base URL
for (let i = 0; i < files.length; i++) {
const file = files[i]
const formDataUpload = new FormData()
@ -182,11 +178,8 @@ export default function Admin() {
},
})
// Add the full URL
const imageUrl = response.data.url.startsWith('http')
? response.data.url
: `${backendUrl}${response.data.url}`
newImages.push(imageUrl)
// Backend now returns full URLs
newImages.push(response.data.url)
}
setUploadedImages(newImages)
@ -341,16 +334,11 @@ export default function Admin() {
formData.append('file', file)
try {
const response = await api.post('/products/upload-image', formData, {
const response = await api.post('/categories/upload-image', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
// Prepend backend URL for image display
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000/api'
const backendUrl = apiUrl.replace('/api', '') // Remove /api suffix to get base URL
const imageUrl = response.data.url.startsWith('http')
? response.data.url
: `${backendUrl}${response.data.url}`
setCategoryFormData({ ...categoryFormData, image: imageUrl })
// Backend now returns full URLs
setCategoryFormData({ ...categoryFormData, image: response.data.url })
setToast({ type: 'success', message: 'Image uploaded successfully!' })
} catch (error) {
console.error('Error uploading image:', error)