diff --git a/.build/dev/nginx.conf b/.build/dev/nginx.conf index e68601a..06bc299 100644 --- a/.build/dev/nginx.conf +++ b/.build/dev/nginx.conf @@ -6,7 +6,7 @@ server { error_log /dev/stdout info; location /api/ { - proxy_pass http://backend:8000/api/; + proxy_pass http://python:8000/api/; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -15,15 +15,24 @@ server { } location /ws/ { - proxy_pass http://backend:8000/ws/; + proxy_pass http://python:8000/ws/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # WebSocket timeout settings + proxy_read_timeout 86400s; + proxy_send_timeout 86400s; + proxy_connect_timeout 60s; } location / { # In development, proxy to Vite dev server - proxy_pass http://frontend:3000; + proxy_pass http://node:3000; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; diff --git a/app/models/__init__.py b/app/models/__init__.py index 24b4758..23f6b46 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -1,7 +1,6 @@ from app.models.user import User from app.models.map import Map from app.models.map_item import MapItem -from app.models.share import Share -from app.models.session import Session +from app.models.map_share import MapShare, MapShareLink -__all__ = ["User", "Map", "MapItem", "Share", "Session"] +__all__ = ["User", "Map", "MapItem", "MapShare", "MapShareLink"] diff --git a/app/models/map_share.py b/app/models/map_share.py index c836b71..cd82e5d 100644 --- a/app/models/map_share.py +++ b/app/models/map_share.py @@ -20,7 +20,7 @@ class MapShare(Base): id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) map_id = Column(UUID(as_uuid=True), ForeignKey("maps.id", ondelete="CASCADE"), nullable=False, index=True) user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) - permission = Column(SQLEnum(SharePermission), nullable=False, default=SharePermission.READ) + permission = Column(SQLEnum(SharePermission, values_callable=lambda x: [e.value for e in x]), nullable=False, default=SharePermission.READ) shared_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False) @@ -34,7 +34,7 @@ class MapShareLink(Base): id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) map_id = Column(UUID(as_uuid=True), ForeignKey("maps.id", ondelete="CASCADE"), nullable=False, index=True) token = Column(String(64), unique=True, nullable=False, index=True) # Random token for the share URL - permission = Column(SQLEnum(SharePermission), nullable=False, default=SharePermission.READ) + permission = Column(SQLEnum(SharePermission, values_callable=lambda x: [e.value for e in x]), nullable=False, default=SharePermission.READ) is_active = Column(Boolean, default=True, nullable=False) created_by = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True) expires_at = Column(DateTime(timezone=True), nullable=True) # Optional expiration diff --git a/app/routers/items.py b/app/routers/items.py index d97edce..62424f3 100644 --- a/app/routers/items.py +++ b/app/routers/items.py @@ -10,6 +10,7 @@ from app.models.user import User from app.schemas.map_item import MapItemCreate, MapItemUpdate, MapItemResponse from app.services import item_service from app.services.item_service import geography_to_geojson +from app.websocket.connection_manager import manager router = APIRouter(prefix="/api/maps/{map_id}/items", tags=["map-items"]) @@ -50,7 +51,12 @@ async def create_map_item( ): """Create a new map item.""" item = item_service.create_map_item(db, map_id, item_data, current_user) - return format_item_response(item) + response = format_item_response(item) + + # Broadcast to WebSocket clients + await manager.send_item_created(map_id, response) + + return response @router.get("/{item_id}", response_model=dict) @@ -75,7 +81,12 @@ async def update_map_item( ): """Update a map item.""" item = item_service.update_map_item(db, item_id, item_data, current_user) - return format_item_response(item) + response = format_item_response(item) + + # Broadcast to WebSocket clients + await manager.send_item_updated(map_id, response) + + return response @router.delete("/{item_id}", status_code=status.HTTP_204_NO_CONTENT) @@ -87,4 +98,8 @@ async def delete_map_item( ): """Delete a map item.""" item_service.delete_map_item(db, item_id, current_user) + + # Broadcast to WebSocket clients + await manager.send_item_deleted(map_id, str(item_id)) + return None diff --git a/app/routers/websocket.py b/app/routers/websocket.py index 26065b3..c0ee6a6 100644 --- a/app/routers/websocket.py +++ b/app/routers/websocket.py @@ -1,11 +1,10 @@ """WebSocket routes for real-time updates.""" -from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Depends, Query -from sqlalchemy.orm import Session +from fastapi import APIRouter, WebSocket, WebSocketDisconnect, Query from uuid import UUID from typing import Optional import logging -from app.database import get_db +from app.database import SessionLocal from app.websocket.connection_manager import manager from app.services.map_share_service import check_map_access from app.dependencies import get_user_from_token @@ -20,11 +19,10 @@ async def websocket_endpoint( websocket: WebSocket, map_id: UUID, token: Optional[str] = Query(None), - share_token: Optional[str] = Query(None), - db: Session = Depends(get_db) + share_token: Optional[str] = Query(None) ): """ - WebSocket endpoint for real-time map updates. + WebSocket endpoint for real-time updates. Clients can connect using: - JWT token (authenticated users) @@ -33,23 +31,38 @@ async def websocket_endpoint( Example: ws://localhost:8000/ws/maps/{map_id}?token={jwt_token} Example: ws://localhost:8000/ws/maps/{map_id}?share_token={share_token} """ - # Verify access to the map - user = None - if token: - try: - user = get_user_from_token(token, db) - except Exception as e: - logger.error(f"Invalid token: {e}") - await websocket.close(code=1008, reason="Invalid token") + # Accept the connection first + await websocket.accept() + + # Create a temporary DB session just for authentication + # This session will be closed immediately after checking access + db = SessionLocal() + try: + # Verify access to the map + user = None + if token: + try: + user = get_user_from_token(token, db) + except Exception as e: + logger.error(f"Invalid token: {e}") + await websocket.close(code=1008, reason="Invalid token") + return + + # Check map access + has_access, permission = check_map_access(db, map_id, user, share_token) + if not has_access: + await websocket.close(code=1008, reason="Access denied") return - # Check map access - has_access, permission = check_map_access(db, map_id, user, share_token) - if not has_access: - await websocket.close(code=1008, reason="Access denied") - return + # Store permission for the connection + permission_value = permission.value + finally: + # CRITICAL: Close the DB session immediately after authentication + db.close() - await manager.connect(websocket, map_id) + # Add to connection manager (don't call accept again) + manager.active_connections.setdefault(str(map_id), set()).add(websocket) + logger.info(f"Client connected to map {map_id}. Total connections: {len(manager.active_connections[str(map_id)])}") try: # Send initial connection message @@ -57,7 +70,7 @@ async def websocket_endpoint( "type": "connected", "data": { "map_id": str(map_id), - "permission": permission.value + "permission": permission_value } }) diff --git a/app/services/item_service.py b/app/services/item_service.py index 470f624..c04f675 100644 --- a/app/services/item_service.py +++ b/app/services/item_service.py @@ -6,13 +6,11 @@ from fastapi import HTTPException, status from geoalchemy2.shape import from_shape, to_shape from shapely.geometry import shape, Point, LineString import json -import asyncio from app.models.map_item import MapItem from app.models.user import User from app.schemas.map_item import MapItemCreate, MapItemUpdate from app.services.map_service import get_map_by_id -from app.websocket.connection_manager import manager def get_map_items(db: Session, map_id: UUID, user: Optional[User] = None) -> List[MapItem]: @@ -60,19 +58,6 @@ def geography_to_geojson(geography) -> dict: return json.loads(json.dumps(geom.__geo_interface__)) -def item_to_dict(item: MapItem) -> dict: - """Convert MapItem to JSON-serializable dict for WebSocket broadcast.""" - return { - "id": str(item.id), - "map_id": str(item.map_id), - "type": item.type, - "geometry": geography_to_geojson(item.geometry), - "properties": item.properties, - "created_at": item.created_at.isoformat(), - "updated_at": item.updated_at.isoformat() - } - - def create_map_item(db: Session, map_id: UUID, item_data: MapItemCreate, user: User) -> MapItem: """Create a new map item.""" # Verify user has access to the map @@ -108,14 +93,6 @@ def create_map_item(db: Session, map_id: UUID, item_data: MapItemCreate, user: U print(f"Updating port connections for end device: {end_device_id}") update_device_connections(db, UUID(end_device_id), item.id) - # Broadcast item creation to WebSocket clients - try: - loop = asyncio.get_event_loop() - loop.create_task(manager.send_item_created(map_id, item_to_dict(item))) - except RuntimeError: - # No event loop running, skip WebSocket broadcast - pass - return item @@ -178,14 +155,6 @@ def update_map_item(db: Session, item_id: UUID, item_data: MapItemUpdate, user: db.commit() db.refresh(item) - # Broadcast item update to WebSocket clients - try: - loop = asyncio.get_event_loop() - loop.create_task(manager.send_item_updated(item.map_id, item_to_dict(item))) - except RuntimeError: - # No event loop running, skip WebSocket broadcast - pass - return item @@ -227,14 +196,6 @@ def delete_map_item(db: Session, item_id: UUID, user: User) -> None: db.delete(item) db.commit() - # Broadcast item deletion to WebSocket clients - try: - loop = asyncio.get_event_loop() - loop.create_task(manager.send_item_deleted(map_id, deleted_item_id)) - except RuntimeError: - # No event loop running, skip WebSocket broadcast - pass - def remove_device_connection(db: Session, device_id: UUID, cable_id: UUID) -> None: """Remove cable connection from device's connections array.""" diff --git a/public/src/components/map/ItemContextMenu.tsx b/public/src/components/map/ItemContextMenu.tsx index ab9b027..5ff837c 100644 --- a/public/src/components/map/ItemContextMenu.tsx +++ b/public/src/components/map/ItemContextMenu.tsx @@ -37,9 +37,14 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte }, [onClose]); const handleDelete = async () => { + console.log('DELETE BUTTON CLICKED - Starting delete process'); + console.log('Item to delete:', { id: item.id, type: item.type, name: item.properties.name }); + console.log('Delete connected cables:', deleteConnectedCables); + try { // If device with connections and user wants to delete connected cables if (isDevice && deleteConnectedCables && hasConnections) { + console.log('Deleting connected cables first...'); // First delete all connected cables const { mapItemService: itemService } = await import('../../services/mapItemService'); const allItems = await itemService.getMapItems(item.map_id); @@ -50,18 +55,27 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte i.type === 'cable' && connectedCableIds.includes(i.id) ); + console.log(`Found ${cablesToDelete.length} cables to delete:`, cablesToDelete.map(c => c.id)); + // Delete each cable for (const cable of cablesToDelete) { + console.log(`Deleting cable ${cable.id}...`); await itemService.deleteMapItem(item.map_id, cable.id); + console.log(`Cable ${cable.id} deleted successfully`); } } // Delete the device/item itself + console.log(`Deleting main item ${item.id}...`); await mapItemService.deleteMapItem(item.map_id, item.id); + console.log(`Item ${item.id} deleted successfully`); + onUpdate(); onClose(); + console.log('Delete process completed successfully'); } catch (error) { console.error('Failed to delete item:', error); + console.error('Error details:', { error, item_id: item.id, map_id: item.map_id }); alert('Failed to delete item'); } }; diff --git a/public/src/components/map/ShareDialog.tsx b/public/src/components/map/ShareDialog.tsx index 35a15e7..3a74c8f 100644 --- a/public/src/components/map/ShareDialog.tsx +++ b/public/src/components/map/ShareDialog.tsx @@ -54,8 +54,11 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) { }); setNewUserId(''); await loadShares(); + alert('Map shared successfully!'); } catch (error: any) { - alert(error.response?.data?.detail || 'Failed to share map'); + console.error('Share error:', error); + const message = error.response?.data?.detail || error.message || 'Failed to share map'; + alert(message); } finally { setLoading(false); } @@ -68,8 +71,11 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) { permission: newLinkPermission, }); await loadLinks(); + alert('Share link created successfully!'); } catch (error: any) { - alert(error.response?.data?.detail || 'Failed to create share link'); + console.error('Create link error:', error); + const message = error.response?.data?.detail || error.message || 'Failed to create share link'; + alert(message); } finally { setLoading(false); } @@ -82,7 +88,9 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) { await mapShareService.revokeShare(mapId, shareId); await loadShares(); } catch (error: any) { - alert(error.response?.data?.detail || 'Failed to revoke share'); + console.error('Revoke share error:', error); + const message = error.response?.data?.detail || error.message || 'Failed to revoke share'; + alert(message); } }; @@ -93,7 +101,9 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) { await mapShareService.deleteShareLink(mapId, linkId); await loadLinks(); } catch (error: any) { - alert(error.response?.data?.detail || 'Failed to delete link'); + console.error('Delete link error:', error); + const message = error.response?.data?.detail || error.message || 'Failed to delete link'; + alert(message); } }; diff --git a/public/src/hooks/useMapWebSocket.ts b/public/src/hooks/useMapWebSocket.ts index d022421..e16b7c9 100644 --- a/public/src/hooks/useMapWebSocket.ts +++ b/public/src/hooks/useMapWebSocket.ts @@ -29,7 +29,24 @@ export function useMapWebSocket({ const reconnectTimeoutRef = useRef(null); const reconnectAttemptsRef = useRef(0); + // Use refs for callbacks to avoid reconnecting when they change + const onItemCreatedRef = useRef(onItemCreated); + const onItemUpdatedRef = useRef(onItemUpdated); + const onItemDeletedRef = useRef(onItemDeleted); + const onConnectedRef = useRef(onConnected); + + // Update refs when callbacks change useEffect(() => { + onItemCreatedRef.current = onItemCreated; + onItemUpdatedRef.current = onItemUpdated; + onItemDeletedRef.current = onItemDeleted; + onConnectedRef.current = onConnected; + }, [onItemCreated, onItemUpdated, onItemDeleted, onConnected]); + + useEffect(() => { + // Skip WebSocket if no mapId + if (!mapId) return; + const connect = () => { // Get the token for authenticated users const token = authService.getAccessToken(); @@ -72,19 +89,19 @@ export function useMapWebSocket({ switch (message.type) { case 'connected': setPermission(message.data.permission); - onConnected?.(message.data); + onConnectedRef.current?.(message.data); break; case 'item_created': - onItemCreated?.(message.data); + onItemCreatedRef.current?.(message.data); break; case 'item_updated': - onItemUpdated?.(message.data); + onItemUpdatedRef.current?.(message.data); break; case 'item_deleted': - onItemDeleted?.(message.data.id); + onItemDeletedRef.current?.(message.data.id); break; default: @@ -132,7 +149,7 @@ export function useMapWebSocket({ wsRef.current = null; } }; - }, [mapId, shareToken, onItemCreated, onItemUpdated, onItemDeleted, onConnected]); + }, [mapId, shareToken]); // Only reconnect when mapId or shareToken changes return { isConnected,