Compare commits
12 Commits
42bca3d114
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
a4820aab18
|
|||
|
ebc7f9cb84
|
|||
|
4f0ce8744e
|
|||
|
e36e5e8fc5
|
|||
|
57adb221f9
|
|||
|
7e4bd35f3a
|
|||
|
d85849debe
|
|||
|
7eaf1b3b86
|
|||
|
61ba40c7d2
|
|||
|
52889def40
|
|||
|
52b976c452
|
|||
|
0e436aac26
|
@@ -1,8 +1,75 @@
|
|||||||
|
# ISP Wiremap Application
|
||||||
|
|
||||||
|
A web-based mapping application for managing ISP network infrastructure with real-time collaboration features.
|
||||||
|
|
||||||
## run migrations
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Backend:** FastAPI with Python 3, PostgreSQL 15 with PostGIS
|
||||||
|
- **Frontend:** React 19 with TypeScript, Vite, Leaflet for mapping
|
||||||
|
- **Authentication:** JWT tokens with bcrypt password hashing
|
||||||
|
- **Real-time:** WebSockets for collaborative editing
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
### Run Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
docker compose exec python alembic upgrade head
|
docker compose exec python alembic upgrade head
|
||||||
|
```
|
||||||
|
|
||||||
|
### Start the Application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The application will be available at `http://localhost:8000`
|
||||||
|
|
||||||
|
## User Management
|
||||||
|
|
||||||
|
### Create Admin User
|
||||||
|
|
||||||
|
Interactively create a new admin user with username, email, and password:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec python python scripts/create_admin.py
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Users
|
||||||
|
|
||||||
|
View all users in the system with their details (username, email, admin status, creation date):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec python python scripts/manage_users.py list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Reset User Password
|
||||||
|
|
||||||
|
Reset a user's password by providing their username, email, or user ID:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec python python scripts/manage_users.py reset-password <username|email|user_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete User
|
||||||
|
|
||||||
|
Delete a user from the system (requires confirmation, affects all their maps and shares):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec python python scripts/manage_users.py delete <username|email|user_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Toggle Admin Status
|
||||||
|
|
||||||
|
Grant or revoke admin privileges for a user:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec python python scripts/manage_users.py toggle-admin <username|email|user_id>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
Example Docker Compose configuration for production:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
services:
|
services:
|
||||||
@@ -38,5 +105,17 @@ services:
|
|||||||
# ports:
|
# ports:
|
||||||
# - "5432:5432"
|
# - "5432:5432"
|
||||||
```
|
```
|
||||||
## prod docker compose.yml
|
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and configure the required variables:
|
||||||
|
|
||||||
|
- `SECRET_KEY` - JWT secret key for token signing
|
||||||
|
- `DATABASE_URL` - PostgreSQL connection string
|
||||||
|
- `ALLOW_REGISTRATION` - Enable/disable public user registration (default: false)
|
||||||
|
- `ACCESS_TOKEN_EXPIRE_MINUTES` - JWT access token lifetime (default: 30)
|
||||||
|
- `REFRESH_TOKEN_EXPIRE_DAYS` - JWT refresh token lifetime (default: 7)
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
GPL v2
|
||||||
|
|||||||
@@ -16,15 +16,18 @@ class MapItem(Base):
|
|||||||
- switch: Network switch
|
- switch: Network switch
|
||||||
- indoor_ap: Indoor access point
|
- indoor_ap: Indoor access point
|
||||||
- outdoor_ap: Outdoor access point
|
- outdoor_ap: Outdoor access point
|
||||||
|
- other_device: Other device (1 ethernet port)
|
||||||
|
- info: Information marker
|
||||||
|
|
||||||
Geometry:
|
Geometry:
|
||||||
- Point for devices (switches, APs)
|
- Point for devices (switches, APs, other devices, info markers)
|
||||||
- LineString for cables and wireless mesh
|
- LineString for cables and wireless mesh
|
||||||
|
|
||||||
Properties (JSONB):
|
Properties (JSONB):
|
||||||
- For cables: cable_type, name, notes, length_meters, start_device_id, end_device_id
|
- For cables: cable_type, name, notes, length_meters, start_device_id, end_device_id
|
||||||
- For devices: name, notes, port_count, connections (array of {cable_id, port_number})
|
- For devices: name, notes, port_count, connections (array of {cable_id, port_number})
|
||||||
- For wireless_mesh: name, notes, start_ap_id, end_ap_id
|
- For wireless_mesh: name, notes, start_ap_id, end_ap_id
|
||||||
|
- For info markers: name, notes, image (optional)
|
||||||
"""
|
"""
|
||||||
|
|
||||||
__tablename__ = "map_items"
|
__tablename__ = "map_items"
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ from app.models.user import User
|
|||||||
router = APIRouter(prefix="/api/uploads", tags=["uploads"])
|
router = APIRouter(prefix="/api/uploads", tags=["uploads"])
|
||||||
|
|
||||||
# Storage directory for uploaded images
|
# Storage directory for uploaded images
|
||||||
STORAGE_DIR = Path("/home/shihaam/git/sarlink/mapmaker/storage/images")
|
# Use relative path from project root to work in both dev and production
|
||||||
|
STORAGE_DIR = Path(__file__).resolve().parent.parent.parent / "storage" / "images"
|
||||||
STORAGE_DIR.mkdir(parents=True, exist_ok=True)
|
STORAGE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
# Allowed image types
|
# Allowed image types
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from uuid import UUID
|
|||||||
|
|
||||||
class MapItemBase(BaseModel):
|
class MapItemBase(BaseModel):
|
||||||
"""Base map item schema with common attributes."""
|
"""Base map item schema with common attributes."""
|
||||||
type: str = Field(..., description="Item type: cable, wireless_mesh, switch, indoor_ap, outdoor_ap")
|
type: str = Field(..., description="Item type: cable, wireless_mesh, switch, indoor_ap, outdoor_ap, other_device, info")
|
||||||
geometry: Dict[str, Any] = Field(..., description="GeoJSON geometry (Point or LineString)")
|
geometry: Dict[str, Any] = Field(..., description="GeoJSON geometry (Point or LineString)")
|
||||||
properties: Dict[str, Any] = Field(default_factory=dict, description="Item-specific properties")
|
properties: Dict[str, Any] = Field(default_factory=dict, description="Item-specific properties")
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useMapEvents, Polyline, Marker } from 'react-leaflet';
|
import { useMapEvents, Polyline, Marker, Circle } from 'react-leaflet';
|
||||||
import { useDrawingStore } from '../../stores/drawingStore';
|
import { useDrawingStore } from '../../stores/drawingStore';
|
||||||
import { useUIStore } from '../../stores/uiStore';
|
import { useUIStore } from '../../stores/uiStore';
|
||||||
import { mapItemService } from '../../services/mapItemService';
|
import { mapItemService } from '../../services/mapItemService';
|
||||||
@@ -8,9 +8,10 @@ import { CABLE_COLORS, type CableType } from '../../types/mapItem';
|
|||||||
interface DrawingHandlerProps {
|
interface DrawingHandlerProps {
|
||||||
mapId: string;
|
mapId: string;
|
||||||
onItemCreated: () => void;
|
onItemCreated: () => void;
|
||||||
|
refreshTrigger?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
export function DrawingHandler({ mapId, onItemCreated, refreshTrigger }: DrawingHandlerProps) {
|
||||||
const { activeTool, isDrawing, drawingPoints, setIsDrawing, addDrawingPoint, resetDrawing } =
|
const { activeTool, isDrawing, drawingPoints, setIsDrawing, addDrawingPoint, resetDrawing } =
|
||||||
useDrawingStore();
|
useDrawingStore();
|
||||||
const { showToast } = useUIStore();
|
const { showToast } = useUIStore();
|
||||||
@@ -18,9 +19,11 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
|||||||
const [allItems, setAllItems] = useState<any[]>([]);
|
const [allItems, setAllItems] = useState<any[]>([]);
|
||||||
const [startDeviceId, setStartDeviceId] = useState<string | null>(null);
|
const [startDeviceId, setStartDeviceId] = useState<string | null>(null);
|
||||||
const [endDeviceId, setEndDeviceId] = useState<string | null>(null);
|
const [endDeviceId, setEndDeviceId] = useState<string | null>(null);
|
||||||
|
const [nearbyDevice, setNearbyDevice] = useState<any | null>(null);
|
||||||
|
const [nearbyFullDevice, setNearbyFullDevice] = useState<any | null>(null);
|
||||||
|
|
||||||
const isCableTool = ['fiber', 'cat6', 'cat6_poe'].includes(activeTool);
|
const isCableTool = ['fiber', 'cat6', 'cat6_poe'].includes(activeTool);
|
||||||
const isDeviceTool = ['switch', 'indoor_ap', 'outdoor_ap', 'info'].includes(activeTool);
|
const isDeviceTool = ['switch', 'indoor_ap', 'outdoor_ap', 'other_device', 'info'].includes(activeTool);
|
||||||
const isWirelessTool = activeTool === 'wireless_mesh';
|
const isWirelessTool = activeTool === 'wireless_mesh';
|
||||||
|
|
||||||
// Load all map items for snapping
|
// Load all map items for snapping
|
||||||
@@ -34,12 +37,12 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadItems();
|
loadItems();
|
||||||
}, [mapId]);
|
}, [mapId, refreshTrigger]);
|
||||||
|
|
||||||
// Find nearby device for snapping (exclude info markers)
|
// Find nearby device for snapping (exclude info markers)
|
||||||
const findNearbyDevice = (lat: number, lng: number, radiusMeters = 5): any | null => {
|
const findNearbyDevice = (lat: number, lng: number, radiusMeters = 2.5): any | null => {
|
||||||
const devices = allItems.filter(item =>
|
const devices = allItems.filter(item =>
|
||||||
['switch', 'indoor_ap', 'outdoor_ap'].includes(item.type) &&
|
['switch', 'indoor_ap', 'outdoor_ap', 'other_device'].includes(item.type) &&
|
||||||
item.geometry.type === 'Point'
|
item.geometry.type === 'Point'
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -78,6 +81,10 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
|||||||
let { lat, lng } = e.latlng;
|
let { lat, lng } = e.latlng;
|
||||||
let clickedDevice = null;
|
let clickedDevice = null;
|
||||||
|
|
||||||
|
// Clear snap indicator on click
|
||||||
|
setNearbyDevice(null);
|
||||||
|
setNearbyFullDevice(null);
|
||||||
|
|
||||||
// Check for nearby device when drawing cables
|
// Check for nearby device when drawing cables
|
||||||
if (isCableTool && map) {
|
if (isCableTool && map) {
|
||||||
clickedDevice = findNearbyDevice(lat, lng);
|
clickedDevice = findNearbyDevice(lat, lng);
|
||||||
@@ -116,7 +123,7 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
|||||||
|
|
||||||
// Only add port_count and connections if it's not an info marker
|
// Only add port_count and connections if it's not an info marker
|
||||||
if (activeTool !== 'info') {
|
if (activeTool !== 'info') {
|
||||||
properties.port_count = activeTool === 'switch' ? 5 : activeTool === 'outdoor_ap' ? 1 : 4;
|
properties.port_count = activeTool === 'switch' ? 5 : (activeTool === 'outdoor_ap' || activeTool === 'other_device') ? 1 : 4;
|
||||||
properties.connections = [];
|
properties.connections = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +158,7 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
|||||||
// Wireless mesh - connect AP to AP only
|
// Wireless mesh - connect AP to AP only
|
||||||
if (isWirelessTool) {
|
if (isWirelessTool) {
|
||||||
// Must click on an AP
|
// Must click on an AP
|
||||||
const ap = findNearbyDevice(lat, lng, 5);
|
const ap = findNearbyDevice(lat, lng, 2.5);
|
||||||
if (!ap || !['indoor_ap', 'outdoor_ap'].includes(ap.type)) {
|
if (!ap || !['indoor_ap', 'outdoor_ap'].includes(ap.type)) {
|
||||||
showToast('Wireless mesh can only connect between Access Points. Please click on an AP.', 'error');
|
showToast('Wireless mesh can only connect between Access Points. Please click on an AP.', 'error');
|
||||||
return;
|
return;
|
||||||
@@ -191,6 +198,40 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
|||||||
if (isDrawing && (isCableTool || isWirelessTool)) {
|
if (isDrawing && (isCableTool || isWirelessTool)) {
|
||||||
setCursorPosition([e.latlng.lat, e.latlng.lng]);
|
setCursorPosition([e.latlng.lat, e.latlng.lng]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for nearby device to show snap indicator
|
||||||
|
if ((isCableTool || isWirelessTool) && map) {
|
||||||
|
const nearby = findNearbyDevice(e.latlng.lat, e.latlng.lng);
|
||||||
|
|
||||||
|
// For wireless mesh, only show indicator for APs
|
||||||
|
if (isWirelessTool) {
|
||||||
|
if (nearby && ['indoor_ap', 'outdoor_ap'].includes(nearby.type)) {
|
||||||
|
setNearbyDevice(nearby);
|
||||||
|
setNearbyFullDevice(null);
|
||||||
|
} else {
|
||||||
|
setNearbyDevice(null);
|
||||||
|
setNearbyFullDevice(null);
|
||||||
|
}
|
||||||
|
} else if (isCableTool) {
|
||||||
|
// For cables, check port availability
|
||||||
|
if (nearby) {
|
||||||
|
if (hasAvailablePorts(nearby)) {
|
||||||
|
setNearbyDevice(nearby);
|
||||||
|
setNearbyFullDevice(null);
|
||||||
|
} else {
|
||||||
|
// Device has full ports - show red indicator
|
||||||
|
setNearbyDevice(null);
|
||||||
|
setNearbyFullDevice(nearby);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setNearbyDevice(null);
|
||||||
|
setNearbyFullDevice(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setNearbyDevice(null);
|
||||||
|
setNearbyFullDevice(null);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
contextmenu: async (e) => {
|
contextmenu: async (e) => {
|
||||||
@@ -214,6 +255,8 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
|||||||
if (e.key === 'Escape' && isDrawing) {
|
if (e.key === 'Escape' && isDrawing) {
|
||||||
resetDrawing();
|
resetDrawing();
|
||||||
setCursorPosition(null);
|
setCursorPosition(null);
|
||||||
|
setNearbyDevice(null);
|
||||||
|
setNearbyFullDevice(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -221,6 +264,12 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
|||||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||||
}, [isDrawing, resetDrawing]);
|
}, [isDrawing, resetDrawing]);
|
||||||
|
|
||||||
|
// Clear nearby device indicator when tool changes
|
||||||
|
useEffect(() => {
|
||||||
|
setNearbyDevice(null);
|
||||||
|
setNearbyFullDevice(null);
|
||||||
|
}, [activeTool]);
|
||||||
|
|
||||||
const finishCable = async () => {
|
const finishCable = async () => {
|
||||||
if (drawingPoints.length < 2) return;
|
if (drawingPoints.length < 2) return;
|
||||||
|
|
||||||
@@ -244,6 +293,8 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
|||||||
setCursorPosition(null);
|
setCursorPosition(null);
|
||||||
setStartDeviceId(null);
|
setStartDeviceId(null);
|
||||||
setEndDeviceId(null);
|
setEndDeviceId(null);
|
||||||
|
setNearbyDevice(null);
|
||||||
|
setNearbyFullDevice(null);
|
||||||
// Reload items
|
// Reload items
|
||||||
const items = await mapItemService.getMapItems(mapId);
|
const items = await mapItemService.getMapItems(mapId);
|
||||||
setAllItems(items);
|
setAllItems(items);
|
||||||
@@ -283,6 +334,8 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
|||||||
setCursorPosition(null);
|
setCursorPosition(null);
|
||||||
setStartDeviceId(null);
|
setStartDeviceId(null);
|
||||||
setEndDeviceId(null);
|
setEndDeviceId(null);
|
||||||
|
setNearbyDevice(null);
|
||||||
|
setNearbyFullDevice(null);
|
||||||
// Reload items
|
// Reload items
|
||||||
const items = await mapItemService.getMapItems(mapId);
|
const items = await mapItemService.getMapItems(mapId);
|
||||||
setAllItems(items);
|
setAllItems(items);
|
||||||
@@ -293,11 +346,12 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
|||||||
setCursorPosition(null);
|
setCursorPosition(null);
|
||||||
setStartDeviceId(null);
|
setStartDeviceId(null);
|
||||||
setEndDeviceId(null);
|
setEndDeviceId(null);
|
||||||
|
setNearbyDevice(null);
|
||||||
|
setNearbyFullDevice(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Render drawing preview
|
// Render drawing preview and snap indicator
|
||||||
if (isDrawing && drawingPoints.length > 0) {
|
|
||||||
const color = isCableTool
|
const color = isCableTool
|
||||||
? CABLE_COLORS[activeTool as CableType]
|
? CABLE_COLORS[activeTool as CableType]
|
||||||
: isWirelessTool
|
: isWirelessTool
|
||||||
@@ -307,6 +361,37 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
|||||||
const dashArray = isWirelessTool ? '10, 10' : undefined;
|
const dashArray = isWirelessTool ? '10, 10' : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{/* Green snap indicator - show when hovering near a device with available ports */}
|
||||||
|
{nearbyDevice && nearbyDevice.geometry.type === 'Point' && (
|
||||||
|
<Circle
|
||||||
|
center={[nearbyDevice.geometry.coordinates[1], nearbyDevice.geometry.coordinates[0]]}
|
||||||
|
radius={3}
|
||||||
|
pathOptions={{
|
||||||
|
color: '#10B981',
|
||||||
|
fillColor: '#10B981',
|
||||||
|
fillOpacity: 0.2,
|
||||||
|
weight: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Red snap indicator - show when hovering near a device with full ports */}
|
||||||
|
{nearbyFullDevice && nearbyFullDevice.geometry.type === 'Point' && (
|
||||||
|
<Circle
|
||||||
|
center={[nearbyFullDevice.geometry.coordinates[1], nearbyFullDevice.geometry.coordinates[0]]}
|
||||||
|
radius={3}
|
||||||
|
pathOptions={{
|
||||||
|
color: '#EF4444',
|
||||||
|
fillColor: '#EF4444',
|
||||||
|
fillOpacity: 0.2,
|
||||||
|
weight: 2,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Drawing preview - only show when actively drawing */}
|
||||||
|
{isDrawing && drawingPoints.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{/* Main line connecting all points */}
|
{/* Main line connecting all points */}
|
||||||
{drawingPoints.length > 1 && (
|
{drawingPoints.length > 1 && (
|
||||||
@@ -335,8 +420,7 @@ export function DrawingHandler({ mapId, onItemCreated }: DrawingHandlerProps) {
|
|||||||
<Marker key={idx} position={point} />
|
<Marker key={idx} position={point} />
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export function ItemContextMenu({ item, position, onClose, onUpdate }: ItemConte
|
|||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const isSwitch = item.type === 'switch';
|
const isSwitch = item.type === 'switch';
|
||||||
const isDevice = ['switch', 'indoor_ap', 'outdoor_ap'].includes(item.type);
|
const isDevice = ['switch', 'indoor_ap', 'outdoor_ap', 'other_device'].includes(item.type);
|
||||||
const hasConnections = item.properties.connections && item.properties.connections.length > 0;
|
const hasConnections = item.properties.connections && item.properties.connections.length > 0;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -88,6 +88,22 @@ const outdoorApIcon = new L.DivIcon({
|
|||||||
iconAnchor: [20, 40],
|
iconAnchor: [20, 40],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const otherDeviceIcon = new L.DivIcon({
|
||||||
|
html: `<div class="device-icon other-device-icon">
|
||||||
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<rect x="4" y="6" width="16" height="12" rx="2" fill="none" stroke="currentColor" stroke-width="2"/>
|
||||||
|
<circle cx="12" cy="12" r="2"/>
|
||||||
|
<line x1="7" y1="9" x2="7" y2="9" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<line x1="17" y1="9" x2="17" y2="9" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<line x1="7" y1="15" x2="7" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
<line x1="17" y1="15" x2="17" y2="15" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||||
|
</svg>
|
||||||
|
</div>`,
|
||||||
|
className: 'custom-device-marker',
|
||||||
|
iconSize: [40, 40],
|
||||||
|
iconAnchor: [20, 40],
|
||||||
|
});
|
||||||
|
|
||||||
const infoIcon = new L.DivIcon({
|
const infoIcon = new L.DivIcon({
|
||||||
html: `<div class="info-marker-icon">
|
html: `<div class="info-marker-icon">
|
||||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
@@ -127,7 +143,7 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
|
|||||||
console.log('Loaded items:', data);
|
console.log('Loaded items:', data);
|
||||||
// Log devices with their connections
|
// Log devices with their connections
|
||||||
data.forEach(item => {
|
data.forEach(item => {
|
||||||
if (['switch', 'indoor_ap', 'outdoor_ap'].includes(item.type)) {
|
if (['switch', 'indoor_ap', 'outdoor_ap', 'other_device'].includes(item.type)) {
|
||||||
console.log(`Device ${item.type} (${item.id}): ${item.properties.connections?.length || 0} / ${item.properties.port_count} ports`);
|
console.log(`Device ${item.type} (${item.id}): ${item.properties.connections?.length || 0} / ${item.properties.port_count} ports`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -153,6 +169,8 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
|
|||||||
return indoorApIcon;
|
return indoorApIcon;
|
||||||
case 'outdoor_ap':
|
case 'outdoor_ap':
|
||||||
return outdoorApIcon;
|
return outdoorApIcon;
|
||||||
|
case 'other_device':
|
||||||
|
return otherDeviceIcon;
|
||||||
case 'info':
|
case 'info':
|
||||||
return infoIcon;
|
return infoIcon;
|
||||||
default:
|
default:
|
||||||
@@ -379,7 +397,7 @@ export function MapItemsLayer({ mapId, refreshTrigger, readOnly = false }: MapIt
|
|||||||
|
|
||||||
// Render devices and info markers
|
// Render devices and info markers
|
||||||
if (
|
if (
|
||||||
['switch', 'indoor_ap', 'outdoor_ap', 'info'].includes(item.type) &&
|
['switch', 'indoor_ap', 'outdoor_ap', 'other_device', 'info'].includes(item.type) &&
|
||||||
item.geometry.type === 'Point'
|
item.geometry.type === 'Point'
|
||||||
) {
|
) {
|
||||||
const [lng, lat] = item.geometry.coordinates;
|
const [lng, lat] = item.geometry.coordinates;
|
||||||
|
|||||||
@@ -1,10 +1,21 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { MapContainer, TileLayer, useMap } from 'react-leaflet';
|
import { MapContainer, TileLayer, useMap } from 'react-leaflet';
|
||||||
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { DrawingHandler } from './DrawingHandler';
|
import { DrawingHandler } from './DrawingHandler';
|
||||||
import { MapItemsLayer } from './MapItemsLayer';
|
import { MapItemsLayer } from './MapItemsLayer';
|
||||||
import { ShareDialog } from './ShareDialog';
|
import { ShareDialog } from './ShareDialog';
|
||||||
import { useMapWebSocket } from '../../hooks/useMapWebSocket';
|
import { useMapWebSocket } from '../../hooks/useMapWebSocket';
|
||||||
|
import { mapItemService } from '../../services/mapItemService';
|
||||||
|
|
||||||
|
// Fix Leaflet's default icon paths for production builds
|
||||||
|
// Since we use custom DivIcons, we just need to prevent 404s
|
||||||
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconUrl: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==',
|
||||||
|
iconRetinaUrl: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==',
|
||||||
|
shadowUrl: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==',
|
||||||
|
});
|
||||||
|
|
||||||
interface MapViewProps {
|
interface MapViewProps {
|
||||||
mapId: string | null;
|
mapId: string | null;
|
||||||
@@ -28,6 +39,50 @@ function MapController() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AutoZoom({ mapId }: { mapId: string }) {
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const zoomToDevices = async () => {
|
||||||
|
try {
|
||||||
|
const items = await mapItemService.getMapItems(mapId);
|
||||||
|
|
||||||
|
// Filter only devices (exclude cables, wireless mesh, and info markers)
|
||||||
|
const devices = items.filter(item =>
|
||||||
|
['switch', 'indoor_ap', 'outdoor_ap', 'other_device'].includes(item.type) &&
|
||||||
|
item.geometry.type === 'Point'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (devices.length === 0) {
|
||||||
|
// No devices, keep default view
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create bounds from all device coordinates
|
||||||
|
const bounds = L.latLngBounds(
|
||||||
|
devices.map(device => {
|
||||||
|
const coords = (device.geometry as GeoJSON.Point).coordinates;
|
||||||
|
const [lng, lat] = coords;
|
||||||
|
return [lat, lng] as [number, number];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fit map to bounds with padding
|
||||||
|
map.fitBounds(bounds, {
|
||||||
|
padding: [50, 50],
|
||||||
|
maxZoom: 18,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to auto-zoom to devices:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
zoomToDevices();
|
||||||
|
}, [mapId, map]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function MapView({ mapId, activeLayer, mapLayers, showShareDialog = false, shareMapId, onCloseShareDialog }: MapViewProps) {
|
export function MapView({ mapId, activeLayer, mapLayers, showShareDialog = false, shareMapId, onCloseShareDialog }: MapViewProps) {
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
|
|
||||||
@@ -79,6 +134,7 @@ export function MapView({ mapId, activeLayer, mapLayers, showShareDialog = false
|
|||||||
style={{ background: '#f0f0f0' }}
|
style={{ background: '#f0f0f0' }}
|
||||||
>
|
>
|
||||||
<MapController />
|
<MapController />
|
||||||
|
<AutoZoom mapId={mapId} />
|
||||||
<TileLayer
|
<TileLayer
|
||||||
key={activeLayer}
|
key={activeLayer}
|
||||||
url={layer.url}
|
url={layer.url}
|
||||||
@@ -89,7 +145,7 @@ export function MapView({ mapId, activeLayer, mapLayers, showShareDialog = false
|
|||||||
|
|
||||||
{/* Drawing handler for creating new items - disabled for read-only */}
|
{/* Drawing handler for creating new items - disabled for read-only */}
|
||||||
{permission !== 'read' && (
|
{permission !== 'read' && (
|
||||||
<DrawingHandler mapId={mapId} onItemCreated={handleItemCreated} />
|
<DrawingHandler mapId={mapId} onItemCreated={handleItemCreated} refreshTrigger={refreshTrigger} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Render existing map items */}
|
{/* Render existing map items */}
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await mapShareService.shareWithUser(mapId, {
|
await mapShareService.shareWithUser(mapId, {
|
||||||
user_id: newUserId.trim(),
|
user_identifier: newUserId.trim(),
|
||||||
permission: newUserPermission,
|
permission: newUserPermission,
|
||||||
});
|
});
|
||||||
setNewUserId('');
|
setNewUserId('');
|
||||||
@@ -59,7 +59,20 @@ export function ShareDialog({ mapId, onClose }: ShareDialogProps) {
|
|||||||
showToast('Map shared successfully!', 'success');
|
showToast('Map shared successfully!', 'success');
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('Share error:', error);
|
console.error('Share error:', error);
|
||||||
const message = error.response?.data?.detail || error.message || 'Failed to share map';
|
let message = 'Failed to share map';
|
||||||
|
|
||||||
|
if (error.response?.data?.detail) {
|
||||||
|
const detail = error.response.data.detail;
|
||||||
|
// Handle FastAPI validation errors (array of objects)
|
||||||
|
if (Array.isArray(detail)) {
|
||||||
|
message = detail.map((err: any) => err.msg).join(', ');
|
||||||
|
} else if (typeof detail === 'string') {
|
||||||
|
message = detail;
|
||||||
|
}
|
||||||
|
} else if (error.message) {
|
||||||
|
message = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
showToast(message, 'error');
|
showToast(message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
|
|||||||
@@ -71,6 +71,12 @@ const DEVICE_TOOLS: ToolButton[] = [
|
|||||||
icon: 'outdoor_ap',
|
icon: 'outdoor_ap',
|
||||||
description: 'Outdoor Access Point',
|
description: 'Outdoor Access Point',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'other_device',
|
||||||
|
label: 'Other Device',
|
||||||
|
icon: 'other_device',
|
||||||
|
description: 'Other Device',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const INFO_TOOL: ToolButton = {
|
const INFO_TOOL: ToolButton = {
|
||||||
@@ -128,6 +134,19 @@ export function Toolbar({ readOnly = false }: ToolbarProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tool.icon === 'other_device') {
|
||||||
|
return (
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style={{ color: '#6B7280' }}>
|
||||||
|
<rect x="4" y="6" width="16" height="12" rx="2" fill="none" stroke="currentColor" strokeWidth="2"/>
|
||||||
|
<circle cx="12" cy="12" r="2"/>
|
||||||
|
<line x1="7" y1="9" x2="7" y2="9" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<line x1="17" y1="9" x2="17" y2="9" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<line x1="7" y1="15" x2="7" y2="15" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
<line x1="17" y1="15" x2="17" y2="15" stroke="currentColor" strokeWidth="2" strokeLinecap="round"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (tool.icon === 'info') {
|
if (tool.icon === 'info') {
|
||||||
return (
|
return (
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style={{ color: '#6366F1' }}>
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" style={{ color: '#6366F1' }}>
|
||||||
|
|||||||
@@ -138,6 +138,11 @@
|
|||||||
color: #F59E0B;
|
color: #F59E0B;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.other-device-icon {
|
||||||
|
border-color: #6B7280;
|
||||||
|
color: #6B7280;
|
||||||
|
}
|
||||||
|
|
||||||
/* Info marker icon */
|
/* Info marker icon */
|
||||||
.custom-info-marker {
|
.custom-info-marker {
|
||||||
background: transparent !important;
|
background: transparent !important;
|
||||||
|
|||||||
@@ -8,12 +8,6 @@ import { LayerSwitcher } from '../components/map/LayerSwitcher';
|
|||||||
type MapLayer = 'osm' | 'google' | 'esri';
|
type MapLayer = 'osm' | 'google' | 'esri';
|
||||||
|
|
||||||
const MAP_LAYERS = {
|
const MAP_LAYERS = {
|
||||||
osm: {
|
|
||||||
name: 'OpenStreetMap',
|
|
||||||
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
||||||
maxZoom: 25,
|
|
||||||
},
|
|
||||||
google: {
|
google: {
|
||||||
name: 'Google Satellite',
|
name: 'Google Satellite',
|
||||||
url: 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}',
|
url: 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}',
|
||||||
@@ -21,6 +15,12 @@ const MAP_LAYERS = {
|
|||||||
maxZoom: 25,
|
maxZoom: 25,
|
||||||
maxNativeZoom: 22,
|
maxNativeZoom: 22,
|
||||||
},
|
},
|
||||||
|
osm: {
|
||||||
|
name: 'OpenStreetMap',
|
||||||
|
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
|
maxZoom: 25,
|
||||||
|
},
|
||||||
esri: {
|
esri: {
|
||||||
name: 'ESRI Satellite',
|
name: 'ESRI Satellite',
|
||||||
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||||
@@ -33,7 +33,7 @@ export function Dashboard() {
|
|||||||
const [selectedMapId, setSelectedMapId] = useState<string | null>(null);
|
const [selectedMapId, setSelectedMapId] = useState<string | null>(null);
|
||||||
const [showShareDialog, setShowShareDialog] = useState(false);
|
const [showShareDialog, setShowShareDialog] = useState(false);
|
||||||
const [shareMapId, setShareMapId] = useState<string | null>(null);
|
const [shareMapId, setShareMapId] = useState<string | null>(null);
|
||||||
const [activeLayer, setActiveLayer] = useState<MapLayer>('osm');
|
const [activeLayer, setActiveLayer] = useState<MapLayer>('google');
|
||||||
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
const [isSidebarOpen, setIsSidebarOpen] = useState(false);
|
||||||
|
|
||||||
const handleShareMap = (mapId: string) => {
|
const handleShareMap = (mapId: string) => {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { MapContainer, TileLayer, useMap } from 'react-leaflet';
|
import { MapContainer, TileLayer, useMap } from 'react-leaflet';
|
||||||
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { LayerSwitcher } from '../components/map/LayerSwitcher';
|
import { LayerSwitcher } from '../components/map/LayerSwitcher';
|
||||||
import { DrawingHandler } from '../components/map/DrawingHandler';
|
import { DrawingHandler } from '../components/map/DrawingHandler';
|
||||||
@@ -9,16 +10,20 @@ import { Toolbar } from '../components/map/Toolbar';
|
|||||||
import { useMapWebSocket } from '../hooks/useMapWebSocket';
|
import { useMapWebSocket } from '../hooks/useMapWebSocket';
|
||||||
import { apiClient } from '../services/api';
|
import { apiClient } from '../services/api';
|
||||||
import { useUIStore } from '../stores/uiStore';
|
import { useUIStore } from '../stores/uiStore';
|
||||||
|
import { mapItemService } from '../services/mapItemService';
|
||||||
|
|
||||||
|
// Fix Leaflet's default icon paths for production builds
|
||||||
|
// Since we use custom DivIcons, we just need to prevent 404s
|
||||||
|
delete (L.Icon.Default.prototype as any)._getIconUrl;
|
||||||
|
L.Icon.Default.mergeOptions({
|
||||||
|
iconUrl: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==',
|
||||||
|
iconRetinaUrl: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==',
|
||||||
|
shadowUrl: 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMSIgaGVpZ2h0PSIxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciPjwvc3ZnPg==',
|
||||||
|
});
|
||||||
|
|
||||||
type MapLayer = 'osm' | 'google' | 'esri';
|
type MapLayer = 'osm' | 'google' | 'esri';
|
||||||
|
|
||||||
const MAP_LAYERS = {
|
const MAP_LAYERS = {
|
||||||
osm: {
|
|
||||||
name: 'OpenStreetMap',
|
|
||||||
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
|
||||||
maxZoom: 25,
|
|
||||||
},
|
|
||||||
google: {
|
google: {
|
||||||
name: 'Google Satellite',
|
name: 'Google Satellite',
|
||||||
url: 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}',
|
url: 'https://mt1.google.com/vt/lyrs=s&x={x}&y={y}&z={z}',
|
||||||
@@ -26,6 +31,12 @@ const MAP_LAYERS = {
|
|||||||
maxZoom: 25,
|
maxZoom: 25,
|
||||||
maxNativeZoom: 22,
|
maxNativeZoom: 22,
|
||||||
},
|
},
|
||||||
|
osm: {
|
||||||
|
name: 'OpenStreetMap',
|
||||||
|
url: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
|
||||||
|
maxZoom: 25,
|
||||||
|
},
|
||||||
esri: {
|
esri: {
|
||||||
name: 'ESRI Satellite',
|
name: 'ESRI Satellite',
|
||||||
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
||||||
@@ -46,10 +57,54 @@ function MapController() {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AutoZoom({ mapId }: { mapId: string }) {
|
||||||
|
const map = useMap();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const zoomToDevices = async () => {
|
||||||
|
try {
|
||||||
|
const items = await mapItemService.getMapItems(mapId);
|
||||||
|
|
||||||
|
// Filter only devices (exclude cables, wireless mesh, and info markers)
|
||||||
|
const devices = items.filter(item =>
|
||||||
|
['switch', 'indoor_ap', 'outdoor_ap', 'other_device'].includes(item.type) &&
|
||||||
|
item.geometry.type === 'Point'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (devices.length === 0) {
|
||||||
|
// No devices, keep default view
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create bounds from all device coordinates
|
||||||
|
const bounds = L.latLngBounds(
|
||||||
|
devices.map(device => {
|
||||||
|
const coords = (device.geometry as GeoJSON.Point).coordinates;
|
||||||
|
const [lng, lat] = coords;
|
||||||
|
return [lat, lng] as [number, number];
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Fit map to bounds with padding
|
||||||
|
map.fitBounds(bounds, {
|
||||||
|
padding: [50, 50],
|
||||||
|
maxZoom: 18,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to auto-zoom to devices:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
zoomToDevices();
|
||||||
|
}, [mapId, map]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function SharedMap() {
|
export function SharedMap() {
|
||||||
const { token } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
const { darkMode, toggleDarkMode } = useUIStore();
|
const { darkMode, toggleDarkMode } = useUIStore();
|
||||||
const [activeLayer, setActiveLayer] = useState<MapLayer>('osm');
|
const [activeLayer, setActiveLayer] = useState<MapLayer>('google');
|
||||||
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
||||||
const [mapData, setMapData] = useState<any>(null);
|
const [mapData, setMapData] = useState<any>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -222,6 +277,7 @@ export function SharedMap() {
|
|||||||
style={{ background: '#f0f0f0' }}
|
style={{ background: '#f0f0f0' }}
|
||||||
>
|
>
|
||||||
<MapController />
|
<MapController />
|
||||||
|
<AutoZoom mapId={mapData.id} />
|
||||||
<TileLayer
|
<TileLayer
|
||||||
key={activeLayer}
|
key={activeLayer}
|
||||||
url={MAP_LAYERS[activeLayer].url}
|
url={MAP_LAYERS[activeLayer].url}
|
||||||
@@ -232,7 +288,7 @@ export function SharedMap() {
|
|||||||
|
|
||||||
{/* Drawing handler for edit access */}
|
{/* Drawing handler for edit access */}
|
||||||
{!isReadOnly && (
|
{!isReadOnly && (
|
||||||
<DrawingHandler mapId={mapData.id} onItemCreated={handleItemCreated} />
|
<DrawingHandler mapId={mapData.id} onItemCreated={handleItemCreated} refreshTrigger={refreshTrigger} />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Render existing map items */}
|
{/* Render existing map items */}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export interface MapShareLink {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface CreateMapShare {
|
export interface CreateMapShare {
|
||||||
user_id: string;
|
user_identifier: string;
|
||||||
permission: 'read' | 'edit';
|
permission: 'read' | 'edit';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export type MapItemType = 'cable' | 'switch' | 'indoor_ap' | 'outdoor_ap' | 'wireless_mesh' | 'info';
|
export type MapItemType = 'cable' | 'switch' | 'indoor_ap' | 'outdoor_ap' | 'other_device' | 'wireless_mesh' | 'info';
|
||||||
|
|
||||||
export type CableType = 'fiber' | 'cat6' | 'cat6_poe';
|
export type CableType = 'fiber' | 'cat6' | 'cat6_poe';
|
||||||
|
|
||||||
@@ -60,6 +60,7 @@ export type DrawingTool =
|
|||||||
| 'switch'
|
| 'switch'
|
||||||
| 'indoor_ap'
|
| 'indoor_ap'
|
||||||
| 'outdoor_ap'
|
| 'outdoor_ap'
|
||||||
|
| 'other_device'
|
||||||
| 'wireless_mesh'
|
| 'wireless_mesh'
|
||||||
| 'info';
|
| 'info';
|
||||||
|
|
||||||
|
|||||||
Executable
+321
@@ -0,0 +1,321 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Script to manage users for the ISP Wiremap application.
|
||||||
|
Supports password reset, user deletion, and user listing.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/manage_users.py list
|
||||||
|
python scripts/manage_users.py reset-password <username|email|user_id>
|
||||||
|
python scripts/manage_users.py delete <username|email|user_id>
|
||||||
|
python scripts/manage_users.py toggle-admin <username|email|user_id>
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# Add parent directory to path to import app modules
|
||||||
|
sys.path.append(str(Path(__file__).resolve().parents[1]))
|
||||||
|
|
||||||
|
from app.database import SessionLocal
|
||||||
|
from app.models.user import User
|
||||||
|
from app.utils.password import hash_password
|
||||||
|
import getpass
|
||||||
|
from sqlalchemy import or_
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
|
def get_user(db, identifier: str) -> Optional[User]:
|
||||||
|
"""
|
||||||
|
Find a user by username, email, or UUID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db: Database session
|
||||||
|
identifier: Username, email, or user UUID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
User object if found, None otherwise
|
||||||
|
"""
|
||||||
|
# Try to parse as UUID first
|
||||||
|
try:
|
||||||
|
user_uuid = UUID(identifier)
|
||||||
|
user = db.query(User).filter(User.id == user_uuid).first()
|
||||||
|
if user:
|
||||||
|
return user
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Search by username or email
|
||||||
|
user = db.query(User).filter(
|
||||||
|
or_(User.username == identifier, User.email == identifier)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def list_users():
|
||||||
|
"""List all users in the system."""
|
||||||
|
print("=" * 80)
|
||||||
|
print("ISP Wiremap - User List")
|
||||||
|
print("=" * 80)
|
||||||
|
print()
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
users = db.query(User).order_by(User.created_at.desc()).all()
|
||||||
|
|
||||||
|
if not users:
|
||||||
|
print("No users found in the system.")
|
||||||
|
return
|
||||||
|
|
||||||
|
print(f"Total users: {len(users)}")
|
||||||
|
print()
|
||||||
|
print(f"{'Username':<20} {'Email':<30} {'Admin':<8} {'Created':<20} {'User ID'}")
|
||||||
|
print("-" * 80)
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
created_at = user.created_at.strftime("%Y-%m-%d %H:%M:%S") if user.created_at else "N/A"
|
||||||
|
admin_status = "Yes" if user.is_admin else "No"
|
||||||
|
print(f"{user.username:<20} {user.email:<30} {admin_status:<8} {created_at:<20} {user.id}")
|
||||||
|
|
||||||
|
print()
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error listing users: {e}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def reset_password(identifier: str):
|
||||||
|
"""
|
||||||
|
Reset a user's password.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
identifier: Username, email, or user UUID
|
||||||
|
"""
|
||||||
|
print("=" * 80)
|
||||||
|
print("ISP Wiremap - Reset User Password")
|
||||||
|
print("=" * 80)
|
||||||
|
print()
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
user = get_user(db, identifier)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
print(f"Error: User '{identifier}' not found")
|
||||||
|
print("You can search by username, email, or user ID")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Display user info
|
||||||
|
print(f"Found user:")
|
||||||
|
print(f" Username: {user.username}")
|
||||||
|
print(f" Email: {user.email}")
|
||||||
|
print(f" User ID: {user.id}")
|
||||||
|
print(f" Admin: {'Yes' if user.is_admin else 'No'}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Confirm action
|
||||||
|
confirm = input("Do you want to reset this user's password? (yes/no): ").strip().lower()
|
||||||
|
if confirm not in ['yes', 'y']:
|
||||||
|
print("Password reset cancelled.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get new password
|
||||||
|
password = getpass.getpass("Enter new password: ")
|
||||||
|
if not password:
|
||||||
|
print("Error: Password cannot be empty")
|
||||||
|
return
|
||||||
|
|
||||||
|
password_confirm = getpass.getpass("Confirm new password: ")
|
||||||
|
if password != password_confirm:
|
||||||
|
print("Error: Passwords do not match")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Update password
|
||||||
|
user.password_hash = hash_password(password)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 80)
|
||||||
|
print("Password reset successfully!")
|
||||||
|
print(f"Username: {user.username}")
|
||||||
|
print(f"User ID: {user.id}")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
print(f"Error resetting password: {e}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def delete_user(identifier: str):
|
||||||
|
"""
|
||||||
|
Delete a user from the system.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
identifier: Username, email, or user UUID
|
||||||
|
"""
|
||||||
|
print("=" * 80)
|
||||||
|
print("ISP Wiremap - Delete User")
|
||||||
|
print("=" * 80)
|
||||||
|
print()
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
user = get_user(db, identifier)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
print(f"Error: User '{identifier}' not found")
|
||||||
|
print("You can search by username, email, or user ID")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Display user info
|
||||||
|
print(f"Found user:")
|
||||||
|
print(f" Username: {user.username}")
|
||||||
|
print(f" Email: {user.email}")
|
||||||
|
print(f" User ID: {user.id}")
|
||||||
|
print(f" Admin: {'Yes' if user.is_admin else 'No'}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Confirm action
|
||||||
|
print("WARNING: This action cannot be undone!")
|
||||||
|
print("All maps, items, and shares owned by this user will also be affected.")
|
||||||
|
confirm = input("Type the username exactly to confirm deletion: ").strip()
|
||||||
|
|
||||||
|
if confirm != user.username:
|
||||||
|
print("Username does not match. Deletion cancelled.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Delete user
|
||||||
|
username = user.username
|
||||||
|
user_id = user.id
|
||||||
|
db.delete(user)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 80)
|
||||||
|
print("User deleted successfully!")
|
||||||
|
print(f"Deleted username: {username}")
|
||||||
|
print(f"Deleted user ID: {user_id}")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
print(f"Error deleting user: {e}")
|
||||||
|
print("Note: The user may have related records that prevent deletion.")
|
||||||
|
print("You may need to manually handle foreign key constraints.")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def toggle_admin(identifier: str):
|
||||||
|
"""
|
||||||
|
Toggle admin status for a user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
identifier: Username, email, or user UUID
|
||||||
|
"""
|
||||||
|
print("=" * 80)
|
||||||
|
print("ISP Wiremap - Toggle Admin Status")
|
||||||
|
print("=" * 80)
|
||||||
|
print()
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
user = get_user(db, identifier)
|
||||||
|
|
||||||
|
if not user:
|
||||||
|
print(f"Error: User '{identifier}' not found")
|
||||||
|
print("You can search by username, email, or user ID")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Display user info
|
||||||
|
current_status = "Yes" if user.is_admin else "No"
|
||||||
|
new_status = "No" if user.is_admin else "Yes"
|
||||||
|
|
||||||
|
print(f"Found user:")
|
||||||
|
print(f" Username: {user.username}")
|
||||||
|
print(f" Email: {user.email}")
|
||||||
|
print(f" User ID: {user.id}")
|
||||||
|
print(f" Current admin status: {current_status}")
|
||||||
|
print(f" New admin status will be: {new_status}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Confirm action
|
||||||
|
confirm = input(f"Toggle admin status to '{new_status}'? (yes/no): ").strip().lower()
|
||||||
|
if confirm not in ['yes', 'y']:
|
||||||
|
print("Operation cancelled.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Toggle admin status
|
||||||
|
user.is_admin = not user.is_admin
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
print()
|
||||||
|
print("=" * 80)
|
||||||
|
print("Admin status updated successfully!")
|
||||||
|
print(f"Username: {user.username}")
|
||||||
|
print(f"Admin status: {'Yes' if user.is_admin else 'No'}")
|
||||||
|
print("=" * 80)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
print(f"Error toggling admin status: {e}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point for the script."""
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Manage users in the ISP Wiremap application",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
%(prog)s list
|
||||||
|
%(prog)s reset-password john_doe
|
||||||
|
%(prog)s reset-password john@example.com
|
||||||
|
%(prog)s reset-password 3d2870bc-7dee-476a-a9d0-432a4c9519e9
|
||||||
|
%(prog)s delete john_doe
|
||||||
|
%(prog)s toggle-admin john_doe
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
subparsers = parser.add_subparsers(dest='command', help='Command to execute')
|
||||||
|
|
||||||
|
# List command
|
||||||
|
subparsers.add_parser('list', help='List all users')
|
||||||
|
|
||||||
|
# Reset password command
|
||||||
|
reset_parser = subparsers.add_parser('reset-password', help='Reset a user\'s password')
|
||||||
|
reset_parser.add_argument('identifier', help='Username, email, or user ID')
|
||||||
|
|
||||||
|
# Delete command
|
||||||
|
delete_parser = subparsers.add_parser('delete', help='Delete a user')
|
||||||
|
delete_parser.add_argument('identifier', help='Username, email, or user ID')
|
||||||
|
|
||||||
|
# Toggle admin command
|
||||||
|
admin_parser = subparsers.add_parser('toggle-admin', help='Toggle admin status for a user')
|
||||||
|
admin_parser.add_argument('identifier', help='Username, email, or user ID')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if not args.command:
|
||||||
|
parser.print_help()
|
||||||
|
return
|
||||||
|
|
||||||
|
if args.command == 'list':
|
||||||
|
list_users()
|
||||||
|
elif args.command == 'reset-password':
|
||||||
|
reset_password(args.identifier)
|
||||||
|
elif args.command == 'delete':
|
||||||
|
delete_user(args.identifier)
|
||||||
|
elif args.command == 'toggle-admin':
|
||||||
|
toggle_admin(args.identifier)
|
||||||
|
else:
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user